artcraft: gate PageDraw Generate on a loaded baseImageBitmap and cancel stale base loads

setBaseImageInfo updates baseImageInfo synchronously and only swaps in
baseImageBitmap once the <img> 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 <PromptEditor isDisabled> 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.
This commit is contained in:
Tanish Manku committed 2026-05-09 13:49:37 -07:00
1 parent bc1b1df0d4
commit 456885f618
3 files changed
+23 -18

No files matched your search

@@ -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<File | null> => {
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}
@@ -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 `<img>.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<SceneState>((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<SceneState>((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;
@@ -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