docs(canvas): explain compute workers and display resource lifetimes

This commit is contained in:
2026-11-12 00:19:19 +08:00
parent af8e2fe76b
commit b471d88520
12 changed files with 136 additions and 0 deletions
@@ -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<string>((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'));}
})});
}
+9
View File
@@ -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<ImageDimensions>((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'));}
})});}
@@ -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<Id, ResourceRecord>).set(id, freeze({ ...resource, sizing: { version: 1, grants } }));
+13
View File
@@ -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<string>;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<string>;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<number,Task>(),active=new Map<number,Task>();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<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);
@@ -15,10 +26,12 @@ export function createDigestPool(options:{execute?:(bytes:Blob,signal:AbortSigna
}
}
return Object.freeze({
/** 检查生命周期、取消、Blob 与容量,建立独立任务;abort 同时通知执行器并取消调用者等待。 */
digest(bytes:Blob,signal?:AbortSignal):Promise<string>{
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'));}},
});
}
@@ -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<SubtleCrypto,'digest'>|null|undefined=globalThis.crypto?.subtle):Promise<string>{
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()});
}
+7
View File
@@ -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'}));
+20
View File
@@ -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<string>; release(): void };
/** owner/key 的内存条目,controller 控制共享加载,leases 精确记录当前借用者,retired 防迟到结果。 */
type Entry = { readonly key: string; readonly owner: object; readonly controller: AbortController; readonly leases: Set<() => void>; promise: Promise<DisplaySource>; 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<object, Map<string, Entry>>(), retired = new WeakSet<object>();
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<DisplaySource>): 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<DisplaySource> };
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<string>((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<typeof createDisplayCache>;
+14
View File
@@ -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<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. */
/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */
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');
/** 每个解码请求独立控制器/完成标记;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<number,Task>(),active=new Map<number,Task>();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<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();});}}
/** 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<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'));}}});
}
+8
View File
@@ -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 {};
+9
View File
@@ -1,5 +1,10 @@
/**
* 共享合作调度软预算,供导入、复制、恢复编码等逐项计算调用。
* 既按耗时判断,也按工作量兜底,避免粗粒度/倒退时钟让长任务一直不让出;单个不可分步骤仍由调用方限大小。
*/
import {fault} from '../canvas-core/values.ts';
/** 注入时钟、让出任务与目标时间片;它们不改变记录语义或操作身份。 */
export type ImportScheduling = {
now?: () => number;
yieldTask?: () => Promise<void>;
@@ -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<void>(resolve => setTimeout(resolve, 0)));
// 每128工作单位才读一次时钟,减少高频开销;总量16384强制让出,不能因时间测不准而无限执行。
let started = now(), work = 0, unchecked = 0;
return {
/** 记工作量并按阈值检查取消/时间;达到预算或时间异常时让出,回来再验取消并重置时间片。 */
step(units = 1): Promise<void> | 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');
@@ -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<void>}={}):Promise<string>{
const chunk=options.chunkBytes??64*1024,yieldTask=options.yieldTask??(()=>new Promise<void>(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<bytes.size;cursor+=chunk){check();const part=await bytes.slice(cursor,cursor+chunk).arrayBuffer();check();hash.update(new Uint8Array(part));if(cursor+chunk<bytes.size){await yieldTask();check();}}
check();return hash.hex();
}
+15
View File
@@ -1,6 +1,12 @@
/**
* SHA256 的增量字节实现,适用于大 Blob 分块计算的 fallback。
* 仅保留64字节尾块、8个状态字与64字消息调度;不累计所有输入,也不读取任何浏览器/文档状态。
* 算法常量/压缩/填充保留原实现;这里的摘要用于文件完整性,不是权限、签名或加密存储。
*/
import {fault} from '../canvas-core/values.ts';
// SHA-256 compression/padding: NIST FIPS 180-4 (2015), §§4.1.2, 4.2.2, 5.1.1, 6.2.
// https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf
/** 每轮固定常量表,压缩的32位加法统一用 >>>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(offset<bytes.byteLength){block.set(bytes.subarray(offset));pending=bytes.byteLength-offset;}
},
/** 终结:追加0x80,补零到56字节后写64位大端位长度;必要时多压一块。
* 输出每状态字固定8字符,缓存后清工作缓冲;不是擦除调用者原输入。 */
hex():string{
if(result!==undefined)return result;block[pending++]=0x80;
if(pending>56){block.fill(0,pending);compress(block,0);pending=0;}