diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx
index 5b24b83..779935a 100644
--- a/src/components/canvas/headless-graph-canvas.tsx
+++ b/src/components/canvas/headless-graph-canvas.tsx
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection } from "@xyflow/react";
-import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
+import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { ReactRenderers } from "@/lib/canvas-bindings/react-renderers";
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/renderer-context";
@@ -148,6 +148,8 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
// while an interaction is active so viewport changes cannot discard IME or capture.
const cullVisible = !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
+ const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
+ const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
// Runtime-only publications must not invalidate every card's editing context.
// Actions use current.current / engine snapshots instead of captured document projections.
@@ -248,6 +250,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <>>}
engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} />
+ {if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
setShowAI(value => !value)} />
add('plain-text')} /> add('text')} /> imageInput.current?.click()} /> add('frame')} />
diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx
index e920008..791e0cb 100644
--- a/src/components/workspace/canvas-runtime-host.tsx
+++ b/src/components/workspace/canvas-runtime-host.tsx
@@ -9,6 +9,7 @@ import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
import { createRuntimeHost, type RuntimeHost } from "@/lib/canvas-runtime/host";
import { createWorkspaceRuntime } from "@/lib/canvas-runtime/workspace";
import { createBrowserDigests } from "@/lib/canvas-runtime/browser-digests";
+import {createBrowserImageDecoder} from "@/lib/canvas-runtime/browser-images";
import { createBrowserStorage } from "@/lib/canvas-runtime/storage";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns } from "@/lib/canvas-service/index";
@@ -20,7 +21,7 @@ export function CanvasRuntimeHost({ children,extensions=[] }: { children: ReactN
const registry = createRegistry([...builtinComponents(),...extensions.flatMap(extension=>extension.components??[])]),renderers=createReactRenderers([...defaultCanvasRenderers.definitions,...extensions.flatMap(extension=>extension.renderers??[])],registry);
const host=createRuntimeHost({ registry,
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
- return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),
+ return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),digests:createBrowserDigests(),images:createBrowserImageDecoder(),
adapters(rootId, current) {
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
return { runs: createFoundryRuns({ workspaceId, rootId, registry, transport }), persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
diff --git a/src/lib/canvas-runtime/browser-images.ts b/src/lib/canvas-runtime/browser-images.ts
new file mode 100644
index 0000000..26681fc
--- /dev/null
+++ b/src/lib/canvas-runtime/browser-images.ts
@@ -0,0 +1,10 @@
+import {fault} from '../canvas-core/values.ts';
+import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts';
+export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise((resolve,reject)=>{
+ if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;}
+ let worker:Worker;try{worker=new Worker(new URL('./image.worker.ts',import.meta.url),{type:'module'});}catch{reject(fault('image_decoder_unavailable'));return;}
+ const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted'));
+ signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;}
+ worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response'));
+ try{worker.postMessage({version:1,id,bytes});}catch{finish(fault('image_decode_failed'));}
+ })});}
diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts
index 70c14d9..d639df9 100644
--- a/src/lib/canvas-runtime/engine.ts
+++ b/src/lib/canvas-runtime/engine.ts
@@ -1,3 +1,5 @@
+import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
+import {unitScale} from '../canvas-core/units.ts';
import {orphanCoverage,prepareOrphanFiles,abortableFileRead,type OrphanFile} from './orphan-files.ts';
import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts';
import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts';
@@ -31,7 +33,7 @@ import type { StoragePort, StorageScope } from './storage.ts';
const digestFailures=new Set(['digest_capacity','digest_worker_failed','digest_worker_unavailable','invalid_digest_response','digest_pool_closed']);
export type EngineOptions = {
scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap;
- storage: StoragePort; digests?:Pick; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
+ storage: StoragePort; images?:Pick; digests?:Pick; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
historyLimit?: number; queryBudget?:EditorOptions['queryBudget']; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock;
onError?: (error: unknown) => void;
};
@@ -72,6 +74,7 @@ export async function openProjectEngine(options: EngineOptions) {
for(const [id,file]of recovered?.runtime.orphanFiles??[])if(file.retirement?.purgedAt!==undefined)durablyPurged.add(id);
for(const [id,entry]of importFiles(recovered?.runtime.imports))if(entry.file.retirement?.purgedAt!==undefined)durablyPurged.add(id);
let collection:Promise|undefined;
+ const imageTasks=new Map>();
const fileWrites = new Map(), digestTasks = new Map>();
let staged: readonly ResourceRecord[] = [];
let applying: RunApplyInput | undefined;
@@ -80,7 +83,7 @@ export async function openProjectEngine(options: EngineOptions) {
const copyPreparations=new Map>();
const importRunners=new Map>(),importLoads=new Map>(),importHolds=new Mapvoid>>();
let recovery: { controller: AbortController; release(): void; cancel():Promise } | undefined;
- const admissionPins=(snapshot:EngineSnapshot)=>copyPreparations.size?new Map([['copy-admission',[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt===undefined).map(resource=>resource.id)]]):new Map();
+ const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map();if(copyPreparations.size)pins.set('copy-admission',[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt===undefined).map(resource=>resource.id));if(imageTasks.size)pins.set('image-decode',[...imageTasks.keys()]);return pins;};
const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); };
const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ imports:readMap(new Map()), recoveries:readMap(new Map()), runs: readMap(new Map()), acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) });
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,queryBudget:options.queryBudget,
@@ -448,7 +451,7 @@ export async function openProjectEngine(options: EngineOptions) {
if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); }
// Intents already exist in this root; notification only schedules their IDs.
const operationIds = [...commit.operationIds, ...staged.map(resource => resource.operationId)];
- queueMicrotask(() => { if (!closing && !closed) { scheduler.notify(operationIds); if (operationIds.length) prepareDigests(); } });
+ queueMicrotask(() => { if (!closing && !closed) { scheduler.notify(operationIds); if (operationIds.length) prepareDigests(); }prepareImages(); });
});
async function digest(bytes:Blob,signal?:AbortSignal){return sourceDigest(bytes,signal);}
function prepareDigests() {
@@ -464,9 +467,9 @@ export async function openProjectEngine(options: EngineOptions) {
const hash = await digest(bytes);
if (closing || closed) return;
const result = editor.updateRuntime(previous => {
- if (previous.resources.get(resource.id) !== resource) throw fault('resource_identity_conflict', [resource.id]);
+ const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.byteLength!==resource.byteLength||current.mimeType!==resource.mimeType||current.digest!==undefined&¤t.digest!==hash||current.retirement?.purgedAt!==undefined)throw fault('resource_identity_conflict',[resource.id]);
const resources = new Map(previous.resources), ledger = createLedger(previous.ledger);
- resources.set(resource.id, freeze({ ...resource, digest: hash })); ledger.block(resource.operationId, []);
+ resources.set(resource.id, freeze({ ...current, digest: hash })); ledger.block(resource.operationId, []);
return Object.freeze({ ...previous, resources: readMap(resources), ledger: ledger.getSnapshot() });
});
if (result.status === 'rejected') throw fault(result.code, result.targets);
@@ -475,6 +478,22 @@ export async function openProjectEngine(options: EngineOptions) {
digestTasks.set(resource.id, task);
}
}
+ function prepareImages(){
+ if(closing||closed||recovery||!options.images)return;
+ for(const resource of editor.getSnapshot().runtime.resources.values()){
+ if(imageTasks.size>=2)break;
+ if(imageTasks.has(resource.id)||resource.image&&(resource.image.version!==1||resource.image.status!=='pending')||resource.retirement?.purgedAt!==undefined)continue;
+ if(!resource.image){editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'pending' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});return;}
+ const task=(async()=>{
+ const bytes=fileWrites.get(resource.id)??await options.storage.readFile(leader,resource.id);
+ if(bytes&&bytes.size!==resource.byteLength||!bytes&&!resource.stored)throw fault('resource_bytes_unavailable',[resource.id]);
+ const size=bytes?await options.images!.decode(bytes,digestLifecycle.signal):resource.stored;
+ if(!size||!validImageDimensions(size))throw fault('invalid_image_dimensions');if(closing||closed||recovery)return;
+ const result=editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image||current.retirement?.purgedAt!==undefined)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'ready' as const,width:size.width,height:size.height,source:bytes?'decode' as const:'server' as const}}));return Object.freeze({...previous,resources:readMap(resources)});});
+ if(result.status==='rejected')throw fault(result.code,result.targets);
+ })().catch(error=>{if(closing||closed||recovery)return;const code=(error as{code?:string}).code??'image_decode_failed';editor.updateRuntime(previous=>{const current=previous.resources.get(resource.id);if(!current||current.operationId!==resource.operationId||current.image!==resource.image)return previous;const resources=new Map(previous.resources);resources.set(resource.id,freeze({...current,image:{version:1 as const,status:'failed' as const,code}}));return Object.freeze({...previous,resources:readMap(resources)});});}).finally(()=>{imageTasks.delete(resource.id);prepareImages();});imageTasks.set(resource.id,task);
+ }
+ }
function queueRunAction(operationType:'run.cancel'|'run.apply',action:RunCancelInput|RunApplyInput):Result {
const root=editor.getSnapshot();let sequence=1;for(const record of root.runtime.ledger.operations.values())sequence=Math.max(sequence,record.intent.sequence+1);
const id=createId(),intent={id,operationType,action,operationSchemaVersion:1,sequence,origin:'user',canvasIds:operationType==='run.apply'?[(action as RunApplyInput).targetCanvasId]:[],changes:[],dependencies:operationType==='run.cancel'?[]:[...root.runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)} as OperationIntent;
@@ -598,7 +617,7 @@ export async function openProjectEngine(options: EngineOptions) {
async resume(id:Id){assertWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(['running','preparing'].includes(job.status)){await loadImport(id);return;}await importLoads.get(id);await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:current.source?.kind==='duplicate-snapshot'?'preparing' as const:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);},
async cancel(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','preparing','paused'].includes(job.status)){preparationControllers.get(id)?.abort();const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}},
});
- scheduler.start(); prepareDigests(); runTracker.notify([...initial.runs.keys()]);
+ scheduler.start(); prepareDigests();prepareImages(); runTracker.notify([...initial.runs.keys()]);
for(const job of initial.imports?.values()??[])if(['running','preparing'].includes(job.status))void loadImport(job.id);
return Object.freeze({
editor, sessions, imports, collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles,
@@ -608,7 +627,7 @@ export async function openProjectEngine(options: EngineOptions) {
assertWritable();
if(!options.persistence.read)throw fault('canonical_reader_unavailable');
const releaseScheduler=scheduler.hold(),releaseRuns=runTracker.hold(),releaseImports=holdImports(),controller=new AbortController();
- const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();releaseImports();},async cancel(){controller.abort();active.release();}};
+ const active={controller,release(){if(recovery!==active)return;recovery=undefined;releaseRuns();releaseScheduler();releaseImports();queueMicrotask(prepareImages);},async cancel(){controller.abort();active.release();}};
recovery=active;
sessions.cancel('recovery');
const assertCurrent=()=>{if(closing||closed)throw fault('engine_closing');if(recovery!==active||controller.signal.aborted)throw fault('recovery_session_closed');};
@@ -739,7 +758,7 @@ export async function openProjectEngine(options: EngineOptions) {
if (!options.assets) throw fault('asset_interpreter_unavailable');
const resources = files.map(file => {
if (!(file.bytes instanceof Blob) || !file.bytes.size || file.bytes.size > maxResourceBytes || !file.bytes.type.startsWith('image/') || typeof file.name !== 'string' || !file.name) throw fault('invalid_staged_file');
- return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size });
+ return freeze({ id: createId(), operationId: createId(), name: file.name, mimeType: file.bytes.type, byteLength: file.bytes.size,...(options.images?{image:{version:1 as const,status:'pending' as const}}:{}) });
});
if (new Set(resources.flatMap(resource => [resource.id, resource.operationId])).size !== resources.length * 2 || resources.some(resource => editor.getSnapshot().runtime.resources.has(resource.id) || editor.getSnapshot().runtime.orphanFiles?.has(resource.id) || fileWrites.has(resource.id) || importFiles(editor.getSnapshot().runtime.imports).has(resource.id))) throw fault('resource_identity_conflict');
staged = resources; resources.forEach((resource, index) => fileWrites.set(resource.id, files[index].bytes));
@@ -781,15 +800,22 @@ export async function openProjectEngine(options: EngineOptions) {
return Object.freeze({url,release});
}catch(error){release();throw error;}
},
+ fitImage(target:Target,resourceId:Id):Result{
+ try{assertWritable();if(sessions.getSnapshot().gesture)throw fault('gesture_in_progress');const root=editor.getSnapshot(),element=root.document.elements.get(target.id),resource=root.runtime.resources.get(resourceId),size=resource?.image?.version===1&&resource.image.status==='ready'?resource.image:resource?.stored;
+ if(!element||element.incarnation!==target.incarnation||element.structure!=='node'||options.registry.resolve(element)!=='known'||!options.registry.components.get(element.componentId)?.resourceFields?.includes('assetId')||element.data.assetId!==resourceId)throw fault('target_changed');if(!size||!validImageDimensions(size))throw fault('image_dimensions_unavailable');
+ const units=unitScale(element.data.presentation),min=units,max=100000*units,scale=Math.min(element.geometry.width/size.width,max/size.width,max/size.height),grow=Math.max(scale,min/size.width,min/size.height),width=size.width*grow,height=size.height*grow;if(!Number.isFinite(width)||!Number.isFinite(height)||width>max||height>max)throw fault('image_fit_constraint');
+ return editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{width,height}});const after=tx.getDocument().elements.get(target.id);if(after?.structure!=='node'||Math.abs(after.geometry.width-width)>1e-8||Math.abs(after.geometry.height-height)>1e-8)throw fault('image_fit_constraint');},{label:'Fit image ratio'});
+ }catch(error){return reject(error);}
+ },
getSnapshot: editor.getSnapshot, persist,
cancelRecovery:async()=>{await recovery?.cancel();},
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
- retry: async () => { if(recovery)await recovery.cancel();assertWritable();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}await scheduler.resume();await runTracker.retry(); },
- async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); },
+ retry: async () => { if(recovery)await recovery.cancel();assertWritable();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); },
+ async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); },
async close() {
if (closed) return;
closing = true;orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
- await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);
+ await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);
recovery?.controller.abort();recovery?.release();
for (const release of [...displays]) release();
// Failure keeps the leader/recovery material available for an explicit retry of close.
diff --git a/src/lib/canvas-runtime/image-decoder.ts b/src/lib/canvas-runtime/image-decoder.ts
new file mode 100644
index 0000000..aa09e62
--- /dev/null
+++ b/src/lib/canvas-runtime/image-decoder.ts
@@ -0,0 +1,17 @@
+import type {Json} from '../canvas-core/types.ts';
+import {fault,freeze} from '../canvas-core/values.ts';
+export type ImageDimensions={readonly width:number;readonly height:number};
+type KnownImageDecodeState={readonly version:1;readonly status:'pending'}|{readonly version:1;readonly status:'ready';readonly width:number;readonly height:number;readonly source:'decode'|'server'}|{readonly version:1;readonly status:'failed';readonly code:string};
+export type ImageDecodeState=KnownImageDecodeState|Readonly<{version:number;[key:string]:Json|undefined}>;
+export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise;close():void};
+export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);}
+/** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */
+export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise;concurrency?:number;maxQueued?:number}):ImageDecoderPort{
+ const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64;
+ if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget');
+ type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void};
+ const waiting=new Map(),active=new Map();let next=0,closed=false;
+ function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));}
+ function pump(){while(!closed&&active.size{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}}
+ return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}});
+}
diff --git a/src/lib/canvas-runtime/image.worker.ts b/src/lib/canvas-runtime/image.worker.ts
new file mode 100644
index 0000000..8b71390
--- /dev/null
+++ b/src/lib/canvas-runtime/image.worker.ts
@@ -0,0 +1,7 @@
+const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void};
+worker.onmessage=async event=>{
+ const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return;
+ try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});}
+ catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});}
+};
+export {};
diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts
index 3c19750..6d63017 100644
--- a/src/lib/canvas-runtime/recovery.ts
+++ b/src/lib/canvas-runtime/recovery.ts
@@ -166,6 +166,7 @@ export function decodeRecovery(image: RecoveryImage, registry: Registry): CoreSn
if (string(raw.id) !== id || string(raw.operationId) === id) throw fault('corrupt_recovery_image');
string(raw.name); string(raw.mimeType); integer(raw.byteLength, 1);
if(raw.retirement!==undefined){if(image.formatVersion<3)throw fault('unsupported_recovery_version');const marker=object(raw.retirement);if(marker.version!==1)throw fault('resource_retirement_interpreter_unavailable');integer(marker.unreferencedAt);if(marker.purgedAt!==undefined&&integer(marker.purgedAt)<(marker.unreferencedAt as number))throw fault('corrupt_recovery_image');}
+ if(raw.image!==undefined){const image=object(raw.image);integer(image.version,1);if(image.version===1){if(!['pending','ready','failed'].includes(String(image.status)))throw fault('corrupt_recovery_image');if(image.status==='ready'){integer(image.width,1);integer(image.height,1);if(!Number.isSafeInteger((image.width as number)*(image.height as number))||!['decode','server'].includes(String(image.source)))throw fault('corrupt_recovery_image');}if(image.status==='failed')string(image.code);}}
if (raw.digest !== undefined && !/^[a-f0-9]{64}$/.test(string(raw.digest))) throw fault('corrupt_recovery_image');
const operation = records.get(raw.operationId as string)?.intent;
if (operation?.operationType !== 'asset.upload' || operation.resourceId !== id) throw fault('corrupt_recovery_image');
diff --git a/src/lib/canvas-runtime/resource-retention.ts b/src/lib/canvas-runtime/resource-retention.ts
index b7ed8d6..b4c3d33 100644
--- a/src/lib/canvas-runtime/resource-retention.ts
+++ b/src/lib/canvas-runtime/resource-retention.ts
@@ -16,9 +16,10 @@ export function resourceRetentionPins(snapshot:EngineSnapshot,registry:Registry,
// Pin the whole known local depot rather than claiming opaque task data contains no references.
for(const job of runtime.imports?.values()??[])if(!['completed','partial','cancelled'].includes(job.status)){if(job.source)add(job.source.fileId,`import-source:${job.id}`);for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`import:${job.id}`);}
for(const run of runtime.runs.values())for(const [id,resource]of runtime.resources)if(resource.retirement?.purgedAt===undefined)add(id,`run:${run.id}`);
+ for(const resource of runtime.resources.values())if(resource.image&&(resource.image.version!==1||resource.image.status==='pending'))add(resource.id,resource.image.version!==1?'image-decoder-unavailable':'image-decode');
for(const [owner,ids]of temporary)for(const id of ids)add(id,owner);
for(const [id,count]of displays)if(count>0)add(id,'display');
- return Object.freeze({complete:current.complete&&base.complete,pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
+ return Object.freeze({complete:current.complete&&base.complete&&![...runtime.resources.values()].some(resource=>resource.image&&resource.image.version!==1),pins:readMap(new Map([...pins].map(([id,owners])=>[id,readSet(owners)])))});
}
/** Runs inside the editor's single publication boundary, including reconcile and metadata commits. */
diff --git a/src/lib/canvas-runtime/resources.ts b/src/lib/canvas-runtime/resources.ts
index d3eb91f..1651ccd 100644
--- a/src/lib/canvas-runtime/resources.ts
+++ b/src/lib/canvas-runtime/resources.ts
@@ -1,3 +1,4 @@
+import type {ImageDecodeState} from './image-decoder.ts';
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
import { historyTransitionChanges } from '../canvas-core/history-transition.ts';
import { ancestors } from '../canvas-core/document.ts';
@@ -6,7 +7,7 @@ import type { OperationStatus } from './scheduler.ts';
import { readMap, readSet } from '../canvas-core/values.ts';
export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string };
-export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
+export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
export type DisplayHandle = { readonly url: string; release(): void };
export type AssetPort = {
sourceURL(resourceId: Id): string;
diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts
index e3e172f..a20560e 100644
--- a/src/lib/canvas-runtime/workspace.ts
+++ b/src/lib/canvas-runtime/workspace.ts
@@ -6,7 +6,7 @@ import type { StoragePort } from './storage.ts';
export type ProjectEngine = Awaited>;
export type WorkspaceOptions = {
- accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort; digests?:DigestPort; queryBudget?:EngineOptions['queryBudget'];
+ accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort; digests?:DigestPort; images?:import('./image-decoder.ts').ImageDecoderPort; queryBudget?:EngineOptions['queryBudget'];
isCurrentIdentity(): boolean;
adapters(projectId: Id, current: () => boolean): Pick;
onError?: (error: unknown) => void;
@@ -26,7 +26,7 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
if (!current()) return Promise.reject(fault('identity_changed'));
const existing = projects.get(document.projectId);
if (existing) return existing.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
- const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, digests, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,
+ const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, digests, images:options.images, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,
traversalPolicy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
policy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
});
@@ -46,7 +46,7 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
}));
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
if (errors.length) throw new AggregateError(errors, 'workspace_close_failed');
- digests.close();options.storage.close(); projects.clear(); closed = true;
+ digests.close();options.images?.close();options.storage.close(); projects.clear(); closed = true;
})();
closing = task.finally(() => { closing = undefined; });
return closing;
diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs
index f9b9b99..0b4d681 100644
--- a/tests/headless/browser-storage-cases.mjs
+++ b/tests/headless/browser-storage-cases.mjs
@@ -1,3 +1,4 @@
+import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
@@ -10,6 +11,11 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
+async function imageDecodeWorkerCase(assert){
+ const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);active++;created++;peak=Math.max(peak,active);this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
+ const pool=createBrowserImageDecoder();try{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),results=await Promise.all([pool.decode(bytes),pool.decode(bytes),pool.decode(bytes)]);assert(results.every(size=>size.width===4&&size.height===2),'real bitmap decoder returned incorrect dimensions');assert(peak===2&&active===0&&created===3,'image worker concurrency/cleanup failed');let code;try{await pool.decode(new Blob(['not an image'],{type:'image/png'}));}catch(error){code=error.code;}assert(code==='image_decode_failed'&&active===0,'invalid image did not fail or leaked a worker');const cancel=new AbortController(),pending=pool.decode(bytes,cancel.signal),cancelled=pending.then(()=>{throw Error('cancelled image returned dimensions');},error=>assert(error.code==='image_decode_aborted','image cancellation code changed'));cancel.abort();await cancelled;assert(active===0,'cancelled image worker leaked');}finally{pool.close();globalThis.Worker=NativeWorker;}
+}
+
async function digestWorkerCase(assert){
const NativeWorker=globalThis.Worker;let active=0,peak=0,created=0;const replies=[];
globalThis.Worker=class extends NativeWorker{constructor(...args){super(...args);created++;active++;peak=Math.max(peak,active);this.live=true;this.addEventListener('message',event=>replies.push(event.data));}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
@@ -672,6 +678,8 @@ export async function run() {
results.push('scoped paged inventory finds legacy orphans without hiding references; orphan v5 deletion rolls back faults, retries, isolates projects and forbids byte identity reuse');
await resourceRecoveryCase(name, assert);
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
+ await imageDecodeWorkerCase(assert);
+ results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers');
await digestWorkerCase(assert);
results.push('bounded real module workers hash chunked Blobs with native SHA256 parity, queued abort and close terminate work without a late result');
await ownedImageStorageCase(name,assert);
diff --git a/tests/headless/image-decoder.test.mjs b/tests/headless/image-decoder.test.mjs
new file mode 100644
index 0000000..70324fe
--- /dev/null
+++ b/tests/headless/image-decoder.test.mjs
@@ -0,0 +1,14 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
+import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
+const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};},bytes=new Blob(['image'],{type:'image/png'});
+test('image decoder bounds real execution slots, preserves FIFO and rejects excess work',async()=>{
+ const gate=deferred(),started=[];let active=0,peak=0;const pool=createImageDecodePool({concurrency:1,maxQueued:1,execute:async file=>{active++;peak=Math.max(peak,active);started.push(await file.text());await gate.promise;active--;return{width:4,height:2};}});try{const first=pool.decode(bytes),second=pool.decode(new Blob(['second'],{type:'image/png'}));await assert.rejects(pool.decode(bytes),/image_decode_capacity/);gate.resolve();assert.deepEqual(await first,{width:4,height:2});assert.deepEqual(await second,{width:4,height:2});assert.deepEqual(started,['image','second']);assert.equal(peak,1);}finally{gate.resolve();pool.close();}
+});
+test('cancelling active work does not free its slot before the underlying decoder settles',async()=>{
+ const gate=deferred(),started=deferred(),cancel=new AbortController();let executions=0;const pool=createImageDecodePool({concurrency:1,execute:async()=>{executions++;started.resolve();await gate.promise;return{width:4,height:2};}});try{const first=pool.decode(bytes,cancel.signal),rejected=assert.rejects(first,/image_decode_aborted/);await started.promise;cancel.abort();await rejected;const second=pool.decode(bytes);await Promise.resolve();assert.equal(executions,1);gate.resolve();await second;assert.equal(executions,2);}finally{gate.resolve();pool.close();}
+});
+test('invalid dimensions and failed workers do not produce usable metadata; worker identity is checked',async()=>{
+ const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
+});
diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs
index fcfc1b3..70f1a44 100644
--- a/tests/headless/renderer.integration.mjs
+++ b/tests/headless/renderer.integration.mjs
@@ -241,8 +241,11 @@ try {
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
- await evaluate(`(()=>{const bytes=Uint8Array.from(atob('${png.toString('base64')}'),c=>c.charCodeAt(0));const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
+ await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
+ const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
+ await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'explicit fit did not use locally decoded 4:2 aspect ratio');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='200px'`,'fit undo did not restore default dimensions');assert.equal(uploads.length<=1,true);
+ console.log('PASS decoded image metadata is available before upload ACK; explicit ratio fit and its undo preserve the image identity');
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
diff --git a/tests/headless/resources.test.mjs b/tests/headless/resources.test.mjs
index f8ee55a..aeead3f 100644
--- a/tests/headless/resources.test.mjs
+++ b/tests/headless/resources.test.mjs
@@ -249,3 +249,21 @@ test('retention covers ACK-only content, opaque job envelopes and historical fie
const malformed={...plain,document:createDocument('project',[...empty.canvases.values()],[node('bad',{assetId:{opaque:'kept'}})])};assert.equal(resourceRetentionPins(malformed,registry).complete,false);
}finally{await engine.close();}
});
+
+test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{
+ const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1);
+ }finally{gate.resolve();await engine.close();}
+});
+test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{
+ const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();}
+});
+test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{
+ const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();}
+});
+test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{
+ const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();}
+});
+
+test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{
+ const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();}
+});