From 456885f6186cd96c66a19bb28e48ceee5385ad42 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Sat, 9 May 2026 13:27:28 -0700 Subject: [PATCH] artcraft: gate PageDraw Generate on a loaded baseImageBitmap and cancel stale base loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setBaseImageInfo updates baseImageInfo synchronously and only swaps in baseImageBitmap once the finishes loading. During that gap the canvas still renders the previous bitmap while baseImageInfo.mediaToken already points at the new image — clicking Generate would send the new image's token alongside a mask drawn over the old pixels (inpaint path) or fail silently (composite path). Worse, with no cancellation, a stale onload from a previous selection could fire after the user moved on, overwriting baseImageBitmap (and via applyImageNodes, drawNodes) with the wrong content. Add a module-level generation counter in SceneState; bump on every setBaseImageInfo and guard onload/onerror so superseded loads no-op. Wire PageDraw's to (!baseImageBitmap && !baseImageInfo?.isBlankCanvas) so the button visibly reflects this. Connect PromptBox2D's existing isDisabled prop to the button's disabled attribute (it was already checked in handleGenerate but never visualized). Drop the latest-ref pattern around baseImageBitmap/baseImageInfo in PageDraw; getCompositeCanvasFile now reads the live snapshot via useSceneStore.getState() which is the simpler answer for state that already lives in Zustand. --- .../components/pagedraw/src/lib/PageDraw.tsx | 26 +++++++------------ .../pagedraw/src/lib/stores/SceneState.ts | 13 ++++++++++ .../promptbox/src/lib/PromptBox2D.tsx | 2 +- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx index 0af0958b24..f5590d9609 100644 --- a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx +++ b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx @@ -404,17 +404,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => { const generateInFlightRef = useRef(false); const [isGenerating, setIsGenerating] = useState(false); - // Latest-ref mirrors of base-image state. Read from `*.current` inside any - // useCallback whose deps would otherwise have to list these and cascade - // recreation through the bake/generate chain. Updated after every render - // (no deps) so callbacks always see fresh values. - const baseImageBitmapRef = useRef(baseImageBitmap); - const baseImageInfoRef = useRef(baseImageInfo); - useEffect(() => { - baseImageBitmapRef.current = baseImageBitmap; - baseImageInfoRef.current = baseImageInfo; - }); - const selectedImageModel: ImageModel | undefined = useSelectedImageModel(PAGE_ID); @@ -606,12 +595,15 @@ const PageDraw = ({ adapter }: PageDrawProps) => { [getAspectRatioDimensions, createImageFromFile], ); - // Stable: reads base state via latest-refs so deps stay empty and the bake - // chain (runCompositeBake → getCompositeFile / scheduleCompositeBake → - // handleGenerate) doesn't recreate on every base-image swap. + // Stable: reads base state imperatively from the Zustand store so deps stay + // empty and the bake chain (runCompositeBake → getCompositeFile / + // scheduleCompositeBake → handleGenerate) doesn't recreate on every + // base-image swap. `useSceneStore.getState()` always returns the latest + // store snapshot, so deferred bakes pick up the current bitmap at run time + // (not the one captured at schedule time). const getCompositeCanvasFile = useCallback(async (): Promise => { - const baseBitmapNow = baseImageBitmapRef.current; - const baseInfoNow = baseImageInfoRef.current; + const { baseImageBitmap: baseBitmapNow, baseImageInfo: baseInfoNow } = + useSceneStore.getState(); if (!stageRef.current || !baseImageKonvaRef.current) return null; if (!baseInfoNow?.isBlankCanvas && !baseBitmapNow) return null; @@ -1179,7 +1171,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => { EncodeImageBitmapToBase64={EncodeImageBitmapToBase64} onGenerateClick={handleGenerate} onFitPressed={onFitPressed} - isDisabled={false} + isDisabled={!baseImageBitmap && !baseImageInfo?.isBlankCanvas} isEnqueueing={isGenerating} generationCount={generationCount} onGenerationCountChange={setGenerationCount} diff --git a/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts b/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts index 1012e92f53..1f6e18d0dd 100644 --- a/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts +++ b/frontend/libs/components/pagedraw/src/lib/stores/SceneState.ts @@ -3,6 +3,14 @@ import { Node, NodeType } from "../Node"; import { ImageBundle, BaseSelectorImage } from "../types"; import { Model3DParams } from "../utilities/render3DModel"; +// Generation counter for in-flight base-image loads. Incremented on every +// setBaseImageInfo call so that a stale `.onload` from a superseded +// selection (e.g. rapid HistoryStack navigation while the previous image is +// still in flight) becomes a no-op. Without this guard, A's onload could fire +// after B was selected and overwrite baseImageBitmap (and via applyImageNodes, +// even drawNodes) with A's content. +let baseImageLoadGen = 0; + // LineNode type — ordering in PageDraw is by position in drawNodes array (zIndex ignored there) export type LineNode = { id: string; @@ -1372,6 +1380,9 @@ export const useSceneStore = create((set, get, store) => ({ }, setBaseImageInfo: (image: BaseSelectorImage | null) => { + // Bump first so any in-flight load from a previous call invalidates itself + // on completion (see imgBitmap.onload / onerror below). + const myGen = ++baseImageLoadGen; if (!image) { set({ baseImageInfo: null }); return; @@ -1442,9 +1453,11 @@ export const useSceneStore = create((set, get, store) => ({ const imgBitmap = new Image(); imgBitmap.onload = () => { + if (myGen !== baseImageLoadGen) return; // superseded by a newer setBaseImageInfo applyImageNodes(imgBitmap); }; imgBitmap.onerror = (event) => { + if (myGen !== baseImageLoadGen) return; console.error("Failed to load base image, discarding", event); set({ baseImageInfo: null, baseImageBitmap: null }); imgBitmap.onload = null; diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx index 0580e59688..e12e9861f3 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox2D.tsx @@ -509,7 +509,7 @@ export const PromptBox2D = ({ className="flex items-center border-none bg-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50" icon={undefined} onClick={handleGenerate} - disabled={isEnqueueing || !prompt.trim()} + disabled={isEnqueueing || isDisabled || !prompt.trim()} loading={isEnqueueing} > Generate