Decode image metadata in bounded workers and expose explicit ratio fitting
This commit is contained in:
@@ -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
|
||||
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||||
<IconButton icon={Ratio} label="按图片比例调整" disabled={!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||||
<IconButton icon={Sparkles} label="AI 创作" disabled={!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
|
||||
</div>
|
||||
|
||||
@@ -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 }) };
|
||||
|
||||
@@ -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<ImageDimensions>((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'));}
|
||||
})});}
|
||||
@@ -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<Id, number>;
|
||||
storage: StoragePort; digests?:Pick<DigestPort,'digest'>; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
|
||||
storage: StoragePort; images?:Pick<ImageDecoderPort,'decode'>; digests?:Pick<DigestPort,'digest'>; 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<CollectionReport>|undefined;
|
||||
const imageTasks=new Map<Id,Promise<void>>();
|
||||
const fileWrites = new Map<Id, Blob>(), digestTasks = new Map<Id, Promise<void>>();
|
||||
let staged: readonly ResourceRecord[] = [];
|
||||
let applying: RunApplyInput | undefined;
|
||||
@@ -80,7 +83,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const copyPreparations=new Map<AbortController,Promise<Id>>();
|
||||
const importRunners=new Map<Id,ReturnType<typeof createImportRunner>>(),importLoads=new Map<Id,Promise<void>>(),importHolds=new Map<symbol,Map<Id,()=>void>>();
|
||||
let recovery: { controller: AbortController; release(): void; cancel():Promise<void> } | 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<Id,readonly Id[]>();
|
||||
const admissionPins=(snapshot:EngineSnapshot)=>{const pins=new Map<Id,readonly Id[]>();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.
|
||||
|
||||
@@ -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<ImageDimensions>;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<ImageDimensions>;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<number,Task>(),active=new Map<number,Task>();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<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{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<ImageDimensions>((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'));}}});
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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');
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { StoragePort } from './storage.ts';
|
||||
|
||||
export type ProjectEngine = Awaited<ReturnType<typeof openProjectEngine>>;
|
||||
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<EngineOptions, 'persistence' | 'assets' | 'runs'>;
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user