@czap/worker 0.1.0

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/compositor-script.d.ts +19 -0
  4. package/dist/compositor-script.d.ts.map +1 -0
  5. package/dist/compositor-script.js +374 -0
  6. package/dist/compositor-script.js.map +1 -0
  7. package/dist/compositor-startup.d.ts +200 -0
  8. package/dist/compositor-startup.d.ts.map +1 -0
  9. package/dist/compositor-startup.js +490 -0
  10. package/dist/compositor-startup.js.map +1 -0
  11. package/dist/compositor-types.d.ts +135 -0
  12. package/dist/compositor-types.d.ts.map +1 -0
  13. package/dist/compositor-types.js +7 -0
  14. package/dist/compositor-types.js.map +1 -0
  15. package/dist/compositor-worker.d.ts +65 -0
  16. package/dist/compositor-worker.d.ts.map +1 -0
  17. package/dist/compositor-worker.js +454 -0
  18. package/dist/compositor-worker.js.map +1 -0
  19. package/dist/evaluate-inline.d.ts +22 -0
  20. package/dist/evaluate-inline.d.ts.map +1 -0
  21. package/dist/evaluate-inline.js +42 -0
  22. package/dist/evaluate-inline.js.map +1 -0
  23. package/dist/host.d.ts +97 -0
  24. package/dist/host.d.ts.map +1 -0
  25. package/dist/host.js +115 -0
  26. package/dist/host.js.map +1 -0
  27. package/dist/index.d.ts +40 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +40 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/messages.d.ts +254 -0
  32. package/dist/messages.d.ts.map +1 -0
  33. package/dist/messages.js +55 -0
  34. package/dist/messages.js.map +1 -0
  35. package/dist/render-worker.d.ts +77 -0
  36. package/dist/render-worker.d.ts.map +1 -0
  37. package/dist/render-worker.js +396 -0
  38. package/dist/render-worker.js.map +1 -0
  39. package/dist/spsc-ring.d.ts +171 -0
  40. package/dist/spsc-ring.d.ts.map +1 -0
  41. package/dist/spsc-ring.js +240 -0
  42. package/dist/spsc-ring.js.map +1 -0
  43. package/package.json +51 -0
  44. package/src/compositor-script.ts +374 -0
  45. package/src/compositor-startup.ts +666 -0
  46. package/src/compositor-types.ts +175 -0
  47. package/src/compositor-worker.ts +613 -0
  48. package/src/evaluate-inline.ts +42 -0
  49. package/src/host.ts +189 -0
  50. package/src/index.ts +49 -0
  51. package/src/messages.ts +343 -0
  52. package/src/render-worker.ts +454 -0
  53. package/src/spsc-ring.ts +309 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * RenderWorker -- off-main-thread video renderer using OffscreenCanvas.
3
+ *
4
+ * The render worker:
5
+ * 1. Receives an OffscreenCanvas via postMessage transfer
6
+ * 2. On `start-render`, iterates frames at the configured fps
7
+ * 3. For each frame, computes state (simplified inline compositor)
8
+ * and draws to the OffscreenCanvas 2d context
9
+ * 4. Posts `frame` messages back with VideoFrameOutput data
10
+ * 5. Posts `render-complete` when done
11
+ *
12
+ * The worker script is inlined as a Blob URL (no separate file needed).
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { Diagnostics, type VideoConfig, type VideoFrameOutput } from '@czap/core';
18
+ import type { ToWorkerMessage, FromWorkerMessage } from './messages.js';
19
+ import { EVALUATE_THRESHOLDS_SOURCE } from './evaluate-inline.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Host-facing surface of a render worker. Owns the underlying `Worker`
27
+ * and `OffscreenCanvas` once transferred; created by
28
+ * {@link RenderWorker.create}.
29
+ */
30
+ export interface RenderWorkerShape {
31
+ /** The underlying Worker instance. */
32
+ readonly worker: Worker;
33
+
34
+ /**
35
+ * Transfer an OffscreenCanvas to the worker.
36
+ * The canvas must have been obtained via `canvas.transferControlToOffscreen()`.
37
+ */
38
+ transferCanvas(canvas: OffscreenCanvas): void;
39
+
40
+ /** Start rendering frames with the given video configuration. */
41
+ startRender(config: VideoConfig): void;
42
+
43
+ /** Stop an in-progress render. */
44
+ stopRender(): void;
45
+
46
+ /** Subscribe to per-frame output. Returns an unsubscribe function. */
47
+ onFrame(callback: (output: VideoFrameOutput) => void): () => void;
48
+
49
+ /** Subscribe to render completion. Returns an unsubscribe function. */
50
+ onComplete(callback: (totalFrames: number) => void): () => void;
51
+
52
+ /** Terminate the worker and clean up resources. */
53
+ dispose(): void;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Inline worker script
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Self-contained render worker script.
62
+ *
63
+ * Contains a minimal compositor and frame iterator that draws state
64
+ * visualization to an OffscreenCanvas.
65
+ */
66
+ const RENDER_WORKER_SCRIPT = /* js */ `
67
+ "use strict";
68
+
69
+ /** @type {OffscreenCanvas | null} */
70
+ let canvas = null;
71
+
72
+ /** @type {OffscreenCanvasRenderingContext2D | null} */
73
+ let ctx = null;
74
+
75
+ /** @type {boolean} */
76
+ let rendering = false;
77
+
78
+ /** @type {boolean} */
79
+ let stopRequested = false;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Simplified inline compositor (mirrors compositor-worker / Boundary.evaluate)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** @type {Map<string, { id: string; states: string[]; thresholds: number[]; currentState: string }>} */
86
+ const quantizers = new Map();
87
+
88
+ /** @type {Map<string, Record<string, number>>} */
89
+ const blendOverrides = new Map();
90
+
91
+ ${EVALUATE_THRESHOLDS_SOURCE}
92
+
93
+ /**
94
+ * Compute a CompositeState from the current quantizer state.
95
+ */
96
+ function computeState() {
97
+ const discrete = {};
98
+ const blend = {};
99
+ const css = {};
100
+ const glsl = {};
101
+ const aria = {};
102
+
103
+ for (const [name, q] of quantizers) {
104
+ const stateStr = q.currentState;
105
+ discrete[name] = stateStr;
106
+
107
+ const override = blendOverrides.get(name);
108
+ if (override !== undefined) {
109
+ blend[name] = override;
110
+ } else {
111
+ const weights = {};
112
+ for (const s of q.states) {
113
+ weights[s] = s === stateStr ? 1 : 0;
114
+ }
115
+ blend[name] = weights;
116
+ }
117
+
118
+ css["--czap-" + name] = stateStr;
119
+
120
+ let stateIndex = 0;
121
+ for (let i = 0; i < q.states.length; i++) {
122
+ if (q.states[i] === stateStr) {
123
+ stateIndex = i;
124
+ break;
125
+ }
126
+ }
127
+ glsl["u_" + name] = stateIndex;
128
+ aria["data-czap-" + name] = stateStr;
129
+ }
130
+
131
+ return { discrete, blend, outputs: { css, glsl, aria } };
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Canvas rendering
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Draw the current CompositeState to the OffscreenCanvas.
140
+ * This is a diagnostic visualization; real applications would
141
+ * implement domain-specific rendering.
142
+ *
143
+ * @param {{ discrete: Record<string, string>; blend: Record<string, Record<string, number>>; outputs: { css: Record<string, number|string>; glsl: Record<string, number>; aria: Record<string, string> } }} state
144
+ * @param {number} frame
145
+ * @param {number} progress
146
+ */
147
+ function drawState(state, frame, progress) {
148
+ if (!ctx || !canvas) return;
149
+
150
+ const w = canvas.width;
151
+ const h = canvas.height;
152
+
153
+ // Clear
154
+ ctx.clearRect(0, 0, w, h);
155
+
156
+ // Background: gradient based on progress
157
+ const gray = Math.round(32 + progress * 32);
158
+ ctx.fillStyle = "rgb(" + gray + "," + gray + "," + gray + ")";
159
+ ctx.fillRect(0, 0, w, h);
160
+
161
+ // Draw discrete state labels
162
+ ctx.fillStyle = "#ffffff";
163
+ ctx.font = "14px monospace";
164
+ ctx.textBaseline = "top";
165
+
166
+ let y = 16;
167
+ const keys = Object.keys(state.discrete);
168
+ for (let i = 0; i < keys.length; i++) {
169
+ const name = keys[i];
170
+ const value = state.discrete[name];
171
+ ctx.fillText(name + ": " + value, 16, y);
172
+ y += 20;
173
+ }
174
+
175
+ // Draw progress bar
176
+ const barY = h - 24;
177
+ const barH = 8;
178
+ ctx.fillStyle = "#333333";
179
+ ctx.fillRect(16, barY, w - 32, barH);
180
+ ctx.fillStyle = "#4488ff";
181
+ ctx.fillRect(16, barY, (w - 32) * progress, barH);
182
+
183
+ // Frame counter
184
+ ctx.fillStyle = "#aaaaaa";
185
+ ctx.font = "12px monospace";
186
+ ctx.textBaseline = "bottom";
187
+ ctx.fillText("frame " + frame, 16, barY - 4);
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Render loop
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Run the fixed-step render loop.
196
+ * @param {{ fps: number; width: number; height: number; durationMs: number }} config
197
+ */
198
+ async function runRender(config) {
199
+ if (rendering) return;
200
+ rendering = true;
201
+ stopRequested = false;
202
+
203
+ const totalFrames = Math.ceil((config.durationMs / 1000) * config.fps);
204
+
205
+ try {
206
+ for (let i = 0; i < totalFrames; i++) {
207
+ if (stopRequested) break;
208
+
209
+ const timestamp = (i * 1000) / config.fps;
210
+ const progress = totalFrames > 1 ? i / (totalFrames - 1) : 1;
211
+ const state = computeState();
212
+
213
+ // Draw to canvas
214
+ drawState(state, i, progress);
215
+
216
+ /** @type {import('./messages.js').VideoFrameOutput} */
217
+ const output = { frame: i, timestamp, progress, state };
218
+
219
+ self.postMessage({ type: "frame", output: output });
220
+
221
+ // Yield to allow stop messages to be processed.
222
+ // In a real scenario, the frame rate would be controlled by
223
+ // the encoding pipeline; here we use a minimal yield.
224
+ if (i % 10 === 9) {
225
+ await new Promise(function (r) { setTimeout(r, 0); });
226
+ }
227
+ }
228
+
229
+ self.postMessage({ type: "render-complete", totalFrames: totalFrames });
230
+ } catch (err) {
231
+ self.postMessage({
232
+ type: "error",
233
+ message: err instanceof Error ? err.message : String(err),
234
+ });
235
+ } finally {
236
+ rendering = false;
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Message handler
242
+ // ---------------------------------------------------------------------------
243
+
244
+ self.addEventListener("message", function (e) {
245
+ const msg = e.data;
246
+ if (!msg || typeof msg.type !== "string") return;
247
+
248
+ switch (msg.type) {
249
+ case "init": {
250
+ quantizers.clear();
251
+ blendOverrides.clear();
252
+ self.postMessage({ type: "ready" });
253
+ break;
254
+ }
255
+
256
+ case "transfer-canvas": {
257
+ canvas = msg.canvas;
258
+ ctx = canvas.getContext("2d");
259
+ break;
260
+ }
261
+
262
+ case "add-quantizer": {
263
+ const initialState = msg.states[0] || "";
264
+ quantizers.set(msg.name, {
265
+ id: msg.boundaryId,
266
+ states: Array.from(msg.states),
267
+ thresholds: Array.from(msg.thresholds),
268
+ currentState: initialState,
269
+ });
270
+ break;
271
+ }
272
+
273
+ case "remove-quantizer": {
274
+ quantizers.delete(msg.name);
275
+ blendOverrides.delete(msg.name);
276
+ break;
277
+ }
278
+
279
+ case "evaluate": {
280
+ const q = quantizers.get(msg.name);
281
+ if (q) {
282
+ q.currentState = evaluateThresholds(q.thresholds, q.states, msg.value);
283
+ }
284
+ break;
285
+ }
286
+
287
+ case "set-blend": {
288
+ blendOverrides.set(msg.name, msg.weights);
289
+ break;
290
+ }
291
+
292
+ case "start-render": {
293
+ runRender(msg.config);
294
+ break;
295
+ }
296
+
297
+ case "stop-render": {
298
+ stopRequested = true;
299
+ break;
300
+ }
301
+
302
+ case "dispose": {
303
+ stopRequested = true;
304
+ quantizers.clear();
305
+ blendOverrides.clear();
306
+ canvas = null;
307
+ ctx = null;
308
+ self.close();
309
+ break;
310
+ }
311
+ }
312
+ });
313
+ `;
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Factory helpers
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function _send(worker: Worker, msg: ToWorkerMessage, transfer?: Transferable[]): void {
320
+ if (transfer && transfer.length > 0) {
321
+ worker.postMessage(msg, transfer);
322
+ } else {
323
+ worker.postMessage(msg);
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Factory
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function _createRenderWorker(): RenderWorkerShape {
332
+ const blob = new Blob([RENDER_WORKER_SCRIPT], { type: 'application/javascript' });
333
+ const url = URL.createObjectURL(blob);
334
+ const worker = new Worker(url, { type: 'classic', name: 'czap-renderer' });
335
+
336
+ URL.revokeObjectURL(url);
337
+
338
+ const frameListeners = new Set<(output: VideoFrameOutput) => void>();
339
+ const completeListeners = new Set<(totalFrames: number) => void>();
340
+
341
+ worker.addEventListener('message', (e: MessageEvent<FromWorkerMessage>) => {
342
+ const msg = e.data;
343
+ if (!msg || typeof msg.type !== 'string') return;
344
+
345
+ switch (msg.type) {
346
+ case 'frame':
347
+ for (const cb of frameListeners) cb(msg.output);
348
+ break;
349
+ case 'render-complete':
350
+ for (const cb of completeListeners) cb(msg.totalFrames);
351
+ break;
352
+ case 'error':
353
+ Diagnostics.error({
354
+ source: 'czap/worker.render-worker',
355
+ code: 'worker-message-error',
356
+ message: 'Render worker reported an error.',
357
+ detail: msg.message,
358
+ });
359
+ break;
360
+ }
361
+ });
362
+
363
+ worker.addEventListener('error', (e: ErrorEvent) => {
364
+ Diagnostics.error({
365
+ source: 'czap/worker.render-worker',
366
+ code: 'worker-unhandled-error',
367
+ message: 'Render worker raised an unhandled error.',
368
+ detail: e.message,
369
+ });
370
+ });
371
+
372
+ // Initialize
373
+ _send(worker, { type: 'init' });
374
+
375
+ return {
376
+ get worker(): Worker {
377
+ return worker;
378
+ },
379
+
380
+ transferCanvas(canvas) {
381
+ // The canvas is Transferable -- it must be in the transfer list
382
+ _send(worker, { type: 'transfer-canvas', canvas }, [canvas]);
383
+ },
384
+
385
+ startRender(config) {
386
+ _send(worker, { type: 'start-render', config });
387
+ },
388
+
389
+ stopRender() {
390
+ _send(worker, { type: 'stop-render' });
391
+ },
392
+
393
+ onFrame(callback) {
394
+ frameListeners.add(callback);
395
+ return () => {
396
+ frameListeners.delete(callback);
397
+ };
398
+ },
399
+
400
+ onComplete(callback) {
401
+ completeListeners.add(callback);
402
+ return () => {
403
+ completeListeners.delete(callback);
404
+ };
405
+ },
406
+
407
+ dispose() {
408
+ _send(worker, { type: 'dispose' });
409
+ frameListeners.clear();
410
+ completeListeners.clear();
411
+ worker.terminate();
412
+ },
413
+ };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Export
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /**
421
+ * Factory namespace for the render worker.
422
+ *
423
+ * Call {@link RenderWorker.create} on the main thread to mint a worker
424
+ * that owns an `OffscreenCanvas` and renders `VideoFrameOutput` frames
425
+ * off the main thread. Transfer control via
426
+ * {@link RenderWorkerShape.transferCanvas} before calling `startRender`.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * import { RenderWorker } from '@czap/worker';
431
+ *
432
+ * const renderer = RenderWorker.create();
433
+ * const offscreen = canvas.transferControlToOffscreen();
434
+ * renderer.transferCanvas(offscreen);
435
+ * renderer.onFrame((frame) => {
436
+ * // stream frame.image / frame.timestampMs somewhere
437
+ * });
438
+ * renderer.startRender({ durationMs: 4000, fps: 30, width: 640, height: 360 });
439
+ * ```
440
+ */
441
+ export const RenderWorker = {
442
+ /**
443
+ * Spin up a render worker. The worker starts idle; transfer an
444
+ * `OffscreenCanvas` via
445
+ * {@link RenderWorkerShape.transferCanvas} before calling
446
+ * `startRender`.
447
+ */
448
+ create: _createRenderWorker,
449
+ } as const;
450
+
451
+ export declare namespace RenderWorker {
452
+ /** Public host-side surface returned by {@link RenderWorker.create}. */
453
+ export type Shape = RenderWorkerShape;
454
+ }