diff --git a/src/lib/canvas-runtime/browser-digests.ts b/src/lib/canvas-runtime/browser-digests.ts index c77f3d8..859d4c4 100644 --- a/src/lib/canvas-runtime/browser-digests.ts +++ b/src/lib/canvas-runtime/browser-digests.ts @@ -1,14 +1,23 @@ +/** + * 浏览器摘要适配:每个活跃任务创建一个 module Worker,由通用 digest pool 限制并发。 + * 没有 Worker 时才使用分块 fallback;Worker 创建/运行失败明确报错,不悄悄把整缓冲原生哈希移到 UI 线程。 + */ import {fault} from '../canvas-core/values.ts'; import {createDigestPool} from './digest-pool.ts'; /** Browser capability stays at the adapter; WorkspaceRuntime owns the pool's lifetime. */ +/** 构建 workspace 可共享的 DigestPort;任务 ID/长度/版本共同验证响应归属。 */ export function createBrowserDigests(){ let next=0; return createDigestPool({execute:typeof Worker==='undefined'?undefined:(bytes,signal)=>new Promise((resolve,reject)=>{ if(signal.aborted){reject(fault('import_aborted'));return;} + // 每请求独立 Worker,所以成功、失败或 abort 都可直接 terminate,不影响其它任务。 let worker:Worker;try{worker=new Worker(new URL('./digest.worker.ts',import.meta.url),{type:'module'});}catch{reject(fault('digest_worker_unavailable'));return;} const id=++next;let done=false; + /** 幂等完成:解绑所有消息/错误/abort 监听并终止 Worker,防止迟到响应泄漏或重复 resolve。 */ const finish=(error?:unknown,sha256?:string)=>{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(sha256!);},abort=()=>finish(fault('import_aborted')); + // 消息须精确匹配版本、任务 ID 和输入长度,再验64位十六进制摘要;已知错误也必须匹配身份。 signal.addEventListener('abort',abort,{once:true});worker.onmessage=event=>{const value=event.data;if(value?.version===1&&value.id===id&&value.byteLength===bytes.size&&value.error==='digest_worker_failed'){finish(fault('digest_worker_failed'));return;}if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size||typeof value.sha256!=='string'||!(/^[a-f0-9]{64}$/.test(value.sha256)))finish(fault('invalid_digest_response'));else finish(undefined,value.sha256);};worker.onerror=event=>{event.preventDefault();finish(fault('digest_worker_failed'));};worker.onmessageerror=()=>finish(fault('invalid_digest_response')); + // Blob 通过结构化克隆传递,不在 UI 主线程先读取整份 arrayBuffer。 try{worker.postMessage({version:1,id,bytes});}catch{finish(fault('digest_worker_failed'));} })}); } diff --git a/src/lib/canvas-runtime/browser-images.ts b/src/lib/canvas-runtime/browser-images.ts index 26681fc..96b2c1e 100644 --- a/src/lib/canvas-runtime/browser-images.ts +++ b/src/lib/canvas-runtime/browser-images.ts @@ -1,10 +1,19 @@ +/** + * 浏览器图片解码适配,每次执行使用专用 module Worker,外部通用 pool 控制并发。 + * 无 Worker/创建失败明确不可用,不退回主线程偷偷解码;没有任何存储、上传或元素发布副作用。 + */ import {fault} from '../canvas-core/values.ts'; import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts'; +/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */ 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;} + // 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。 let worker:Worker;try{worker=new Worker(new URL('./image.worker.ts',import.meta.url),{type:'module'});}catch{reject(fault('image_decoder_unavailable'));return;} + /** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */ 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;} + // 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。 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')); + // 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。 try{worker.postMessage({version:1,id,bytes});}catch{finish(fault('image_decode_failed'));} })});} diff --git a/src/lib/canvas-runtime/default-image-size.ts b/src/lib/canvas-runtime/default-image-size.ts index 65d19cb..f1cb88d 100644 --- a/src/lib/canvas-runtime/default-image-size.ts +++ b/src/lib/canvas-runtime/default-image-size.ts @@ -1,16 +1,24 @@ +/** + * 为新建图片保留一次可恢复的自动默认尺寸授权。解码/服务元数据异步到达后,只有创建历史、 + * 目标身份/类型、资源引用及初始视觉尺寸仍有效时才允许调整;实际文档/历史改写走专用 planner。 + * 用户已提交的手动 resize 撤销授权,即使以后 undo 回到初始尺寸也不自动复活。 + */ import type { Commit, CoreSnapshot, Id, Registry, Target } from '../canvas-core/types.ts'; import { closeNumber, unitScale } from '../canvas-core/units.ts'; import { fault, freeze, readMap } from '../canvas-core/values.ts'; import type { EngineSnapshot, RuntimeState } from './contracts.ts'; import type { ResourceRecord } from './resources.ts'; +/** 目标代次/创建entry/组件版本和初始视觉尺寸共同限定授权,status/code 描述可恢复结果。 */ export type DefaultSizeGrant = { readonly target: Target; readonly entryId: Id; readonly componentId: string; readonly dataVersion: number; readonly initialSize: { readonly width: number; readonly height: number }; readonly status: 'pending' | 'applied' | 'revoked' | 'blocked'; readonly code?: string; }; +/** 每资源的版本化授权列表;图片副本可有多个目标,各 grant 独立判断。 */ export type DefaultImageSizing = { readonly version: 1; readonly grants: readonly DefaultSizeGrant[] }; +/** 校验解释器、目标/entry/尺寸/状态,并禁止同一 ID+incarnation 的重复 grant。 */ export function validateDefaultImageSizing(value: unknown): asserts value is DefaultImageSizing { const state = value as DefaultImageSizing; if (!state || state.version !== 1) throw fault('default_size_interpreter_unavailable'); @@ -26,15 +34,18 @@ export function validateDefaultImageSizing(value: unknown): asserts value is Def } } +/** 从当前创建提交与关联 history 捕获授权,只针对引用该 assetId 的已知且可 resize 节点。 */ export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSnapshot, commit: Commit, registry: Registry): ResourceRecord { const grants: DefaultSizeGrant[] = []; for (const entry of next.history.entries.values()) { + // 只处理当前 commit 真正贡献过的创建历史,不能把其它旧 entry 偶然匹配的图片纳入授权。 if (!entry.operationIds.some(id => commit.operationIds.includes(id))) continue; for (const change of commit.changes) { if (change.kind !== 'element' || change.before || change.after?.structure !== 'node') continue; const element = next.document.elements.get(change.id); if (element?.structure !== 'node' || element.incarnation !== change.after.incarnation || element.data.assetId !== resource.id || registry.resolve(element) !== 'known' || !registry.components.get(element.componentId)?.resourceFields?.includes('assetId') || !registry.supports(element, 'geometry.resize')) continue; + // 保存 width/height 除 unitScale 的视觉初值,换 owner 的等价坐标转换不算用户手动缩放。 const units = unitScale(element.data.presentation); grants.push(freeze({ target: { id: element.id, incarnation: element.incarnation }, entryId: entry.id, componentId: element.componentId, dataVersion: element.dataVersion, initialSize: { width: element.geometry.width / units, height: element.geometry.height / units }, status: 'pending' })); @@ -46,11 +57,14 @@ export function captureDefaultImageSizing(resource: ResourceRecord, next: CoreSn /** Runs on every publication path. Revocation is durable and does not revive when * undo happens to return to the default geometry. Preview-only gestures do not publish. */ +/** 每条 Root 发布路径检查 pending/blocked grant;失效时持久标 revoked,未变化则复用 runtime。 */ export function prepareDefaultImageSizing(previous: EngineSnapshot, next: EngineSnapshot): RuntimeState { let resources = next.runtime.resources; for (const [id, resource] of resources) { + // 未来子协议保留原样不解释;applied/revoked 是已终结授权,不因当前几何恢复而重新启用。 if (resource.sizing?.version !== 1) continue; let changed = false; + // 目标换代/换类型/换资源,创建entry离开undo,或视觉尺寸已改都使授权失效。 const grants = resource.sizing.grants.map(grant => { if (!['pending', 'blocked'].includes(grant.status)) return grant; const element = next.document.elements.get(grant.target.id); @@ -62,9 +76,11 @@ export function prepareDefaultImageSizing(previous: EngineSnapshot, next: Engine if (!closeNumber(element.geometry.width / units, grant.initialSize.width) || !closeNumber(element.geometry.height / units, grant.initialSize.height)) code = 'user_resized'; // Detect committed resize-away-and-back transactions. Pure coordinate // conversions may alter geometry sizes and unitScale together. + // 已提交事务即使最终尺寸回原值,也检查用户 geometry changes;纯 owner/unit 转换例外。 if (!code && previous.document !== next.document && previous.operations.size !== next.operations.size) { for (const [operationId, intent] of next.operations) { if (previous.operations.has(operationId) || intent.origin !== 'user') continue; + // owner 重挂/表现比例变换可能同时改几何尺寸,不能误当用户主动指定图片新尺寸。 const conversion = intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id && (change.field === 'ownerCanvasId' || change.field === 'data.presentation' && !closeNumber(unitScale(change.before), unitScale(change.after)))); if (!conversion && intent.changes.some(change => change.kind === 'field' && change.target.id === grant.target.id && change.field === 'geometry' @@ -75,6 +91,7 @@ export function prepareDefaultImageSizing(previous: EngineSnapshot, next: Engine if (!code) return grant; changed = true; return freeze({ ...grant, status: 'revoked' as const, code }); }); + // 只有实际撤销某项授权才 copy-on-write 更新资源表,普通 ACK 不重建全部资源对象。 if (changed) { if (resources === next.runtime.resources) resources = new Map(resources); (resources as Map).set(id, freeze({ ...resource, sizing: { version: 1, grants } })); diff --git a/src/lib/canvas-runtime/digest-pool.ts b/src/lib/canvas-runtime/digest-pool.ts index 9aaa599..7c98dfc 100644 --- a/src/lib/canvas-runtime/digest-pool.ts +++ b/src/lib/canvas-runtime/digest-pool.ts @@ -1,13 +1,24 @@ +/** + * 只读摘要计算池:active 限并发,waiting 按 Map 插入顺序 FIFO 等待,超队列预算明确拒绝。 + * 它不保存上传/导入 operation,不持久化任务;workspace 拥有生命周期,业务完整性证据由调用者采用结果。 + * 取消先拒绝调用者,但 active 槽只有执行 Promise 真正结束才释放,避免执行器无视 abort 时超发计算。 + */ import {fault} from '../canvas-core/values.ts'; import {importSourceDigest} from './import-integrity.ts'; +/** 可替换摘要端口,输出完整 Blob 的小写 SHA256;close 终止本池,不清业务数据或外部回执。 */ export type DigestPort={digest(bytes:Blob,signal?:AbortSignal):Promise;close():void}; /** Read-only compute scheduling: bounded active slots + FIFO Map, never durable operation facts. */ +/** 默认2并发/64等待,上限8并发;可注入 Worker/native/测试执行器,默认使用分块摘要。 */ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSignal)=>Promise;concurrency?:number;maxQueued?:number}={}):DigestPort{ const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64,execute=options.execute??importSourceDigest; if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_digest_budget'); + /** 内存计算句柄:独立 AbortController、一次性完成标记与外部 abort 监听清理,不是 ledger 意图。 */ type Task={id:number;bytes:Blob;controller:AbortController;resolve:(value:string)=>void;reject:(error:unknown)=>void;cleanup:()=>void;done:boolean}; + // Map 既保留入队次序,又可在等待期取消时 O(1) 删除;active 保留真实未结束执行的容量占用。 const waiting=new Map(),active=new Map();let next=0,closed=false; + /** 只 settle 一次并移除等待项/监听;故意不在这里 active.delete,迟到结果不能二次采纳。 */ function finish(task:Task,error?:unknown,value?:string){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(value!);} + /** 有空槽就取最早等待项,先登记 active 再微任务执行;结果校验/取消后 finally 归还真实槽。 */ function pump(){ if(closed)return; while(active.size{ if(closed)return Promise.reject(fault('digest_pool_closed'));if(signal?.aborted)return Promise.reject(fault('import_aborted'));if(!(bytes instanceof Blob))return Promise.reject(fault('invalid_digest_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('digest_capacity')); return new Promise((resolve,reject)=>{const task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,cleanup:()=>signal?.removeEventListener('abort',abort),done:false},abort=()=>{task.controller.abort();finish(task,fault('import_aborted'));pump();};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();}); }, + /** 幂等关闭,取消等待/活跃任务的调用结果;无视 signal 的执行仍由其 Promise 收尾后释放 active。 */ close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('import_aborted'));}}, }); } diff --git a/src/lib/canvas-runtime/digest-worker-source.ts b/src/lib/canvas-runtime/digest-worker-source.ts index ed18a52..801dcc6 100644 --- a/src/lib/canvas-runtime/digest-worker-source.ts +++ b/src/lib/canvas-runtime/digest-worker-source.ts @@ -1,17 +1,25 @@ +/** + * 专用 Worker 内的摘要算法选择:小于等于64MiB可用原生 SubtleCrypto,较大文件分块流式 SHA。 + * 整缓冲读取只允许发生在 Worker,取消由宿主终止该请求的 Worker;本函数不访问存储或文档。 + */ import {fault} from '../canvas-core/values.ts'; import {importSourceDigest} from './import-integrity.ts'; // Each of the browser pool's two active Workers admits at most 64 MiB; larger inputs stream. +/** 单任务 native 缓冲阈值,不是整个应用内存硬上限;总并发由外部 pool 决定。 */ export const nativeDigestByteLimit=64*1024*1024; /** Dedicated Worker only: whole-buffer native hashing never moves onto the UI thread. * The host still owns cancellation by terminating the request's Worker. */ +/** 验证 Blob,按能力/大小选 native 或增量实现,均返回相同的完整原字节摘要。 */ export async function digestWorkerSource(bytes:Blob,subtle:Pick|null|undefined=globalThis.crypto?.subtle):Promise{ if(!(bytes instanceof Blob))throw fault('invalid_digest_input'); + // native digest 必须返回32字节;输出固定64字符十六进制,不接受变长响应。 if(subtle&&bytes.size<=nativeDigestByteLimit){ const input=await bytes.arrayBuffer(),digest=await subtle.digest('SHA-256',input); if(digest.byteLength!==32)throw fault('invalid_digest_response'); return [...new Uint8Array(digest)].map(value=>value.toString(16).padStart(2,'0')).join(''); } + // Worker fallback 每块1MiB,只做微任务让出;UI 让出策略由 UI fallback 的端口另行决定。 return importSourceDigest(bytes,undefined,{chunkBytes:1024*1024,yieldTask:()=>Promise.resolve()}); } diff --git a/src/lib/canvas-runtime/digest.worker.ts b/src/lib/canvas-runtime/digest.worker.ts index 1f9907f..37b3825 100644 --- a/src/lib/canvas-runtime/digest.worker.ts +++ b/src/lib/canvas-runtime/digest.worker.ts @@ -1,7 +1,14 @@ +/** + * 单请求摘要 Worker 入口。只接收 version/id/Blob,输出摘要或稳定错误码,不拥有任何模型、凭证或持久任务。 + * 宿主在完成/取消时 terminate;accepted 防止一个专用 Worker 误被重复消息启动第二份计算。 + */ import {digestWorkerSource} from './digest-worker-source.ts'; // Dedicated to one request. Host terminates on success/failure/abort; no storage, model or credentials here. +/** 将 globalThis 限定为本 Worker 使用的消息接口,避免引入浏览器页面组件依赖。 */ const scope=globalThis as unknown as {onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; +/** 首条有效请求被接受后保持 true,直到整个 Worker 被宿主终止。 */ let accepted=false; +/** 校验协议和请求后启动只读摘要;成功/失败都回显输入 ID 与字节数,供宿主核对。 */ scope.onmessage=event=>{ const request=event.data;if(accepted||!request||request.version!==1||!Number.isSafeInteger(request.id)||request.id<1||!(request.bytes instanceof Blob))return;accepted=true; void digestWorkerSource(request.bytes).then(sha256=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,sha256}),()=>scope.postMessage({version:1,id:request.id,byteLength:request.bytes.size,error:'digest_worker_failed'})); diff --git a/src/lib/canvas-runtime/display-cache.ts b/src/lib/canvas-runtime/display-cache.ts index fb37749..61dffeb 100644 --- a/src/lib/canvas-runtime/display-cache.ts +++ b/src/lib/canvas-runtime/display-cache.ts @@ -1,17 +1,29 @@ +/** + * 临时显示资源的共享租约,按不透明 owner 对象+key 复用进行中的加载和 URL。 + * 不拥有上传/授权/原字节持久化,调用者取得租约前必须确认权限并保留字节;没有 idle URL 缓存。 + * 最后一个租约释放就 abort/清理来源;迟到 load 返回的资源也会被释放,不能跨退役 owner 重新采用。 + */ import { fault } from '../canvas-core/values.ts'; +/** 加载器返回的临时 URL 与可选清理回调(如 revokeObjectURL),不是永久资源身份。 */ export type DisplaySource = { readonly url: string; release?(): void }; +/** 每个消费者独立 ready/release,共享来源不意味着一个 view 卸载可以释放其它 view 的租约。 */ export type DisplayLease = { readonly ready: Promise; release(): void }; +/** owner/key 的内存条目,controller 控制共享加载,leases 精确记录当前借用者,retired 防迟到结果。 */ type Entry = { readonly key: string; readonly owner: object; readonly controller: AbortController; readonly leases: Set<() => void>; promise: Promise; source?: DisplaySource; retired: boolean }; /** Ephemeral display ownership only. The caller checks authorization and pins * durable bytes before acquiring. Scope objects are never serialized or reused * across project engines/auth epochs. There is no idle URL retention. */ +/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */ export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) { + // owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。 const owners = new Map>(), retired = new WeakSet(); let closed = false; + /** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */ function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } } + /** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */ function retire(entry: Entry) { if (entry.retired) return; entry.retired = true; @@ -20,6 +32,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void entry.controller.abort(); if (entry.source) { dispose(entry.source); entry.source = undefined; } } + /** 永久撤销一个 owner 的所有租约;逐租约拒绝尚未完成 ready,再清理来源/请求。 */ function releaseOwner(owner: object) { retired.add(owner); for (const entry of [...(owners.get(owner)?.values() ?? [])]) { @@ -28,11 +41,13 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void } } return Object.freeze({ + /** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */ acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise): DisplayLease { if (closed || retired.has(owner)) throw fault('display_scope_closed'); if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key'); let entries = owners.get(owner); if (!entries) { entries = new Map(); owners.set(owner, entries); } let entry = entries.get(key); + // 先登记 created 再微任务调用外部 load,使同步异常也有清理句柄,不重复启动相同 key 加载。 if (!entry) { const created: Entry = { owner, key, controller: new AbortController(), leases: new Set(), retired: false, promise: undefined as unknown as Promise }; entry = created; entries.set(key, created); @@ -41,18 +56,21 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void return load(created.controller.signal); }).then(source => { if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); } + // 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。 if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); } created.source = source; return source; }, error => { retire(created); throw error; }); } const shared = entry; let released = false, rejectReady: (error: unknown) => void = () => {}; + /** 只释放本租约;未结束 ready 用 display_released 拒绝,最后一个租约才退休共享 entry。 */ const release = () => { if (released) return; released = true; shared.leases.delete(release); rejectReady(fault('display_released')); if (!shared.leases.size) retire(shared); }; shared.leases.add(release); + // 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。 const ready = new Promise((resolve, reject) => { rejectReady = reject; shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } }); @@ -60,7 +78,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void return Object.freeze({ ready, release }); }, releaseOwner, + /** 幂等关闭整个缓存并退休全部 owner,不删除文件或撤销已确认上传。 */ close() { if (closed) return; closed = true; for (const owner of [...owners.keys()]) releaseOwner(owner); }, }); } +/** 公共 API 由工厂推导;view 只借用 lease,不直接拥有该 workspace 级缓存。 */ export type DisplayCache = ReturnType; diff --git a/src/lib/canvas-runtime/image-decoder.ts b/src/lib/canvas-runtime/image-decoder.ts index aa09e62..ff12fb1 100644 --- a/src/lib/canvas-runtime/image-decoder.ts +++ b/src/lib/canvas-runtime/image-decoder.ts @@ -1,17 +1,31 @@ +/** + * 可恢复解码状态合同与只读图片计算池。Blob 属于文件仓库,解码仅产生尺寸,不重编码/替换原字节。 + * 等待/执行都在内存调度,结果由 engine 校验生命周期后写入资源事实;不能直接在回调中改元素。 + */ import type {Json} from '../canvas-core/types.ts'; import {fault,freeze} from '../canvas-core/values.ts'; +/** 解码器返回的正整数宽高,不包含临时 bitmap 或浏览器对象。 */ export type ImageDimensions={readonly width:number;readonly height:number}; +/** 当前 v1 的 pending/ready/failed;ready 记录尺寸来源为 decode 或 server。 */ 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}>; +/** 宿主可注入 Worker 或其它解码执行器,close 属于计算池生命周期,不属于 view。 */ 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. */ +/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */ 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'); + /** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */ 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; + /** 幂等 settle 调用者,成功捕获冻结尺寸;不提前归还仍在执行的 active 槽。 */ 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!}));} + /** FIFO 取任务并在微任务执行,校验返回尺寸,最后 finally 删除 active 再继续泵;异常不毒化后续队列。 */ 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();});}} + /** decode 校验图片 MIME/容量并建立任务;close 取消所有调用结果,active 实际结束仍由 finally 收尾。 */ 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 index 8b71390..cc1bed6 100644 --- a/src/lib/canvas-runtime/image.worker.ts +++ b/src/lib/canvas-runtime/image.worker.ts @@ -1,7 +1,15 @@ +/** + * 专用图片解码 Worker:使用 createImageBitmap 读取尺寸,立即 close bitmap 后回传纯数据。 + * 它不输出转码图片、不更改原文件,也不创建新上传/生成任务;宿主负责终止 Worker 与核对消息归属。 + */ +/** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */ const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; +/** 接受正确版本/id/Blob,按 from-image 选项解码;失败回稳定 image_decode_failed。 */ worker.onmessage=async event=>{ const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return; + // bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。 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'});} }; +// 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。 export {}; diff --git a/src/lib/canvas-runtime/import-budget.ts b/src/lib/canvas-runtime/import-budget.ts index b49e75d..025ea12 100644 --- a/src/lib/canvas-runtime/import-budget.ts +++ b/src/lib/canvas-runtime/import-budget.ts @@ -1,5 +1,10 @@ +/** + * 共享合作调度软预算,供导入、复制、恢复编码等逐项计算调用。 + * 既按耗时判断,也按工作量兜底,避免粗粒度/倒退时钟让长任务一直不让出;单个不可分步骤仍由调用方限大小。 + */ import {fault} from '../canvas-core/values.ts'; +/** 注入时钟、让出任务与目标时间片;它们不改变记录语义或操作身份。 */ export type ImportScheduling = { now?: () => number; yieldTask?: () => Promise; @@ -8,13 +13,16 @@ export type ImportScheduling = { /** A soft time slice with a work threshold, even when the clock is coarse. * The caller bounds each indivisible parse/decode operation separately. */ +/** 默认6ms软目标;step 不一定产生 Promise,调用方须在返回等待值时真正 await/yield。 */ export function createImportBudget(signal: AbortSignal, options: ImportScheduling = {}) { const budget = options.frameBudgetMs ?? 6; if (!Number.isFinite(budget) || budget <= 0) throw fault('invalid_import_budget'); const now = options.now ?? (() => performance.now()); const yieldTask = options.yieldTask ?? (() => new Promise(resolve => setTimeout(resolve, 0))); + // 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。 let started = now(), work = 0, unchecked = 0; return { + /** 记工作量并按阈值检查取消/时间;达到预算或时间异常时让出,回来再验取消并重置时间片。 */ step(units = 1): Promise | undefined { work += units; unchecked += units; @@ -23,6 +31,7 @@ export function createImportBudget(signal: AbortSignal, options: ImportSchedulin if (signal.aborted) throw fault('import_aborted'); const elapsed = now() - started; if (work < 16384 && elapsed >= 0 && elapsed < budget) return; + // 这里的异步边界才实际交出执行权;预算是 cooperative,不能抢占前面已经运行的同步工作。 return (async () => { await yieldTask(); if (signal.aborted) throw fault('import_aborted'); diff --git a/src/lib/canvas-runtime/import-integrity.ts b/src/lib/canvas-runtime/import-integrity.ts index 6cd1d8e..b450383 100644 --- a/src/lib/canvas-runtime/import-integrity.ts +++ b/src/lib/canvas-runtime/import-integrity.ts @@ -1,10 +1,17 @@ +/** + * 完整导入源的增量 SHA256:有界 Blob 分块读取,不把全文件放进 UI ArrayBuffer。 + * 摘要是源完整性/稳定身份依据,不证明该文件有权限、schema 正确或所有记录已加入画布。 + */ import { fault } from '../canvas-core/values.ts'; import {createSha256} from './sha256.ts'; /** Complete-source SHA-256, with bounded chunk memory and cooperative fallback scheduling. */ +/** 默认64KiB分块(配置上限4MiB),每块读前/读后检查取消,并在块间合作让出。 */ export async function importSourceDigest(bytes:Blob,signal?:AbortSignal,options:{chunkBytes?:number;yieldTask?:()=>Promise}={}):Promise{ const chunk=options.chunkBytes??64*1024,yieldTask=options.yieldTask??(()=>new Promise(resolve=>setTimeout(resolve,0))); if(!(bytes instanceof Blob)||!Number.isSafeInteger(chunk)||chunk<1||chunk>4*1024*1024)throw fault('invalid_digest_input'); + /** 读/计算/让出边界统一检查 signal;异步读取即使无法中止,迟到字节也不再参与有效结果。 */ const check=()=>{if(signal?.aborted)throw fault('import_aborted');};check();const hash=createSha256(); + // 更新增量状态后释放对本块的局部引用;空文件直接 hex 得到标准空输入摘要。 for(let cursor=0;cursor>>0 截断到模2^32。 */ const K=new Uint32Array([ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, @@ -11,24 +17,33 @@ const K=new Uint32Array([ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, ]); +/** 32位循环右移;区别于简单逻辑右移,低位从左侧回填。 */ const rotate=(n:number,bits:number)=>(n>>>bits)|(n<<(32-bits)); /** Incremental bytes only; owns at most a partial block, state and message schedule. */ +/** 新建单次摘要状态;允许多次 update,hex 终结后只可重复读结果,不能继续追加。 */ export function createSha256(){ + // length 计原输入字节;pending 计尾块已占字节;result 缓存最终十六进制并标记终结。 const state=new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]),block=new Uint8Array(64),words=new Uint32Array(64);let pending=0,length=0,result:string|undefined; + /** 压缩一个完整64字节块:大端读16字→扩展64调度字→64轮工作寄存器→加回累计状态。 */ function compress(bytes:Uint8Array,offset:number){ const view=new DataView(bytes.buffer,bytes.byteOffset+offset,64);for(let i=0;i<16;i++)words[i]=view.getUint32(i*4,false); + // 小sigma 混合之前调度字,Uint32Array/>>>0 保持32位算术;不把整个输入转换为巨大数字。 for(let i=16;i<64;i++){const x=words[i-15],y=words[i-2],s0=rotate(x,7)^rotate(x,18)^(x>>>3),s1=rotate(y,17)^rotate(y,19)^(y>>>10);words[i]=(words[i-16]+s0+words[i-7]+s1)>>>0;} let[a,b,c,d,e,f,g,h]=state; + // 大Sigma、Ch、Maj 组合得到t1/t2,按顺序滚动a..h;每轮仍只操作固定8个工作字。 for(let i=0;i<64;i++){const s1=rotate(e,6)^rotate(e,11)^rotate(e,25),ch=(e&f)^(~e&g),t1=(h+s1+ch+K[i]+words[i])>>>0,s0=rotate(a,2)^rotate(a,13)^rotate(a,22),maj=(a&b)^(a&c)^(b&c),t2=(s0+maj)>>>0;h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;} const values=[a,b,c,d,e,f,g,h];for(let i=0;i<8;i++)state[i]=(state[i]+values[i])>>>0; } return Object.freeze({ + /** 先补上次尾块,再就地压缩输入完整块,最后只复制不足64字节的尾部。 */ update(bytes:Uint8Array){ if(result!==undefined)throw fault('digest_finalized');if(!(bytes instanceof Uint8Array)||!Number.isSafeInteger(length+bytes.byteLength))throw fault('invalid_digest_input');length+=bytes.byteLength;let offset=0; if(pending){const count=Math.min(64-pending,bytes.byteLength);block.set(bytes.subarray(0,count),pending);pending+=count;offset=count;if(pending===64){compress(block,0);pending=0;}} while(offset+64<=bytes.byteLength){compress(bytes,offset);offset+=64;} if(offset56){block.fill(0,pending);compress(block,0);pending=0;}