From 3750648dea13656f0176ab6873b12b23804bda4f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 23:45:24 +0800 Subject: [PATCH] Decode image metadata in bounded workers and expose explicit ratio fitting --- .../canvas/headless-graph-canvas.tsx | 5 +- .../workspace/canvas-runtime-host.tsx | 3 +- src/lib/canvas-runtime/browser-images.ts | 10 ++++ src/lib/canvas-runtime/engine.ts | 48 ++++++++++++++----- src/lib/canvas-runtime/image-decoder.ts | 17 +++++++ src/lib/canvas-runtime/image.worker.ts | 7 +++ src/lib/canvas-runtime/recovery.ts | 1 + src/lib/canvas-runtime/resource-retention.ts | 3 +- src/lib/canvas-runtime/resources.ts | 3 +- src/lib/canvas-runtime/workspace.ts | 6 +-- tests/headless/browser-storage-cases.mjs | 8 ++++ tests/headless/image-decoder.test.mjs | 14 ++++++ tests/headless/renderer.integration.mjs | 5 +- tests/headless/resources.test.mjs | 18 +++++++ 14 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 src/lib/canvas-runtime/browser-images.ts create mode 100644 src/lib/canvas-runtime/image-decoder.ts create mode 100644 src/lib/canvas-runtime/image.worker.ts create mode 100644 tests/headless/image-decoder.test.mjs 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();} +});