mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 20:32:12 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user