fix(studio): the repair runs off the page's own thread, and a stroke is answered twice so the hand is never held

The graph is seconds of solid arithmetic however large the crop is, and it ran
where the pointer lives: the brush lagged behind the hand for exactly as long as
the model took. The runtime's wasm provider now works in a worker of its own, so
the page keeps drawing while the repair is thought through.

The wait itself stays — it is the model's, not the page's — so a preview paints
the blurred fill first and lets the model's version of the same mask replace it.
Filing and export still wait for the model: the faster answer is for the eye
waiting on the brush and for nothing else.

Measured on the same photo and stroke, worst main-thread gap 3703 ms before and
487 ms after; previews land at 1.0 s and 5.3 s, and the model's render is the one
left on the stage.
This commit is contained in:
2026-10-12 21:43:00 +07:00
parent 5535cb8210
commit 54891148a9
3 changed files with 44 additions and 1 deletions
+24
View File
@@ -1032,6 +1032,30 @@ export function Workspace() {
// render and the compare copy, so the two can never drift apart.
const frame = cropEditing ? 'none' : frameId;
const options = stageOptions;
// A repair in hand is drawn twice, and the first one is the cheap half:
// the blurred fill lands in a fraction of the model's fixed seconds and
// is a repair in its own right (it is what a machine without the runtime
// gets), so the stroke is answered at once and the model's version of
// the same mask replaces it when it arrives. Everything the app files or
// exports still waits for the model.
if (shownRecipe.adjustments.heal?.length) {
const quick = await renderPhoto({
source: previewBytes,
recipe: shownRecipe,
frameId: frame,
useGeotag,
gpsInfo,
assets: engine.assets,
options: { ...options, healModel: false },
});
if (token !== renderToken.current) return;
if (quick) {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(new Blob([quick.bytes as BlobPart], { type: 'image/jpeg' }));
});
}
}
const out = await renderPhoto({
source: previewBytes,
recipe: shownRecipe,