@lokvis/runtime 0.2.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/asset-store.d.ts +13 -0
  2. package/dist/asset-store.d.ts.map +1 -1
  3. package/dist/asset-store.js +15 -2
  4. package/dist/asset-store.js.map +1 -1
  5. package/dist/batch-processor.d.ts +32 -161
  6. package/dist/batch-processor.d.ts.map +1 -1
  7. package/dist/batch-processor.js +107 -333
  8. package/dist/batch-processor.js.map +1 -1
  9. package/dist/batch-progress.d.ts +94 -0
  10. package/dist/batch-progress.d.ts.map +1 -0
  11. package/dist/batch-progress.js +128 -0
  12. package/dist/batch-progress.js.map +1 -0
  13. package/dist/batch-scheduler.d.ts +70 -0
  14. package/dist/batch-scheduler.d.ts.map +1 -0
  15. package/dist/batch-scheduler.js +147 -0
  16. package/dist/batch-scheduler.js.map +1 -0
  17. package/dist/browser-detect.d.ts +115 -0
  18. package/dist/browser-detect.d.ts.map +1 -0
  19. package/dist/browser-detect.js +160 -0
  20. package/dist/browser-detect.js.map +1 -0
  21. package/dist/concurrency-controller.d.ts +31 -0
  22. package/dist/concurrency-controller.d.ts.map +1 -0
  23. package/dist/concurrency-controller.js +33 -0
  24. package/dist/concurrency-controller.js.map +1 -0
  25. package/dist/errors.d.ts +51 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +74 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/executor.d.ts +9 -0
  30. package/dist/executor.d.ts.map +1 -1
  31. package/dist/executor.js +87 -48
  32. package/dist/executor.js.map +1 -1
  33. package/dist/history.d.ts.map +1 -1
  34. package/dist/history.js +3 -2
  35. package/dist/history.js.map +1 -1
  36. package/dist/idb-asset-store.d.ts.map +1 -1
  37. package/dist/idb-asset-store.js +6 -1
  38. package/dist/idb-asset-store.js.map +1 -1
  39. package/dist/index.d.ts +17 -15
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +20 -15
  42. package/dist/index.js.map +1 -1
  43. package/dist/managers/asset-manager.d.ts +114 -0
  44. package/dist/managers/asset-manager.d.ts.map +1 -0
  45. package/dist/managers/asset-manager.js +238 -0
  46. package/dist/managers/asset-manager.js.map +1 -0
  47. package/dist/managers/history-manager.d.ts +100 -0
  48. package/dist/managers/history-manager.d.ts.map +1 -0
  49. package/dist/managers/history-manager.js +296 -0
  50. package/dist/managers/history-manager.js.map +1 -0
  51. package/dist/managers/mcp-manifest-builder.d.ts +18 -0
  52. package/dist/managers/mcp-manifest-builder.d.ts.map +1 -0
  53. package/dist/managers/mcp-manifest-builder.js +99 -0
  54. package/dist/managers/mcp-manifest-builder.js.map +1 -0
  55. package/dist/managers/quota-manager.d.ts +42 -0
  56. package/dist/managers/quota-manager.d.ts.map +1 -0
  57. package/dist/managers/quota-manager.js +126 -0
  58. package/dist/managers/quota-manager.js.map +1 -0
  59. package/dist/managers/workflow-coordinator.d.ts +62 -0
  60. package/dist/managers/workflow-coordinator.d.ts.map +1 -0
  61. package/dist/managers/workflow-coordinator.js +125 -0
  62. package/dist/managers/workflow-coordinator.js.map +1 -0
  63. package/dist/opfs-asset-store.d.ts.map +1 -1
  64. package/dist/opfs-asset-store.js +12 -2
  65. package/dist/opfs-asset-store.js.map +1 -1
  66. package/dist/plugin-context.d.ts +35 -0
  67. package/dist/plugin-context.d.ts.map +1 -0
  68. package/dist/plugin-context.js +62 -0
  69. package/dist/plugin-context.js.map +1 -0
  70. package/dist/plugin-permissions.d.ts +99 -0
  71. package/dist/plugin-permissions.d.ts.map +1 -0
  72. package/dist/plugin-permissions.js +205 -0
  73. package/dist/plugin-permissions.js.map +1 -0
  74. package/dist/runtime-impl.d.ts +116 -0
  75. package/dist/runtime-impl.d.ts.map +1 -0
  76. package/dist/runtime-impl.js +270 -0
  77. package/dist/runtime-impl.js.map +1 -0
  78. package/dist/runtime.d.ts +10 -233
  79. package/dist/runtime.d.ts.map +1 -1
  80. package/dist/runtime.js +12 -978
  81. package/dist/runtime.js.map +1 -1
  82. package/dist/test-utils/fakes.d.ts +35 -0
  83. package/dist/test-utils/fakes.d.ts.map +1 -0
  84. package/dist/test-utils/fakes.js +60 -0
  85. package/dist/test-utils/fakes.js.map +1 -0
  86. package/dist/types.d.ts +113 -1
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/worker-host.d.ts.map +1 -1
  89. package/dist/worker-host.js +32 -9
  90. package/dist/worker-host.js.map +1 -1
  91. package/dist/worker-protocol.d.ts +30 -0
  92. package/dist/worker-protocol.d.ts.map +1 -1
  93. package/dist/worker-protocol.js +24 -0
  94. package/dist/worker-protocol.js.map +1 -1
  95. package/package.json +2 -2
  96. package/src/asset-store.ts +32 -2
  97. package/src/batch-processor.ts +133 -510
  98. package/src/batch-progress.ts +228 -0
  99. package/src/batch-scheduler.ts +209 -0
  100. package/src/browser-detect.ts +221 -0
  101. package/src/concurrency-controller.ts +52 -0
  102. package/src/errors.ts +82 -0
  103. package/src/executor.ts +106 -55
  104. package/src/history.ts +3 -2
  105. package/src/idb-asset-store.ts +7 -1
  106. package/src/index.ts +50 -15
  107. package/src/managers/asset-manager.ts +259 -0
  108. package/src/managers/history-manager.ts +327 -0
  109. package/src/managers/mcp-manifest-builder.ts +108 -0
  110. package/src/managers/quota-manager.ts +149 -0
  111. package/src/managers/workflow-coordinator.ts +158 -0
  112. package/src/opfs-asset-store.ts +16 -2
  113. package/src/plugin-context.ts +87 -0
  114. package/src/plugin-permissions.ts +228 -0
  115. package/src/runtime-impl.ts +298 -0
  116. package/src/runtime.ts +16 -1116
  117. package/src/test-utils/fakes.ts +72 -0
  118. package/src/types.ts +116 -0
  119. package/src/worker-host.ts +35 -7
  120. package/src/worker-protocol.ts +47 -0
  121. package/dist/workflow-builder.d.ts +0 -124
  122. package/dist/workflow-builder.d.ts.map +0 -1
  123. package/dist/workflow-builder.js +0 -240
  124. package/dist/workflow-builder.js.map +0 -1
  125. package/src/workflow-builder.ts +0 -303
@@ -0,0 +1,228 @@
1
+ /**
2
+ * BatchProgressEmitter - 批量作业进度聚合与事件发射(W4.4 拆分)
3
+ *
4
+ * 职责:
5
+ * - 托管批量公开接口(BatchProgress / BatchItemInput / BatchItem / BatchJob / EnqueueOptions)
6
+ * - 管理 onProgress 订阅 + 发射所有 batch:* 事件
7
+ * - waitForCompletion:事件等待 + 超时(泛型,不依赖 BatchJob)
8
+ *
9
+ * 设计原则:无状态,不持有 job 引用,所有数据通过参数传入。
10
+ */
11
+ import type {
12
+ AssetId,
13
+ AssetSource,
14
+ BatchItemStatus,
15
+ BatchJobStatus,
16
+ LokvisEvent,
17
+ Workflow,
18
+ } from '@lokvis/schema';
19
+ import type { EventBus } from '@lokvis/schema';
20
+
21
+ /** 进度回调载荷 */
22
+ export interface BatchProgress {
23
+ jobId: string;
24
+ completed: number;
25
+ failed: number;
26
+ total: number;
27
+ }
28
+
29
+ /** 批量项输入(每个文件一个) */
30
+ export interface BatchItemInput {
31
+ source: AssetSource;
32
+ workflow: Workflow;
33
+ maxRetries?: number;
34
+ }
35
+
36
+ /** 批量项运行时状态(对外只读视图) */
37
+ export interface BatchItem {
38
+ id: string;
39
+ index: number;
40
+ status: BatchItemStatus;
41
+ source: AssetSource;
42
+ workflow: Workflow;
43
+ outputAssetId?: AssetId;
44
+ error?: Error;
45
+ attempts: number;
46
+ maxRetries: number;
47
+ duration?: number;
48
+ }
49
+
50
+ /** 批量作业运行时状态(对外只读视图) */
51
+ export interface BatchJob {
52
+ id: string;
53
+ status: BatchJobStatus;
54
+ items: BatchItem[];
55
+ completed: number;
56
+ failed: number;
57
+ total: number;
58
+ startedAt: number;
59
+ endedAt?: number;
60
+ }
61
+
62
+ /** enqueue 选项 */
63
+ export interface EnqueueOptions {
64
+ items: BatchItemInput[];
65
+ maxRetries?: number;
66
+ concurrency?: number;
67
+ }
68
+
69
+ /** 批量进度发射器:统一管理 batch:* 事件与 progress 订阅。 */
70
+ export class BatchProgressEmitter {
71
+ private readonly eventBus: EventBus;
72
+ private readonly progressSubs = new Map<string, Set<(p: BatchProgress) => void>>();
73
+
74
+ constructor(eventBus: EventBus) {
75
+ this.eventBus = eventBus;
76
+ }
77
+
78
+ /** 订阅 job 进度(返回取消订阅函数) */
79
+ onProgress(jobId: string, handler: (p: BatchProgress) => void): () => void {
80
+ let subs = this.progressSubs.get(jobId);
81
+ if (!subs) {
82
+ subs = new Set();
83
+ this.progressSubs.set(jobId, subs);
84
+ }
85
+ subs.add(handler);
86
+ return () => {
87
+ subs!.delete(handler);
88
+ if (subs!.size === 0) this.progressSubs.delete(jobId);
89
+ };
90
+ }
91
+
92
+ /** 发出 progress 事件 + 通知订阅者 */
93
+ emitProgress(jobId: string, completed: number, failed: number, total: number): void {
94
+ const payload: BatchProgress = { jobId, completed, failed, total };
95
+ this.emit({ type: 'batch:progress', ...payload });
96
+ const subs = this.progressSubs.get(jobId);
97
+ if (subs) {
98
+ // M1:遍历副本,防止 handler 内 unsubscribe mutate 正在迭代的 Set
99
+ for (const fn of [...subs]) {
100
+ try {
101
+ fn(payload);
102
+ } catch (err) {
103
+ // 单个订阅者异常不阻断其他订阅者(与 event-bus.ts onAny 隔离策略一致)
104
+ console.error(`[lokvis] batch-progress subscriber threw for job ${jobId}:`, err);
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ emitStarted(jobId: string, total: number): void {
111
+ this.emit({ type: 'batch:started', jobId, total });
112
+ }
113
+
114
+ emitItemStarted(jobId: string, itemId: string, index: number, total: number): void {
115
+ this.emit({ type: 'batch:item:started', jobId, itemId, index, total });
116
+ }
117
+
118
+ emitItemFinished(
119
+ jobId: string, itemId: string, index: number, total: number,
120
+ outputAssetId: AssetId, duration: number
121
+ ): void {
122
+ this.emit({ type: 'batch:item:finished', jobId, itemId, index, total, outputAssetId, duration });
123
+ }
124
+
125
+ emitItemFailed(
126
+ jobId: string, itemId: string, index: number, total: number,
127
+ error: Error, attempts: number
128
+ ): void {
129
+ this.emit({ type: 'batch:item:failed', jobId, itemId, index, total, error, attempts });
130
+ }
131
+
132
+ /** batch:completed(无论 completed 还是 failed 状态都发此事件) */
133
+ emitCompleted(
134
+ jobId: string, total: number, completed: number, failed: number, duration: number
135
+ ): void {
136
+ this.emit({ type: 'batch:completed', jobId, total, completed, failed, duration });
137
+ }
138
+
139
+ emitCancelled(jobId: string, cancelled: number): void {
140
+ this.emit({ type: 'batch:cancelled', jobId, cancelled });
141
+ }
142
+
143
+ emitPaused(jobId: string): void {
144
+ this.emit({ type: 'batch:paused', jobId });
145
+ }
146
+
147
+ emitResumed(jobId: string): void {
148
+ this.emit({ type: 'batch:resumed', jobId });
149
+ }
150
+
151
+ /** 清理 job 的所有进度订阅(终态时调用,避免泄漏) */
152
+ cleanupJobSubs(jobId: string): void {
153
+ this.progressSubs.delete(jobId);
154
+ }
155
+
156
+ /**
157
+ * 等待 job 完成(completed/failed/cancelled 任一)。
158
+ *
159
+ * M5:timeoutMs 默认 5min,超时 reject + 调用 onTimeout(cancel job),避免坏 job
160
+ * 永久挂起泄漏订阅。batch:completed 涵盖 failed(maybeComplete 对 failed>0 同样发),
161
+ * 故只需监听 completed + cancelled。resolve 后立即解绑订阅。
162
+ *
163
+ * 泛型 T 不依赖 BatchJob,由调用方提供 resolveJob 回调构造视图。
164
+ */
165
+ async waitForCompletion<T>(
166
+ jobId: string,
167
+ opts: {
168
+ timeoutMs?: number;
169
+ isTerminal: () => boolean;
170
+ resolveJob: () => T;
171
+ onTimeout: () => Promise<void>;
172
+ }
173
+ ): Promise<T> {
174
+ if (opts.isTerminal()) return opts.resolveJob();
175
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000;
176
+
177
+ return new Promise<T>((resolve, reject) => {
178
+ let settled = false;
179
+ let offCompleted: () => void;
180
+ let offCancelled: () => void;
181
+ let timer: ReturnType<typeof setTimeout>;
182
+ const finish = (fn: () => void) => {
183
+ if (settled) return;
184
+ settled = true;
185
+ clearTimeout(timer);
186
+ offCompleted();
187
+ offCancelled();
188
+ fn();
189
+ };
190
+ offCompleted = this.eventBus.on('batch:completed', (e) => {
191
+ if (e.jobId !== jobId) return;
192
+ finish(() => resolve(opts.resolveJob()));
193
+ });
194
+ offCancelled = this.eventBus.on('batch:cancelled', (e) => {
195
+ if (e.jobId !== jobId) return;
196
+ finish(() => resolve(opts.resolveJob()));
197
+ });
198
+ timer = setTimeout(() => {
199
+ // 先 finish(reject + 取消订阅),再 onTimeout(cancel)。
200
+ // 顺序重要:finish 内 offCancelled() 解除订阅,此后 cancel 触发的
201
+ // batch:cancelled 不会被本 Promise 监听,避免抢先 resolve。
202
+ finish(() =>
203
+ reject(
204
+ new Error(
205
+ `BatchProcessor.waitForCompletion timed out after ${timeoutMs}ms (jobId=${jobId})`
206
+ )
207
+ )
208
+ );
209
+ void opts.onTimeout().catch((err) => {
210
+ console.warn(
211
+ `[lokvis] BatchProcessor: cancel(${jobId}) after timeout failed:`,
212
+ err
213
+ );
214
+ });
215
+ }, timeoutMs);
216
+ });
217
+ }
218
+
219
+ /** 安全 emit(EventBus 异常不阻断批量逻辑) */
220
+ private emit(event: LokvisEvent): void {
221
+ try {
222
+ this.eventBus.emit(event);
223
+ } catch (err) {
224
+ // EventBus 异常不阻断批量逻辑,但需记录便于调试
225
+ console.warn(`[lokvis] batch-progress emit("${event.type}") threw:`, err);
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * BatchScheduler - 批量作业调度与重试(W4.4 拆分)
3
+ *
4
+ * 职责:
5
+ * - 调度循环:schedule 把 pending 项填入空闲槽位(ConcurrencyController)
6
+ * - 项处理:processItem 导入 → run → 导出 output AssetId
7
+ * - 失败重试:maxRetries 内回 pending,达上限标 failed
8
+ * - 完成检测:maybeComplete
9
+ * - cancel 竞态守卫(B1)
10
+ *
11
+ * 不持有 jobs Map(由 Facade 持有),通过参数接收 job。
12
+ */
13
+ import type {
14
+ AssetId,
15
+ AssetSource,
16
+ BatchItemStatus,
17
+ BatchJobStatus,
18
+ Workflow,
19
+ WorkflowResult,
20
+ } from '@lokvis/schema';
21
+ import type { LokvisRuntime } from './types.js';
22
+ import type { BatchProgressEmitter } from './batch-progress.js';
23
+ import type { ConcurrencyController } from './concurrency-controller.js';
24
+
25
+ /** BatchItem 内部可变状态 */
26
+ export interface BatchItemInternal {
27
+ id: string;
28
+ index: number;
29
+ status: BatchItemStatus;
30
+ source: AssetSource;
31
+ workflow: Workflow;
32
+ outputAssetId?: AssetId;
33
+ error?: Error;
34
+ attempts: number;
35
+ maxRetries: number;
36
+ duration?: number;
37
+ }
38
+
39
+ /** BatchJob 内部可变状态 */
40
+ export interface BatchJobInternal {
41
+ id: string;
42
+ status: BatchJobStatus;
43
+ baseConcurrency: number;
44
+ maxRetries: number;
45
+ items: BatchItemInternal[];
46
+ completed: number;
47
+ failed: number;
48
+ startedAt: number;
49
+ endedAt?: number;
50
+ scheduleLock: boolean;
51
+ paused: boolean;
52
+ cancelled: boolean;
53
+ }
54
+
55
+ /** 批量调度器:负责 job 的调度循环、项处理、重试与完成检测。 */
56
+ export class BatchScheduler {
57
+ private readonly runtime: LokvisRuntime;
58
+ private readonly progress: BatchProgressEmitter;
59
+ private readonly concurrency: ConcurrencyController;
60
+
61
+ constructor(opts: {
62
+ runtime: LokvisRuntime;
63
+ progress: BatchProgressEmitter;
64
+ concurrency: ConcurrencyController;
65
+ }) {
66
+ this.runtime = opts.runtime;
67
+ this.progress = opts.progress;
68
+ this.concurrency = opts.concurrency;
69
+ }
70
+
71
+ /** 启动 job:发 batch:started,进入 running,触发首次 schedule */
72
+ async runJob(job: BatchJobInternal): Promise<void> {
73
+ if (job.cancelled) return;
74
+ job.status = 'running';
75
+ this.progress.emitStarted(job.id, job.items.length);
76
+ await this.schedule(job);
77
+ }
78
+
79
+ /**
80
+ * 调度循环:把 pending 项填入空闲槽位,直到无可用槽位或无 pending 项。
81
+ * 重入保护:scheduleLock 防并发 schedule 超限。
82
+ */
83
+ async schedule(job: BatchJobInternal): Promise<void> {
84
+ if (job.scheduleLock) return;
85
+ if (job.cancelled || job.paused) return;
86
+ if (this.isTerminal(job.status)) return;
87
+
88
+ job.scheduleLock = true;
89
+ try {
90
+ while (!job.cancelled && !job.paused) {
91
+ const running = job.items.filter((i) => i.status === 'processing').length;
92
+ const cap = this.concurrency.current(job.baseConcurrency);
93
+ if (running >= cap) break;
94
+
95
+ const next = job.items.find((i) => i.status === 'pending');
96
+ if (!next) break;
97
+
98
+ next.status = 'processing';
99
+ void this.processItem(job, next);
100
+ }
101
+ this.maybeComplete(job);
102
+ } finally {
103
+ job.scheduleLock = false;
104
+ }
105
+ }
106
+
107
+ /** 处理单个项:导入 → run workflow → 导出 output AssetId */
108
+ private async processItem(job: BatchJobInternal, item: BatchItemInternal): Promise<void> {
109
+ const start = Date.now();
110
+ const wfId = this.itemWorkflowId(job.id, item.id);
111
+ this.progress.emitItemStarted(job.id, item.id, item.index, job.items.length);
112
+
113
+ // B1:cancel 已同步标 item=cancelled,此处短路避免竞态
114
+ if (job.cancelled || item.status === 'cancelled') return;
115
+
116
+ let inputAssetId: AssetId | undefined;
117
+ let result: WorkflowResult | undefined;
118
+ try {
119
+ inputAssetId = await this.runtime.importAsset(item.source);
120
+
121
+ // B1:import 期间可能被 cancel
122
+ if (job.cancelled) {
123
+ item.status = 'cancelled';
124
+ // TD-3.3: asset-store remove 已对 NotFoundError 静默忽略,此处 catch 到的
125
+ // 都是真实错误,用 error 级别记录便于排查
126
+ void this.runtime.removeAsset(inputAssetId).catch((err) => {
127
+ console.error(`[lokvis] BatchProcessor: cleanup input(${inputAssetId}) on cancel failed:`, err);
128
+ });
129
+ return;
130
+ }
131
+
132
+ const wf: Workflow = { ...item.workflow, id: wfId };
133
+ result = await this.runtime.run(wf, [inputAssetId]);
134
+
135
+ // B1:run 期间可能被 cancel
136
+ if (job.cancelled) {
137
+ item.status = 'cancelled';
138
+ return;
139
+ }
140
+
141
+ if (result.status !== 'completed' || result.outputs.length === 0) {
142
+ throw new Error(result.error || `Workflow ${result.status}`);
143
+ }
144
+
145
+ const outputAssetId = result.outputs[0]!;
146
+ const duration = Date.now() - start;
147
+ item.status = 'completed';
148
+ item.outputAssetId = outputAssetId;
149
+ item.duration = duration;
150
+ job.completed++;
151
+ this.progress.emitItemFinished(job.id, item.id, item.index, job.items.length, outputAssetId, duration);
152
+ this.progress.emitProgress(job.id, job.completed, job.failed, job.items.length);
153
+ } catch (err) {
154
+ // B1:被 cancel 的 workflow 抛错不计入 failed
155
+ if (job.cancelled) {
156
+ item.status = 'cancelled';
157
+ return;
158
+ }
159
+ item.attempts++;
160
+ if (item.attempts <= item.maxRetries) {
161
+ // 重试:回 pending(不重置 attempts),清理本次 input 避免孤儿累积
162
+ item.status = 'pending';
163
+ if (inputAssetId !== undefined) {
164
+ void this.runtime.removeAsset(inputAssetId).catch((err) => {
165
+ console.error(`[lokvis] BatchProcessor: cleanup input(${inputAssetId}) on retry failed:`, err);
166
+ });
167
+ }
168
+ } else {
169
+ item.status = 'failed';
170
+ item.error = err instanceof Error ? err : new Error(String(err));
171
+ job.failed++;
172
+ // M4:最终失败时清理 input asset
173
+ if (inputAssetId !== undefined) {
174
+ void this.runtime.removeAsset(inputAssetId).catch((err) => {
175
+ console.error(`[lokvis] BatchProcessor: cleanup input(${inputAssetId}) on final fail failed:`, err);
176
+ });
177
+ }
178
+ this.progress.emitItemFailed(job.id, item.id, item.index, job.items.length, item.error, item.attempts);
179
+ this.progress.emitProgress(job.id, job.completed, job.failed, job.items.length);
180
+ }
181
+ } finally {
182
+ if (!job.cancelled && !job.paused && !this.isTerminal(job.status)) {
183
+ void this.schedule(job);
184
+ }
185
+ }
186
+ }
187
+
188
+ /** 检查 job 是否全部完成,如是则发 batch:completed */
189
+ maybeComplete(job: BatchJobInternal): void {
190
+ if (this.isTerminal(job.status)) return;
191
+ const pending = job.items.some((i) => i.status === 'pending' || i.status === 'processing');
192
+ if (pending) return;
193
+
194
+ job.endedAt = Date.now();
195
+ job.status = job.failed > 0 ? 'failed' : 'completed';
196
+ // 无论 completed 还是 failed 都发 batch:completed(waitForCompletion 依赖此行为)
197
+ this.progress.emitCompleted(job.id, job.items.length, job.completed, job.failed, job.endedAt - job.startedAt);
198
+ this.progress.cleanupJobSubs(job.id);
199
+ }
200
+
201
+ isTerminal(status: BatchJobStatus): boolean {
202
+ return status === 'completed' || status === 'cancelled' || status === 'failed';
203
+ }
204
+
205
+ /** 派生每项的 workflow id(独立于其他项,便于 cancel) */
206
+ itemWorkflowId(jobId: string, itemId: string): string {
207
+ return `${jobId}__${itemId}`;
208
+ }
209
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 浏览器能力检测(W22.3)
3
+ *
4
+ * 提供 Safari / Firefox 等浏览器的特性降级路径所需的能力探测:
5
+ * - 存储类:OPFS / IndexedDB / navigator.storage.estimate
6
+ * - 编解码类:WebCodecs(ImageDecoder/VideoDecoder/AudioDecoder/VideoEncoder) /
7
+ * OffscreenCanvas / createImageBitmap
8
+ * - 并发类:SharedArrayBuffer / crossOriginIsolated(WASM 线程所需)
9
+ * - 其他:WebWorker / ServiceWorker / WebAssembly
10
+ *
11
+ * 设计原则:
12
+ * 1. 优先 feature detection(typeof X !== 'undefined' / 'function'),
13
+ * 不依赖 UA 嗅探(UA 易伪装且不未来兼容)
14
+ * 2. UA 嗅探仅作辅助,用于已知 Safari/ Firefox 版本的特定 workaround
15
+ * 3. 所有检测在 SSR(Node)环境下安全返回 false / unknown
16
+ * 4. 探测结果可被 Runtime 初始化 + Playground UI 复用
17
+ *
18
+ * 现有降级链(已在其他模块实现,本模块仅提供能力探测供调用方决策):
19
+ * - AssetStore:OPFS → IndexedDB → Memory(createAssetStore 工厂,W2.8)
20
+ * - engine-image:纯 Canvas 2D API(无 WebCodecs 需要降级)
21
+ * - canvas-engine.decodeResized:Safari 16.4+ 支持 createImageBitmap resize
22
+ * 选项,不支持时回退到普通 decode + drawImage(已实现)
23
+ */
24
+
25
+ import { isOpfsSupported } from './opfs-asset-store.js';
26
+ import { isIdbSupported } from './idb-asset-store.js';
27
+
28
+ /** 浏览器能力检测结果 */
29
+ export interface BrowserCapabilities {
30
+ /** OPFS(Origin Private File System)——大文件持久化存储 */
31
+ opfs: boolean;
32
+ /** IndexedDB——元数据 + blob 持久化(OPFS 不可用时的降级) */
33
+ indexedDB: boolean;
34
+ /** navigator.storage.estimate——存储配额查询 */
35
+ storageEstimate: boolean;
36
+ /** WebCodecs ImageDecoder——图像解码(未来引擎,当前未实装) */
37
+ webCodecsImageDecoder: boolean;
38
+ /** WebCodecs VideoDecoder——视频解码(Phase 2 engine-video) */
39
+ webCodecsVideoDecoder: boolean;
40
+ /** WebCodecs VideoEncoder——视频编码(Phase 2 engine-video) */
41
+ webCodecsVideoEncoder: boolean;
42
+ /** WebCodecs AudioDecoder——音频解码(Phase 2 engine-audio) */
43
+ webCodecsAudioDecoder: boolean;
44
+ /** OffscreenCanvas——离屏 canvas 渲染(engine-image 编解码核心依赖) */
45
+ offscreenCanvas: boolean;
46
+ /** createImageBitmap——图像位图创建(engine-image 解码核心依赖) */
47
+ createImageBitmap: boolean;
48
+ /** SharedArrayBuffer——WASM 多线程(ffmpeg.wasm 等 Phase 2 引擎) */
49
+ sharedArrayBuffer: boolean;
50
+ /** crossOriginIsolated——WASM 线程所需的安全上下文 */
51
+ crossOriginIsolated: boolean;
52
+ /** WebAssembly——WASM 引擎基础依赖 */
53
+ webAssembly: boolean;
54
+ /** WebWorker——Worker 内执行引擎(runtime worker-host) */
55
+ webWorker: boolean;
56
+ /** ServiceWorker——PWA 离线缓存 */
57
+ serviceWorker: boolean;
58
+ }
59
+
60
+ /** 浏览器 UA 识别结果(辅助用,优先用 BrowserCapabilities) */
61
+ export interface BrowserInfo {
62
+ /** 浏览器名(基于 UA 嗅探,可能不准确) */
63
+ name: 'safari' | 'firefox' | 'chrome' | 'edge' | 'unknown';
64
+ /** 浏览器主版本号(基于 UA 嗅探,可能不准确) */
65
+ version: number;
66
+ /** 是否为 Safari(iOS Safari / macOS Safari) */
67
+ isSafari: boolean;
68
+ /** 是否为 Firefox */
69
+ isFirefox: boolean;
70
+ /** 是否为 Chrome(含 Chromium 内核,Edge 单独识别) */
71
+ isChrome: boolean;
72
+ /** 是否为 Microsoft Edge(Chromium 内核) */
73
+ isEdge: boolean;
74
+ /** 是否为移动端 */
75
+ isMobile: boolean;
76
+ }
77
+
78
+ /** 缓存能力探测结果(同一会话内不变,避免重复检测) */
79
+ let cachedCapabilities: BrowserCapabilities | undefined;
80
+ /** 缓存 UA 识别结果(同一会话内不变) */
81
+ let cachedBrowserInfo: BrowserInfo | undefined;
82
+
83
+ /**
84
+ * 探测当前环境(浏览器/Node)的能力。
85
+ *
86
+ * 在 SSR(Node)环境下,所有浏览器 API 检测返回 false。
87
+ *
88
+ * 结果在首次调用后缓存,同一会话内重复调用返回同一对象。
89
+ * 如需强制重新探测(测试场景),传 { force: true }。
90
+ */
91
+ export function detectBrowserCapabilities(
92
+ options: { force?: boolean } = {}
93
+ ): BrowserCapabilities {
94
+ if (cachedCapabilities && !options.force) return cachedCapabilities;
95
+ const g = globalThis as Record<string, unknown>;
96
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
97
+ cachedCapabilities = {
98
+ opfs: isOpfsSupported(),
99
+ indexedDB: isIdbSupported(),
100
+ storageEstimate:
101
+ !!nav && typeof nav.storage?.estimate === 'function',
102
+ webCodecsImageDecoder: typeof g.ImageDecoder !== 'undefined',
103
+ webCodecsVideoDecoder: typeof g.VideoDecoder !== 'undefined',
104
+ webCodecsVideoEncoder: typeof g.VideoEncoder !== 'undefined',
105
+ webCodecsAudioDecoder: typeof g.AudioDecoder !== 'undefined',
106
+ offscreenCanvas: typeof g.OffscreenCanvas !== 'undefined',
107
+ createImageBitmap: typeof g.createImageBitmap === 'function',
108
+ sharedArrayBuffer: typeof g.SharedArrayBuffer !== 'undefined',
109
+ crossOriginIsolated:
110
+ typeof g.crossOriginIsolated === 'boolean' ? g.crossOriginIsolated : false,
111
+ webAssembly: typeof g.WebAssembly !== 'undefined',
112
+ webWorker: typeof g.Worker !== 'undefined',
113
+ serviceWorker: !!nav && typeof nav.serviceWorker !== 'undefined',
114
+ };
115
+ return cachedCapabilities;
116
+ }
117
+
118
+ /** UA 嗅探正则(仅作辅助,优先用 detectBrowserCapabilities) */
119
+ const SAFARI_REGEX = /Version\/(\d+)\.\d+.*Safari\/[\d.]+/;
120
+ const FIREFOX_REGEX = /Firefox\/(\d+)\.\d+/;
121
+ const EDGE_REGEX = /Edg\/(\d+)\.\d+/;
122
+ const CHROME_REGEX = /Chrome\/(\d+)\.\d+/;
123
+ const MOBILE_REGEX = /Mobi|Android|iPhone|iPad|iPod/i;
124
+
125
+ /**
126
+ * 识别当前浏览器(基于 UA 嗅探)。
127
+ *
128
+ * 注意:UA 嗅探不可靠,浏览器可伪装 UA。优先用 detectBrowserCapabilities()
129
+ * 检测实际能力。本函数仅用于已知浏览器的特定 workaround(如 Safari 的
130
+ * createImageBitmap resize 选项兼容性)。
131
+ *
132
+ * 结果在首次调用后缓存,同一会话内重复调用返回同一对象。
133
+ * 如需强制重新识别(测试场景),传 { force: true }。
134
+ */
135
+ export function detectBrowserInfo(
136
+ options: { force?: boolean } = {}
137
+ ): BrowserInfo {
138
+ if (cachedBrowserInfo && !options.force) return cachedBrowserInfo;
139
+ if (typeof navigator === 'undefined') {
140
+ cachedBrowserInfo = {
141
+ name: 'unknown', version: 0,
142
+ isSafari: false, isFirefox: false, isChrome: false, isEdge: false,
143
+ isMobile: false,
144
+ };
145
+ return cachedBrowserInfo;
146
+ }
147
+ const ua = navigator.userAgent;
148
+ // Edge 必须先检测(含 Chrome 字样)
149
+ const edgeMatch = ua.match(EDGE_REGEX);
150
+ if (edgeMatch && edgeMatch[1]) {
151
+ cachedBrowserInfo = {
152
+ name: 'edge', version: parseInt(edgeMatch[1], 10),
153
+ isSafari: false, isFirefox: false, isChrome: false, isEdge: true,
154
+ isMobile: MOBILE_REGEX.test(ua),
155
+ };
156
+ return cachedBrowserInfo;
157
+ }
158
+ // Safari 必须在 Chrome 之前检测(排除 Chrome:Chrome UA 含 Chrome/ 但不含 Version/X Safari/X)
159
+ const safariMatch = ua.match(SAFARI_REGEX);
160
+ if (safariMatch && safariMatch[1] && !ua.includes('Chrome/')) {
161
+ cachedBrowserInfo = {
162
+ name: 'safari', version: parseInt(safariMatch[1], 10),
163
+ isSafari: true, isFirefox: false, isChrome: false, isEdge: false,
164
+ isMobile: MOBILE_REGEX.test(ua),
165
+ };
166
+ return cachedBrowserInfo;
167
+ }
168
+ const firefoxMatch = ua.match(FIREFOX_REGEX);
169
+ if (firefoxMatch && firefoxMatch[1]) {
170
+ cachedBrowserInfo = {
171
+ name: 'firefox', version: parseInt(firefoxMatch[1], 10),
172
+ isSafari: false, isFirefox: true, isChrome: false, isEdge: false,
173
+ isMobile: MOBILE_REGEX.test(ua),
174
+ };
175
+ return cachedBrowserInfo;
176
+ }
177
+ const chromeMatch = ua.match(CHROME_REGEX);
178
+ if (chromeMatch && chromeMatch[1]) {
179
+ cachedBrowserInfo = {
180
+ name: 'chrome', version: parseInt(chromeMatch[1], 10),
181
+ isSafari: false, isFirefox: false, isChrome: true, isEdge: false,
182
+ isMobile: MOBILE_REGEX.test(ua),
183
+ };
184
+ return cachedBrowserInfo;
185
+ }
186
+ cachedBrowserInfo = {
187
+ name: 'unknown', version: 0,
188
+ isSafari: false, isFirefox: false, isChrome: false, isEdge: false,
189
+ isMobile: MOBILE_REGEX.test(ua),
190
+ };
191
+ return cachedBrowserInfo;
192
+ }
193
+
194
+ /**
195
+ * 判断当前浏览器是否为 Safari(基于 UA 嗅探)。
196
+ *
197
+ * 用于已知 Safari 版本的特定 workaround,如:
198
+ * - Safari 16.4 以下不支持 createImageBitmap resize 选项
199
+ * - Safari 的 OffscreenCanvas 在 Worker 中行为不一致
200
+ *
201
+ * 优先用 detectBrowserCapabilities().offscreenCanvas 等能力检测。
202
+ */
203
+ export function isSafari(): boolean {
204
+ return detectBrowserInfo().isSafari;
205
+ }
206
+
207
+ /**
208
+ * 判断当前浏览器是否为 Firefox(基于 UA 嗅探)。
209
+ *
210
+ * 用于 Firefox 特定 workaround(如某些 CSS / API 差异)。
211
+ * 优先用 detectBrowserCapabilities() 检测实际能力。
212
+ */
213
+ export function isFirefox(): boolean {
214
+ return detectBrowserInfo().isFirefox;
215
+ }
216
+
217
+ /** 测试专用:重置缓存(确保下次 detectBrowserXxx 重新探测) */
218
+ export function _resetBrowserDetectCache(): void {
219
+ cachedCapabilities = undefined;
220
+ cachedBrowserInfo = undefined;
221
+ }