Compare commits

...
Author SHA1 Message Date
Tanish Manku 14a9967dbb artcraft: tighten PageDraw generate pipeline against bypass and resource leaks
Four small hardenings flagged during audit:

- handleGenerate now re-checks (!baseImageBitmap && !baseImageInfo.isBlankCanvas)
  alongside the existing mediaToken check, so a programmatic dispatch or
  alternate trigger that bypasses the visual gate can't reach the snapshot
  step against a stale base. baseImageBitmap added to the deps list.

- Generate pipeline worker closes ImageBitmaps in a finally block instead of
  inline after drawImage, so a throw during compositing or PNG encoding can't
  leak ~67MB of bitmap data per failed bake.

- compositeInWorker / maskInWorker wrap worker.postMessage in a synchronous
  try/catch that deletes the pendingRequests entry and rejects the returned
  Promise on failure (e.g. transferable already detached, structured-clone
  refusal). Without this the entry would stay registered forever and the
  Promise would hang.

- setBaseImageInfo(null) now clears baseImageBitmap alongside baseImageInfo
  to keep the store self-consistent (previously baseImageBitmap could
  linger from the prior selection after a programmatic clear).
2026-05-09 13:49:37 -07:00
Tanish Manku bc15d93606 artcraft: clear baseImageBitmap synchronously when starting a new base load
setBaseImageInfo for a non-blank-canvas image only swapped baseImageInfo and
left baseImageBitmap pointing at the previous image until the new <img>
finished loading. The Generate-button gate (added in 96ce5026b2) checks
baseImageBitmap truthiness, so during that gap the gate let through clicks
that snapshotted the previous image's pixels alongside the new image's
mediaToken — a real wrong-pixels-on-generate bug for inpaint and a stale
composite for the edit path.

Force baseImageBitmap to null at the same set() call that updates
baseImageInfo so the gate now means "loaded for the current info" rather
than "any bitmap is set". The onload handler restores the bitmap when the
new image finishes loading.

Side effect: the canvas briefly renders the previous drawNodes with no
base bitmap until the new image arrives. Acceptable trade for correctness;
revisit if the visual transition feels jarring.
2026-05-09 13:49:37 -07:00
Tanish Manku 456885f618 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.
2026-05-09 13:49:37 -07:00
Tanish Manku bc1b1df0d4 PageDraw: Remove extra useEffect
dependency
2026-05-09 13:49:37 -07:00
Tanish Manku 96e0b1065b artcraft: stabilize PageDraw bake hooks via latest-refs to stop cascading recreates
The bake chain (getCompositeCanvasFile → runCompositeBake → getCompositeFile /
scheduleCompositeBake → handleGenerate) used to rebuild every callback in the
chain on every base-image change, because getCompositeCanvasFile listed
baseImageBitmap/baseImageInfo as deps. Reading those via latest-refs makes
getCompositeCanvasFile stable (deps []) and the cascade collapses — listed
deps remain (lint-clean) but never trigger recreates.

Also list baseImageInfo explicitly in the dirty-effect deps so cache
invalidation no longer relies on the cascade's identity changes (baseImageInfo
syncs synchronously in setBaseImageInfo, but baseImageBitmap arrives async
after the image loads, so the two diverge in time).

Wrap getMaskArrayBuffer in useCallback([]) so handleGenerate can list it as a
dep without churning per render.
2026-05-09 13:49:37 -07:00
Tanish Manku 5d8e22afb2 artcraft: drop worker base-bitmap cache so rapid HistoryStack navigation doesn't lag
Each base change triggered an immediate clone+postMessage on top of the
already-debounced pre-bake, costing ~5-15ms per click through history.
Folding the base back into each composite call (atomic per-message) lets
the debounced pre-bake absorb the clone cost on its own schedule and
sidesteps a race where concurrent bakes with different bases could leave
a stale base in the worker-side cache.
2026-05-09 13:49:37 -07:00
Tanish Manku bbdde58b92 artcraft: pre-bake PageDraw composite on idle and cache base bitmap in worker 2026-05-09 13:49:37 -07:00
Tanish Manku 574de2be90 artcraft: send PageDraw composite as canvas_image_media_token to match field semantics 2026-05-09 13:49:36 -07:00
Tanish Manku b72c0ab911 artcraft: stop duplicate generates and offload PageDraw compositing to a worker 2026-05-09 13:49:36 -07:00
7 changed files with 514 additions and 118 deletions
@@ -97,7 +97,7 @@ const useTauriAdapter = (): PageDrawAdapter => {
enqueueEditImage: async (req) => {
const request: GenerateImageRequest = {
model: req.model,
scene_image_media_token: req.sceneImageMediaToken,
canvas_image_media_token: req.canvasImageMediaToken,
image_media_tokens: req.imageMediaTokens,
prompt: req.prompt,
enable_system_prompt:
@@ -1,4 +1,11 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from "react";
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
memo,
} from "react";
import { useShallow } from "zustand/react/shallow";
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
import "./pagedraw.css";
@@ -28,8 +35,8 @@ import { CostCalculatorButton } from "@storyteller/ui-pricing-modal";
import { GenerationProvider } from "@storyteller/api-enums";
import { HistoryStack } from "./HistoryStack";
import { type BaseSelectorImage } from "./types";
import { normalizeCanvas } from "./utilities/canvasHelpers";
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
import { compositeInWorker, maskInWorker } from "./utilities/generatePipeline";
import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox";
import { PromptsApi } from "@storyteller/api";
import toast from "react-hot-toast";
@@ -106,13 +113,23 @@ const Edit3DButton = memo(function Edit3DButton({
const transformers = stage.find("Transformer");
transformers.forEach((tr) => {
tr.on(`transformstart${ns}`, () => setInteracting(true));
tr.on(`transformend${ns}`, () => requestAnimationFrame(() => { setInteracting(false); bump(); }));
tr.on(`transformend${ns}`, () =>
requestAnimationFrame(() => {
setInteracting(false);
bump();
}),
);
});
const konvaNode = stage.findOne("#" + nodeId);
if (konvaNode) {
konvaNode.on(`dragstart${ns}`, () => setInteracting(true));
konvaNode.on(`dragend${ns}`, () => requestAnimationFrame(() => { setInteracting(false); bump(); }));
konvaNode.on(`dragend${ns}`, () =>
requestAnimationFrame(() => {
setInteracting(false);
bump();
}),
);
}
return () => {
@@ -380,6 +397,13 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
const baseImageUrl = baseImageInfo?.url;
// Synchronous re-entry guard for the Generate flow. A ref (not state) is the
// safety belt against duplicate paid generations: it flips before the first
// await so a panic-clicked second click sees the in-flight value immediately,
// independent of React state batching. The state below is only for the UI.
const generateInFlightRef = useRef(false);
const [isGenerating, setIsGenerating] = useState(false);
const selectedImageModel: ImageModel | undefined =
useSelectedImageModel(PAGE_ID);
@@ -396,8 +420,12 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
onPaste: pasteItems,
});
// Create a function to use the left layer ref and download the bitmap from it
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
// Read the inpaint mask off the Konva layer and encode it on a worker thread.
// The Konva readback itself runs on main (Konva is DOM-bound), but everything
// downstream — drawImage to an exact-size canvas, PNG encoding, byte transfer —
// happens off the main thread. Stable: only touches refs, so handleGenerate
// can list it as a dep without churning per render.
const getMaskArrayBuffer = useCallback(async (): Promise<Uint8Array> => {
if (!stageRef.current || !baseImageKonvaRef.current) {
console.error("Stage or left panel ref is not available");
throw new Error("Stage or left panel or base image ref is not available");
@@ -416,17 +444,13 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
pixelRatio: 1 / stageRef.current.scaleX(),
});
const fittedCanvas = normalizeCanvas(
layerCrop,
rect.width(),
rect.height(),
);
const blob = await fittedCanvas.convertToBlob({ type: "image/png" });
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
};
const markerBitmap = await createImageBitmap(layerCrop);
return maskInWorker({
markerBitmap,
width: rect.width(),
height: rect.height(),
});
}, []);
// Listen for gallery drag and drop events
useEffect(() => {
@@ -469,7 +493,11 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
stagePoint.y - displayH / 2,
dataUrl,
modelUrl,
{ ...DEFAULT_MODEL3D_PARAMS, nativeWidth: img.width, nativeHeight: img.height },
{
...DEFAULT_MODEL3D_PARAMS,
nativeWidth: img.width,
nativeHeight: img.height,
},
displayW,
displayH,
);
@@ -540,33 +568,44 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
[drawNodes, editing3DNodeId],
);
const handleImageUpload = useCallback(async (files: File[]): Promise<void> => {
const { width: canvasW, height: canvasH } = getAspectRatioDimensions();
const handleImageUpload = useCallback(
async (files: File[]): Promise<void> => {
const { width: canvasW, height: canvasH } = getAspectRatioDimensions();
const maxW = canvasW * 0.85;
const maxH = canvasH * 0.85;
const maxW = canvasW * 0.85;
const maxH = canvasH * 0.85;
for (const file of files) {
const img = new Image();
img.onload = () => {
const { naturalWidth, naturalHeight } = img;
for (const file of files) {
const img = new Image();
img.onload = () => {
const { naturalWidth, naturalHeight } = img;
const scale = Math.min(maxW / naturalWidth, maxH / naturalHeight, 1);
const displayW = naturalWidth * scale;
const displayH = naturalHeight * scale;
const scale = Math.min(maxW / naturalWidth, maxH / naturalHeight, 1);
const displayW = naturalWidth * scale;
const displayH = naturalHeight * scale;
const x = (canvasW - displayW) / 2;
const y = (canvasH - displayH) / 2;
const x = (canvasW - displayW) / 2;
const y = (canvasH - displayH) / 2;
createImageFromFile(x, y, file, displayW, displayH);
};
img.src = URL.createObjectURL(file);
}
}, [getAspectRatioDimensions, createImageFromFile]);
createImageFromFile(x, y, file, displayW, displayH);
};
img.src = URL.createObjectURL(file);
}
},
[getAspectRatioDimensions, createImageFromFile],
);
// 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 { baseImageBitmap: baseBitmapNow, baseImageInfo: baseInfoNow } =
useSceneStore.getState();
if (!stageRef.current || !baseImageKonvaRef.current) return null;
if (!baseImageInfo?.isBlankCanvas && !baseImageBitmap) return null;
if (!baseInfoNow?.isBlankCanvas && !baseBitmapNow) return null;
const editsLayer = stageRef.current
.getLayers()
@@ -581,17 +620,6 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const width = rect.width();
const height = rect.height();
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
if (baseImageBitmap) {
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
} else {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
}
const markerLayerCanvas = editsLayer.toCanvas({
x: stageRef.current.x(),
y: stageRef.current.y(),
@@ -599,17 +627,108 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
height: rect.height() * stageRef.current.scaleY(),
pixelRatio: 1 / stageRef.current.scaleX(),
});
const fittedMarkerCanvas = normalizeCanvas(
markerLayerCanvas,
// Move the marker pixels and a clone of the base bitmap off the main
// thread. The worker handles the exact-size resize, compositing, and PNG
// encoding. Each bake carries its own base atomically; we don't try to
// cache it across bakes — pre-baking on idle (see runCompositeBake) hides
// the per-bake clone cost on the click path, and the atomic-per-call
// contract avoids any race where concurrent bakes leave a stale base in a
// worker-side cache.
const [markerBitmap, baseBitmap] = await Promise.all([
createImageBitmap(markerLayerCanvas),
baseBitmapNow
? createImageBitmap(baseBitmapNow)
: Promise.resolve(undefined),
]);
const blob = await compositeInWorker({
markerBitmap,
baseBitmap,
width,
height,
);
ctx.drawImage(fittedMarkerCanvas, 0, 0, width, height);
const blob = await canvas.convertToBlob({ type: "image/png" });
});
const uuid = crypto.randomUUID();
return new File([blob], `${uuid}.png`, { type: "image/png" });
}, [baseImageBitmap, baseImageInfo]);
}, []);
// Pre-bake the composite File on idle so the Generate click path is
// near-instant in the common case (user pauses >300ms before clicking). The
// bake itself still freezes the main thread for the Konva readback, but it
// happens between user actions instead of on click. A generation counter
// makes concurrent bakes correct: only the latest bake commits its result,
// and only if no canvas change happened mid-bake.
const bakedCompositeRef = useRef<File | null>(null);
const bakeDirtyRef = useRef(true);
const bakeInFlightRef = useRef<Promise<File | null> | null>(null);
const bakeGenRef = useRef(0);
const bakeTimeoutRef = useRef<number | null>(null);
const runCompositeBake = useCallback((): Promise<File | null> => {
const myGen = ++bakeGenRef.current;
bakeDirtyRef.current = false;
const promise = (async () => {
try {
const file = await getCompositeCanvasFile();
if (myGen === bakeGenRef.current && !bakeDirtyRef.current) {
bakedCompositeRef.current = file;
}
return file;
} catch (err) {
if (myGen === bakeGenRef.current) bakeDirtyRef.current = true;
console.error("Composite pre-bake failed:", err);
return null;
} finally {
// Only the latest bake clears the in-flight slot. Older bakes that
// finish after a newer one has started leave the newer one's promise
// in place.
if (myGen === bakeGenRef.current) bakeInFlightRef.current = null;
}
})();
bakeInFlightRef.current = promise;
return promise;
}, [getCompositeCanvasFile]);
const getCompositeFile = useCallback((): Promise<File | null> => {
if (!bakeDirtyRef.current) {
if (bakedCompositeRef.current) {
return Promise.resolve(bakedCompositeRef.current);
}
if (bakeInFlightRef.current) return bakeInFlightRef.current;
}
return runCompositeBake();
}, [runCompositeBake]);
const scheduleCompositeBake = useCallback(() => {
if (bakeTimeoutRef.current !== null) {
window.clearTimeout(bakeTimeoutRef.current);
}
bakeTimeoutRef.current = window.setTimeout(() => {
bakeTimeoutRef.current = null;
runCompositeBake().catch(() => {});
}, 300);
}, [runCompositeBake]);
// Invalidate + reschedule on any change that affects what's actually rendered.
// `baseImageInfo` updates synchronously when the user picks a new base, but
// the canvas keeps showing the old bitmap until `baseImageBitmap` swaps in
// (see setBaseImageInfo in SceneState.ts) — so `baseImageBitmap` is the right
// trigger. The Generate button is gated separately on bitmap presence so we
// never bake or fire a generate against a not-yet-loaded base.
useEffect(() => {
bakeDirtyRef.current = true;
bakedCompositeRef.current = null;
scheduleCompositeBake();
}, [drawNodes, baseImageBitmap, scheduleCompositeBake]);
useEffect(
() => () => {
if (bakeTimeoutRef.current !== null) {
window.clearTimeout(bakeTimeoutRef.current);
}
},
[],
);
const handleGenerate = useCallback(
async (
@@ -621,28 +740,47 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
selectedProvider?: GenerationProvider;
},
) => {
const editedImageToken = baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
adapter.onEnqueueMeta?.({
prompt,
refImageUrls: (options?.images || [])
.map((img) => img.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
if (generateInFlightRef.current) return;
generateInFlightRef.current = true;
setIsGenerating(true);
try {
// Yield once so React paints the disabled button before the heavy
// canvas readback / encode / upload runs and locks the main thread.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const editedImageToken = baseImageInfo?.mediaToken;
if (!editedImageToken) {
console.error("Base image is not available");
return;
}
// Defense in depth: the visual gate (PromptEditor's isDisabled) is the
// primary user-facing guard, but a programmatic dispatch or future
// alternate trigger could still land here while the bitmap for the
// current baseImageInfo isn't loaded yet. Bail rather than snapshot
// stale pixels.
if (!baseImageBitmap && !baseImageInfo?.isBlankCanvas) {
console.error("Base image bitmap not yet loaded");
return;
}
const subscriberId: string =
crypto?.randomUUID?.() ??
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
adapter.onEnqueueMeta?.({
prompt,
refImageUrls: (options?.images || [])
.map((img) => img.url)
.filter(Boolean),
modelType:
(selectedImageModel as any)?.tauriId || String(selectedImageModel),
timestamp: Date.now(),
});
let result;
if (selectedImageModel?.editingIsInpainting) {
@@ -660,7 +798,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
});
} else if (selectedImageModel?.isNanoBananaModel()) {
// CASE 2 - NANO BANANA
const compositeFile = await getCompositeCanvasFile();
const compositeFile = await getCompositeFile();
if (!compositeFile) {
console.error("Failed to create composite canvas");
@@ -680,7 +818,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const imgs = options?.images || [];
result = await adapter.enqueueEditImage({
model: selectedImageModel,
sceneImageMediaToken: snapshotResult.data,
canvasImageMediaToken: snapshotResult.data,
imageMediaTokens: imgs
.map((img) => img.mediaToken)
.filter((t) => t.length > 0),
@@ -694,7 +832,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
});
} else {
// CASE 3 - DEFAULT
const compositeFile = await getCompositeCanvasFile();
const compositeFile = await getCompositeFile();
if (!compositeFile) {
console.error("Failed to create composite canvas");
@@ -714,7 +852,7 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const imgs = options?.images || [];
result = await adapter.enqueueEditImage({
model: selectedImageModel,
sceneImageMediaToken: snapshotResult.data,
canvasImageMediaToken: snapshotResult.data,
imageMediaTokens: imgs
.map((img) => img.mediaToken)
.filter((t) => t.length > 0),
@@ -731,15 +869,18 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
if (result?.status === "success") {
addPendingGeneration(subscriberId, generationCount);
}
} catch (error) {
throw error;
} finally {
generateInFlightRef.current = false;
setIsGenerating(false);
}
},
[
generationCount,
getCompositeCanvasFile,
getCompositeFile,
getMaskArrayBuffer,
selectedImageModel,
adapter,
baseImageBitmap,
baseImageInfo,
addPendingGeneration,
useSystemPrompt,
@@ -815,7 +956,10 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
const handleSelectTool = useCallback(() => setActiveTool("select"), [setActiveTool]);
const handleSelectTool = useCallback(
() => setActiveTool("select"),
[setActiveTool],
);
const handleActivateShapeTool = useCallback(
(shape: "rectangle" | "circle" | "triangle") => {
@@ -838,7 +982,9 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
);
const handleCanvasBackground = useCallback(
(hex: string) => { setFillColor(hex); },
(hex: string) => {
setFillColor(hex);
},
[setFillColor],
);
@@ -867,10 +1013,14 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
async (ratio: string) => {
const ratioToType = (r: string): AspectRatioType => {
switch (r) {
case "tall": return AspectRatioType.PORTRAIT;
case "wide": return AspectRatioType.LANDSCAPE;
case "square": return AspectRatioType.SQUARE;
default: return AspectRatioType.NONE;
case "tall":
return AspectRatioType.PORTRAIT;
case "wide":
return AspectRatioType.LANDSCAPE;
case "square":
return AspectRatioType.SQUARE;
default:
return AspectRatioType.NONE;
}
};
setAspectRatioType(ratioToType(ratio));
@@ -883,7 +1033,9 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
const handleMenuAction = useCallback(
async (action: string) => {
switch (action) {
case "LOCK": toggleLock(selectedNodeIds); break;
case "LOCK":
toggleLock(selectedNodeIds);
break;
case "REMOVE_BACKGROUND": {
const result = await beginRemoveBackground(selectedNodeIds);
if (result) {
@@ -891,26 +1043,51 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
}
break;
}
case "BRING_TO_FRONT": bringToFront(selectedNodeIds); break;
case "BRING_FORWARD": bringForward(selectedNodeIds); break;
case "SEND_BACKWARD": sendBackward(selectedNodeIds); break;
case "SEND_TO_BACK": sendToBack(selectedNodeIds); break;
case "DUPLICATE": copySelectedItems(); pasteItems(); break;
case "DELETE": deleteSelectedItems(); break;
default: break;
case "BRING_TO_FRONT":
bringToFront(selectedNodeIds);
break;
case "BRING_FORWARD":
bringForward(selectedNodeIds);
break;
case "SEND_BACKWARD":
sendBackward(selectedNodeIds);
break;
case "SEND_TO_BACK":
sendToBack(selectedNodeIds);
break;
case "DUPLICATE":
copySelectedItems();
pasteItems();
break;
case "DELETE":
deleteSelectedItems();
break;
default:
break;
}
},
[
selectedNodeIds, toggleLock, beginRemoveBackground, adapter, bringToFront,
bringForward, sendBackward, sendToBack, copySelectedItems, pasteItems,
selectedNodeIds,
toggleLock,
beginRemoveBackground,
adapter,
bringToFront,
bringForward,
sendBackward,
sendToBack,
copySelectedItems,
pasteItems,
deleteSelectedItems,
],
);
const handleCanvasSizeChange = useCallback((width: number, height: number) => {
canvasWidth.current = width;
canvasHeight.current = height;
}, []);
const handleCanvasSizeChange = useCallback(
(width: number, height: number) => {
canvasWidth.current = width;
canvasHeight.current = height;
},
[],
);
const isLocked = useMemo(
() =>
@@ -927,14 +1104,11 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
return n?.type !== "line" ? n : null;
}, [selectedNodeIds, drawNodes]);
const editingNode = useMemo(
() => {
if (!editing3DNodeId) return null;
const n = drawNodes.find((n) => n.id === editing3DNodeId);
return n?.type !== "line" ? n : null;
},
[editing3DNodeId, drawNodes],
);
const editingNode = useMemo(() => {
if (!editing3DNodeId) return null;
const n = drawNodes.find((n) => n.id === editing3DNodeId);
return n?.type !== "line" ? n : null;
}, [editing3DNodeId, drawNodes]);
// Display image selector on launch, otherwise hide it.
// Also show the selector when a non-blank-canvas image is set but the bitmap is still loading.
@@ -952,7 +1126,10 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
addHistoryImageBundle({ images: [image] });
setBaseImageInfo(image);
},
showLoading: baseImageInfo !== null && baseImageBitmap === null && !baseImageInfo.isBlankCanvas,
showLoading:
baseImageInfo !== null &&
baseImageBitmap === null &&
!baseImageInfo.isBlankCanvas,
})}
</div>
</div>
@@ -1005,7 +1182,8 @@ const PageDraw = ({ adapter }: PageDrawProps) => {
EncodeImageBitmapToBase64={EncodeImageBitmapToBase64}
onGenerateClick={handleGenerate}
onFitPressed={onFitPressed}
isDisabled={false}
isDisabled={!baseImageBitmap && !baseImageInfo?.isBlankCanvas}
isEnqueueing={isGenerating}
generationCount={generationCount}
onGenerationCountChange={setGenerationCount}
selectedImageModel={selectedImageModel}
@@ -7,7 +7,7 @@ import type { BaseSelectorImage } from "./types";
export interface PageDrawEditRequest {
model?: ImageModel;
provider?: GenerationProvider;
sceneImageMediaToken?: string;
canvasImageMediaToken?: string;
imageMediaTokens?: string[];
prompt?: string;
disableSystemPrompt?: boolean;
@@ -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,8 +1380,11 @@ 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 });
set({ baseImageInfo: null, baseImageBitmap: null });
return;
}
@@ -1438,13 +1449,22 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
return;
}
set({ baseImageInfo: image });
// Clear baseImageBitmap synchronously so consumers can distinguish
// "loaded for current info" from "stale from previous info" purely by
// checking truthiness. Without this, baseImageInfo points at the new
// image while baseImageBitmap still holds the previous one, and any code
// gating on bitmap presence (e.g. PageDraw's Generate button) would let
// through a click that snapshots the previous pixels with the new
// image's mediaToken. applyImageNodes will repopulate it on onload.
set({ baseImageInfo: image, baseImageBitmap: null });
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;
@@ -0,0 +1,117 @@
import GeneratePipelineWorker from "./generatePipeline.worker.ts?worker&inline";
type CompositeOk = { id: number; ok: true; type: "composite"; blob: Blob };
type MaskOk = { id: number; ok: true; type: "mask"; bytes: Uint8Array };
type WorkerErr = { id: number; ok: false; error: string };
type WorkerResponse = CompositeOk | MaskOk | WorkerErr;
type Pending = {
resolve: (value: CompositeOk | MaskOk) => void;
reject: (err: Error) => void;
};
let workerInstance: Worker | null = null;
let nextId = 1;
const pendingRequests = new Map<number, Pending>();
const ensureWorker = (): Worker => {
if (workerInstance) return workerInstance;
const worker = new GeneratePipelineWorker();
worker.addEventListener("message", (event: MessageEvent<WorkerResponse>) => {
const res = event.data;
const pending = pendingRequests.get(res.id);
if (!pending) return;
pendingRequests.delete(res.id);
if (res.ok) {
pending.resolve(res);
} else {
pending.reject(new Error(res.error));
}
});
worker.addEventListener("error", (event) => {
const err = new Error(event.message || "Generate pipeline worker error");
for (const pending of pendingRequests.values()) {
pending.reject(err);
}
pendingRequests.clear();
});
workerInstance = worker;
return worker;
};
// postMessage can throw synchronously (data not structured-cloneable, a
// transferable already detached, etc). Without this, the pendingRequests
// entry would stay registered forever and the returned Promise would hang.
const postOrReject = (
worker: Worker,
id: number,
message: unknown,
transfer: Transferable[],
reject: (err: Error) => void,
): void => {
try {
worker.postMessage(message, transfer);
} catch (err) {
pendingRequests.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
};
export const compositeInWorker = (params: {
markerBitmap: ImageBitmap;
baseBitmap?: ImageBitmap;
width: number;
height: number;
}): Promise<Blob> => {
const worker = ensureWorker();
const id = nextId++;
const transfer: Transferable[] = [params.markerBitmap];
if (params.baseBitmap) transfer.push(params.baseBitmap);
return new Promise<Blob>((resolve, reject) => {
pendingRequests.set(id, {
resolve: (res) => {
if (res.type !== "composite") {
reject(new Error("Unexpected response type for composite"));
return;
}
resolve(res.blob);
},
reject,
});
postOrReject(
worker,
id,
{ id, type: "composite", ...params },
transfer,
reject,
);
});
};
export const maskInWorker = (params: {
markerBitmap: ImageBitmap;
width: number;
height: number;
}): Promise<Uint8Array> => {
const worker = ensureWorker();
const id = nextId++;
return new Promise<Uint8Array>((resolve, reject) => {
pendingRequests.set(id, {
resolve: (res) => {
if (res.type !== "mask") {
reject(new Error("Unexpected response type for mask"));
return;
}
resolve(res.bytes);
},
reject,
});
postOrReject(
worker,
id,
{ id, type: "mask", ...params },
[params.markerBitmap],
reject,
);
});
};
@@ -0,0 +1,81 @@
/// <reference lib="webworker" />
type WorkerRequest =
| {
id: number;
type: "composite";
markerBitmap: ImageBitmap;
baseBitmap?: ImageBitmap;
width: number;
height: number;
}
| {
id: number;
type: "mask";
markerBitmap: ImageBitmap;
width: number;
height: number;
};
type WorkerResponse =
| { id: number; ok: true; type: "composite"; blob: Blob }
| { id: number; ok: true; type: "mask"; bytes: Uint8Array }
| { id: number; ok: false; error: string };
const ctxOf = (canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D => {
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get OffscreenCanvas 2d context");
return ctx;
};
const post = (response: WorkerResponse, transfer: Transferable[] = []) => {
(self as unknown as DedicatedWorkerGlobalScope).postMessage(
response,
transfer,
);
};
self.addEventListener("message", async (event: MessageEvent<WorkerRequest>) => {
const req = event.data;
try {
if (req.type === "composite") {
const canvas = new OffscreenCanvas(req.width, req.height);
const ctx = ctxOf(canvas);
if (req.baseBitmap) {
ctx.drawImage(req.baseBitmap, 0, 0, req.width, req.height);
} else {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, req.width, req.height);
}
ctx.drawImage(req.markerBitmap, 0, 0, req.width, req.height);
const blob = await canvas.convertToBlob({ type: "image/png" });
post({ id: req.id, ok: true, type: "composite", blob });
} else if (req.type === "mask") {
const canvas = new OffscreenCanvas(req.width, req.height);
const ctx = ctxOf(canvas);
ctx.drawImage(req.markerBitmap, 0, 0, req.width, req.height);
const blob = await canvas.convertToBlob({ type: "image/png" });
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
post({ id: req.id, ok: true, type: "mask", bytes }, [bytes.buffer]);
}
} catch (err) {
post({
id: req.id,
ok: false,
error: err instanceof Error ? err.message : String(err),
});
} finally {
// Always release transferred ImageBitmaps regardless of success/failure
// path. Without this, a throw mid-pipeline (e.g. an OOM during drawImage
// on a 4K canvas) would leak ~67MB of bitmap data per failed bake.
if (req.type === "composite") {
req.baseBitmap?.close();
req.markerBitmap.close();
} else if (req.type === "mask") {
req.markerBitmap.close();
}
}
});
export {};
@@ -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