From 36780e5669eb3b9849cdeb5621661afede897db0 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 3 Sep 2025 23:05:05 +0700 Subject: [PATCH] ux update inpainting history (#815) - reverted to previous ux - added placeholder loaders for generating images (matched by frontend subscriber id) - can now generate multiple images at a time - auto scrolls to top of history stack on enqueue --- .../src/pages/PageEdit/EditPaintSurface.tsx | 165 +----------------- .../app/src/pages/PageEdit/HistoryStack.tsx | 94 ++++++++-- .../app/src/pages/PageEdit/PageEdit.tsx | 54 +++--- .../apps/editor3d/app/src/styles/base.css | 40 +++++ 4 files changed, 154 insertions(+), 199 deletions(-) diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/EditPaintSurface.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/EditPaintSurface.tsx index d7e982f416..55b8e94f05 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/EditPaintSurface.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/EditPaintSurface.tsx @@ -36,7 +36,6 @@ export type EditPaintSurfaceProps = { transformerRefs: React.RefObject<{ [key: string]: Konva.Transformer }>; leftPanelRef: React.RefObject; baseImageRef: React.RefObject; - isGenerating?: boolean; }; export const EditPaintSurface = ({ @@ -52,7 +51,6 @@ export const EditPaintSurface = ({ transformerRefs, leftPanelRef, baseImageRef, - isGenerating = false, }: EditPaintSurfaceProps) => { // switch off to be preview panel mode. const singlePaneMode = true; @@ -69,9 +67,7 @@ export const EditPaintSurface = ({ const rightContainerRef = React.useRef(null); const cursorLayerRef = React.useRef(null); const cursorShapeRef = React.useRef(null); - const borderLayerRef = React.useRef(null); - const runnerRectRef = React.useRef(null); - const runnerRectRef2 = React.useRef(null); + // Removed generating border refs // Layout dimensions const leftPanelWidth = 1024; @@ -647,124 +643,6 @@ export const EditPaintSurface = ({ } }; - // Animated generating border around the actual canvas area (continuous, cornerless) - useEffect(() => { - if ( - !isGenerating || - !runnerRectRef.current || - !borderLayerRef.current || - !runnerRectRef2.current - ) { - return; - } - const rectA = runnerRectRef.current; - const rectB = runnerRectRef2.current; - const layer = borderLayerRef.current; - let distance = 0; - const speedPixelsPerSecond = 1200; // very fast runner speed - const runnerThickness = 10; // thicker - const anim = new Konva.Animation((frame) => { - if (!frame) return; - const w = store.getAspectRatioDimensions().width; - const h = store.getAspectRatioDimensions().height; - const perimeter = 2 * (w + h); - const baseRunner = Math.max(24, Math.round(perimeter * 0.12)); - const maxRunner = Math.floor(Math.max(w, h) * 0.5); - const runnerLength = Math.min(baseRunner, maxRunner); - - distance = - (distance + speedPixelsPerSecond * (frame.timeDiff / 1000)) % perimeter; - let d = distance; - - // Top edge: left -> right - if (d < w) { - const seg = Math.min(runnerLength, w - d); - rectA.x(d); - rectA.y(0); - rectA.width(seg); - rectA.height(runnerThickness); - const overflow = runnerLength - seg; - if (overflow > 0) { - rectB.x(w - runnerThickness); - rectB.y(0); - rectB.width(runnerThickness); - rectB.height(Math.min(overflow, h)); - } else { - rectB.width(0); - rectB.height(0); - } - return; - } - d -= w; - - // Right edge: top -> bottom - if (d < h) { - const seg = Math.min(runnerLength, h - d); - rectA.x(w - runnerThickness); - rectA.y(d); - rectA.width(runnerThickness); - rectA.height(seg); - const overflow = runnerLength - seg; - if (overflow > 0) { - rectB.x(Math.max(0, w - overflow)); - rectB.y(h - runnerThickness); - rectB.width(Math.min(overflow, w)); - rectB.height(runnerThickness); - } else { - rectB.width(0); - rectB.height(0); - } - return; - } - d -= h; - - // Bottom edge: right -> left - if (d < w) { - const seg = Math.min(runnerLength, w - d); - rectA.x(w - d - seg); - rectA.y(h - runnerThickness); - rectA.width(seg); - rectA.height(runnerThickness); - const overflow = runnerLength - seg; - if (overflow > 0) { - rectB.x(0); - rectB.y(Math.max(0, h - overflow)); - rectB.width(runnerThickness); - rectB.height(Math.min(overflow, h)); - } else { - rectB.width(0); - rectB.height(0); - } - return; - } - d -= w; - - // Left edge: bottom -> top - { - const seg = Math.min(runnerLength, h - d); - rectA.x(0); - rectA.y(h - d - seg); - rectA.width(runnerThickness); - rectA.height(seg); - const overflow = runnerLength - seg; - if (overflow > 0) { - rectB.x(0); - rectB.y(0); - rectB.width(Math.min(overflow, w)); - rectB.height(runnerThickness); - } else { - rectB.width(0); - rectB.height(0); - } - } - }, layer); - anim.start(); - return () => { - anim.stop(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isGenerating]); - useLayoutEffect(() => { const cursorNode = cursorShapeRef.current; const cursorLayer = cursorLayerRef.current; @@ -1451,47 +1329,6 @@ export const EditPaintSurface = ({ - {isGenerating && ( - - {(() => { - const w = store.getAspectRatioDimensions().width; - const h = store.getAspectRatioDimensions().height; - const perimeter = 2 * (w + h); - const runnerLength = Math.max( - 24, - Math.round(perimeter * 0.08), - ); - return ( - <> - - - - ); - })()} - - )} diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/HistoryStack.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/HistoryStack.tsx index 402c82793d..ac0559e993 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/HistoryStack.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/HistoryStack.tsx @@ -5,7 +5,7 @@ import { faTrashXmark, faXmark, } from "@fortawesome/pro-solid-svg-icons"; -import { Fragment, useRef } from "react"; +import { Fragment, useEffect, useRef } from "react"; import { twMerge } from "tailwind-merge"; import { BaseSelectorImage } from "./BaseImageSelector"; import { Tooltip } from "@storyteller/ui-tooltip"; @@ -24,7 +24,10 @@ interface HistoryStackProps { onImageSelect?: (image: BaseSelectorImage) => void; imageBundles: ImageBundle[]; onNewImageBundle?: (newBundle: ImageBundle) => void; + pendingPlaceholders?: { id: string; count: number }[]; + onResolvePending?: (id: string) => void; selectedImageToken?: string; + blurredBackgroundUrl?: string; } export const HistoryStack = ({ @@ -32,7 +35,10 @@ export const HistoryStack = ({ onImageSelect = () => {}, imageBundles, onNewImageBundle = () => {}, + pendingPlaceholders = [], + onResolvePending = () => {}, selectedImageToken, + blurredBackgroundUrl, }: HistoryStackProps) => { useImageEditCompleteEvent(async (event) => { const newBundle: ImageBundle = { @@ -46,24 +52,37 @@ export const HistoryStack = ({ }; onNewImageBundle(newBundle); - if (newBundle.images.length > 0) { - onImageSelect(newBundle.images[0]); - // ensure the scroll container jumps to top for latest bundle - setTimeout(() => { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); - }, 0); + // Resolve any matching pending placeholders using subscriber id + if (event.maybe_frontend_subscriber_id) { + onResolvePending(event.maybe_frontend_subscriber_id); } + // Do not auto-select the new image; only scroll to top for visibility + setTimeout(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, 0); }); // This is used to force image reloads in different sessions // and prevent fetching CORS-tainted images from cache const sessionRandBuster = useRef(Math.random()); const scrollRef = useRef(null); + const prevPendingCountRef = useRef(0); const handleClear = () => { onClear(); }; + // Scroll to top when new pending placeholders are added (after enqueue) + useEffect(() => { + const current = pendingPlaceholders.length; + if (current > prevPendingCountRef.current) { + setTimeout(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, 0); + } + prevPendingCountRef.current = current; + }, [pendingPlaceholders.length]); + return (
@@ -76,9 +95,65 @@ export const HistoryStack = ({
+ {/* Pending placeholders first (newest at top) */} + {(() => { + const reversed = [...pendingPlaceholders].slice().reverse(); + return reversed.map((p, idx) => ( + + {Array.from({ length: Math.max(1, p.count || 1) }).map( + (_, i) => ( +
+
+ {blurredBackgroundUrl && ( + + )} +
+
+
+ {/* SVG running border (single solid line) */} + + + +
+
+ ), + )} + {idx < reversed.length - 1 && ( +
+ )} + + )); + })()} + + {pendingPlaceholders.length > 0 && ( +
+ )} + + {/* Completed images below placeholders, newest bundles first */} {[...imageBundles] .slice() .reverse() @@ -96,7 +171,6 @@ export const HistoryStack = ({ onImageSelect(image); }} > - {/* TODO: Fix CORS issue here */} )} diff --git a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx index 0247a8cc34..5e74b26323 100644 --- a/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx +++ b/frontend/apps/editor3d/app/src/pages/PageEdit/PageEdit.tsx @@ -40,12 +40,14 @@ const PageEdit = () => { const leftPanelRef = useRef({} as Konva.Layer); const baseImageKonvaRef = useRef({} as Konva.Image); const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({}); - const [isEnqueuing, setIsEnqueuing] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); + const [pendingGenerations, setPendingGenerations] = useState< + { id: string; count: number }[] + >([]); const [generationCount, setGenerationCount] = useState(1); // Use the Zustand store const store = useEditStore(); + const baseImageUrl = store.baseImageInfo?.url; const addHistoryImageBundle = useEditStore( (state) => state.addHistoryImageBundle, ); @@ -203,14 +205,6 @@ const PageEdit = () => { const handleGenerate = useCallback( async (prompt: string) => { - if (isEnqueuing) { - console.warn("Already enqueuing an image, please wait."); - return; - } - - setIsEnqueuing(true); - setIsGenerating(true); - const editedImageToken = store.baseImageInfo?.mediaToken; if (!editedImageToken) { @@ -218,31 +212,38 @@ const PageEdit = () => { return; } - // TODO: Call inference API here const arrayBuffer = await getMaskArrayBuffer(); + const subscriberId = + (typeof crypto !== "undefined" && (crypto as any).randomUUID + ? (crypto as any).randomUUID() + : undefined) || + `inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`; + try { - await EnqueueImageInpaint({ + const result = await EnqueueImageInpaint({ model: selectedImageModel, image_media_token: editedImageToken, mask_image_raw_bytes: arrayBuffer, prompt: prompt, image_count: generationCount, frontend_caller: "image_editor", + frontend_subscriber_id: subscriberId, }); + if (result.status === "success") { + setPendingGenerations((prev) => [ + ...prev, + { id: subscriberId as string, count: generationCount }, + ]); + } } catch (error) { - setIsGenerating(false); + setPendingGenerations((prev) => + prev.filter((p) => p.id !== subscriberId), + ); throw error; - } finally { - setIsEnqueuing(false); } }, - [ - generationCount, - isEnqueuing, - selectedImageModel, - store.baseImageInfo?.mediaToken, - ], + [generationCount, selectedImageModel, store.baseImageInfo?.mediaToken], ); //const modelConfig = lookupModelByTauriId(selectedImageModel!.tauriId); @@ -315,17 +316,21 @@ const PageEdit = () => { { store.RESET(); - setIsGenerating(false); + setPendingGenerations([]); }} imageBundles={historyImageBundles} + pendingPlaceholders={pendingGenerations} + blurredBackgroundUrl={baseImageUrl} onImageSelect={(baseImage) => { store.clearLineNodes(); store.setBaseImageInfo(baseImage); }} onNewImageBundle={(newBundle: ImageBundle) => { addHistoryImageBundle(newBundle); - setIsGenerating(false); }} + onResolvePending={(id: string) => + setPendingGenerations((prev) => prev.filter((p) => p.id !== id)) + } selectedImageToken={store.baseImageInfo?.mediaToken} />
@@ -342,7 +347,7 @@ const PageEdit = () => { selectedMode={store.activeTool} onGenerateClick={handleGenerate} onFitPressed={onFitPressed} - isDisabled={isEnqueuing || isGenerating} + isDisabled={false} generationCount={generationCount} onGenerationCountChange={setGenerationCount} supportsMaskedInpainting={supportsMaskedInpainting} @@ -399,7 +404,6 @@ const PageEdit = () => { transformerRefs={transformerRefs} leftPanelRef={leftPanelRef} baseImageRef={baseImageKonvaRef} - isGenerating={isGenerating} />
diff --git a/frontend/apps/editor3d/app/src/styles/base.css b/frontend/apps/editor3d/app/src/styles/base.css index b7d1771a05..a75eccdd8f 100644 --- a/frontend/apps/editor3d/app/src/styles/base.css +++ b/frontend/apps/editor3d/app/src/styles/base.css @@ -276,3 +276,43 @@ img { .ReactCrop__crop-selection:focus { outline: none !important; } + +/* Editor3D HistoryStack pending running border (continuous) */ +.st-loading-tile { + position: relative; +} +.st-border-svg { + position: absolute; + inset: -2px; + width: calc(100% + 4px); + height: calc(100% + 4px); + pointer-events: none; +} + +/* base rect style for runner */ +.st-border-svg rect { + fill: none; + stroke: rgba(59, 130, 246, 1); + stroke-width: 8; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Single runner: a solid line chasing its tail */ +/* Continuous runner with wrapping to avoid flicker at corners */ +.st-border-solid { + stroke-dasharray: 90 110; /* shorter visible segment + larger gap = clearer motion */ + stroke-dashoffset: 0; + animation: st-solid-wrap 1.6s linear infinite; + filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.8)) + drop-shadow(0 0 18px rgba(59, 130, 246, 0.5)); +} + +@keyframes st-solid-wrap { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -200; + } +}