@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,613 @@
1
+ /**
2
+ * CompositorWorker -- off-main-thread compositor running in a Web Worker.
3
+ *
4
+ * The worker maintains a simplified compositor that:
5
+ * - Tracks quantizer definitions (name maps to boundary plus current state)
6
+ * - Evaluates threshold-based quantization
7
+ * - Maintains blend weight overrides
8
+ * - Produces CompositeState on `compute` commands
9
+ * - Uses DirtyFlags for selective recomputation
10
+ *
11
+ * The worker script is inlined as a Blob URL to avoid bundler complexity
12
+ * with separate worker entry files.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { Diagnostics } from '@czap/core';
18
+ import type { RuntimeCoordinator } from '@czap/core';
19
+ import type {
20
+ FromWorkerMessage,
21
+ WorkerConfig,
22
+ WorkerUpdate,
23
+ BootstrapQuantizerRegistration,
24
+ ResolvedStateEntry,
25
+ } from './messages.js';
26
+ import { makeResolvedStateEnvelope } from './messages.js';
27
+
28
+ // Re-export types from compositor-types
29
+ export type { CompositorWorkerStartupStage, CompositorWorkerStartupTelemetry } from './compositor-types.js';
30
+
31
+ import type {
32
+ CompositorWorkerState,
33
+ CompositorWorkerStartupStage,
34
+ ResolvedStateAckPayload,
35
+ CompositorWorkerShape,
36
+ CompositorWorkerStartupTelemetry,
37
+ } from './compositor-types.js';
38
+
39
+ // Import startup/lifecycle helpers
40
+ import {
41
+ currentTimeNs,
42
+ recordStartupDiagnosticStage,
43
+ notifyResolvedStateSettled,
44
+ createStartupPacketState,
45
+ buildStartupComputePacket,
46
+ getStartupPacketRuntimeSeed,
47
+ runtimeMatchesStartupSeed,
48
+ setStartupPacketRegistration,
49
+ pushStartupPacketUpdate,
50
+ setStartupPacketInitialState,
51
+ setStartupPacketBlendWeights,
52
+ removeStartupPacketEntries,
53
+ resetStartupPacketTransientState,
54
+ claimCompositorLease,
55
+ parkOrDisposeCompositorLease,
56
+ _send,
57
+ prepareRegistrationForTransfer,
58
+ sameBootstrapRegistration,
59
+ evaluateRegistrationState,
60
+ toResolvedStateEntriesFromAck,
61
+ } from './compositor-startup.js';
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Factory
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function _createCompositorWorker(
68
+ config?: WorkerConfig,
69
+ startupTelemetry?: CompositorWorkerStartupTelemetry,
70
+ ): CompositorWorkerShape {
71
+ const capacity = config?.poolCapacity ?? 64;
72
+ const { worker, runtime, bootstrapSnapshot } = claimCompositorLease(capacity, startupTelemetry);
73
+ const snapshotByName = new Map(bootstrapSnapshot.map((registration) => [registration.name, registration] as const));
74
+ const activeRegistrations = new Map<string, BootstrapQuantizerRegistration>();
75
+ const stateListeners = new Set<(state: CompositorWorkerState) => void>();
76
+ const resolvedStateAckListeners = new Set<(ack: ResolvedStateAckPayload) => void>();
77
+ const metricsListeners = new Set<(fps: number, budgetUsed: number) => void>();
78
+ const confirmedSnapshotNames = new Set<string>();
79
+ const preparedRegistrationCache = new Map<
80
+ string,
81
+ {
82
+ readonly source: BootstrapQuantizerRegistration;
83
+ readonly transferRegistration: BootstrapQuantizerRegistration;
84
+ readonly buffer: ArrayBuffer;
85
+ }
86
+ >();
87
+ const startupPacket = createStartupPacketState(
88
+ bootstrapSnapshot.length > 0 ? 'warm-snapshot' : 'cold',
89
+ bootstrapSnapshot,
90
+ );
91
+ let steadyStatePendingUpdates: WorkerUpdate[] = [];
92
+ let flushScheduled = false;
93
+ let startupMode = true;
94
+ let startupDispatchCompletedNs: number | null = null;
95
+ let startupStatePending = false;
96
+ let resolvedStateDispatchCompletedNs: number | null = null;
97
+ let resolvedStateAckPending = false;
98
+ let lastMetrics: { readonly fps: number; readonly budgetUsed: number } | null = null;
99
+ let lastWorkerError: string | null = null;
100
+
101
+ const getPreparedRegistration = (registration: BootstrapQuantizerRegistration) => {
102
+ const cached = preparedRegistrationCache.get(registration.name);
103
+ /* v8 ignore next — current call sites always delete the cache entry in the same
104
+ synchronous turn that sets it (see `consumePreparedRegistrations`), so this
105
+ cache-hit arm is reserved for a future pre-flight path that warms the cache
106
+ before dispatch; unreachable under today's code paths. */
107
+ if (cached && cached.source === registration && cached.buffer.byteLength > 0) {
108
+ return cached;
109
+ }
110
+
111
+ const { registration: transferRegistration, buffer } = prepareRegistrationForTransfer(registration);
112
+ const prepared = {
113
+ source: registration,
114
+ transferRegistration,
115
+ buffer,
116
+ };
117
+ preparedRegistrationCache.set(registration.name, prepared);
118
+ return prepared;
119
+ };
120
+
121
+ const consumePreparedRegistrations = (registrations: readonly BootstrapQuantizerRegistration[]) => {
122
+ const buffers: ArrayBuffer[] = [];
123
+ const transferRegistrations = registrations.map((registration) => {
124
+ const prepared = getPreparedRegistration(registration);
125
+ preparedRegistrationCache.delete(registration.name);
126
+ buffers.push(prepared.buffer);
127
+ return prepared.transferRegistration;
128
+ });
129
+
130
+ return {
131
+ registrations: transferRegistrations,
132
+ buffers,
133
+ };
134
+ };
135
+
136
+ const flushPendingUpdates = (): void => {
137
+ flushScheduled = false;
138
+ if (steadyStatePendingUpdates.length === 0) {
139
+ return;
140
+ }
141
+
142
+ const updates = steadyStatePendingUpdates;
143
+ steadyStatePendingUpdates = [];
144
+ _send(worker, {
145
+ type: 'apply-updates',
146
+ updates,
147
+ });
148
+ };
149
+
150
+ const queueUpdate = (update: WorkerUpdate): void => {
151
+ if (startupMode) {
152
+ pushStartupPacketUpdate(startupPacket, update);
153
+ return;
154
+ }
155
+
156
+ steadyStatePendingUpdates.push(update);
157
+ if (flushScheduled) {
158
+ return;
159
+ }
160
+
161
+ flushScheduled = true;
162
+ queueMicrotask(flushPendingUpdates);
163
+ };
164
+
165
+ const markStartupBootstrapForRebuild = (): void => {
166
+ if (startupPacket.bootstrapMode === 'warm-snapshot') {
167
+ startupPacket.bootstrapMode = 'rebuild';
168
+ }
169
+ };
170
+
171
+ const applyResolvedStatesToRuntime = (states: readonly ResolvedStateEntry[]): void => {
172
+ for (const entry of states) {
173
+ runtime.markDirty(entry.name);
174
+ runtime.applyState(entry.name, entry.state);
175
+ }
176
+ };
177
+
178
+ const ensureResolvedStateMode = (): void => {
179
+ if (!startupMode) {
180
+ flushPendingUpdates();
181
+ return;
182
+ }
183
+
184
+ startupMode = false;
185
+ startupStatePending = false;
186
+ startupDispatchCompletedNs = null;
187
+ flushScheduled = false;
188
+ steadyStatePendingUpdates = [];
189
+ const registrations = Array.from(activeRegistrations.values());
190
+
191
+ if (startupPacket.bootstrapMode !== 'cold') {
192
+ _send(worker, { type: 'init' });
193
+ }
194
+ if (registrations.length > 0) {
195
+ const { registrations: transferRegs, buffers } = consumePreparedRegistrations(registrations);
196
+ _send(
197
+ worker,
198
+ {
199
+ type: 'bootstrap-quantizers',
200
+ registrations: transferRegs,
201
+ },
202
+ buffers,
203
+ );
204
+ }
205
+
206
+ resetStartupPacketTransientState(startupPacket);
207
+ };
208
+
209
+ const sendResolvedStateMessage = (
210
+ type: 'bootstrap-resolved-state' | 'apply-resolved-state',
211
+ states: readonly ResolvedStateEntry[],
212
+ ): void => {
213
+ if (states.length === 0) {
214
+ return;
215
+ }
216
+
217
+ ensureResolvedStateMode();
218
+ applyResolvedStatesToRuntime(states);
219
+ const expectAck = resolvedStateAckListeners.size > 0 || startupTelemetry !== undefined;
220
+ const dispatchStartNs = currentTimeNs();
221
+ _send(worker, makeResolvedStateEnvelope(type, states, expectAck));
222
+ resolvedStateDispatchCompletedNs = currentTimeNs();
223
+ resolvedStateAckPending = expectAck;
224
+ recordStartupDiagnosticStage(
225
+ startupTelemetry,
226
+ 'request-compute:dispatch-send',
227
+ resolvedStateDispatchCompletedNs - dispatchStartNs,
228
+ );
229
+ recordStartupDiagnosticStage(startupTelemetry, 'request-compute:packet-finalize', 0);
230
+ recordStartupDiagnosticStage(startupTelemetry, 'request-compute:post-send-bookkeeping', 0);
231
+ };
232
+
233
+ const handleMessage = (e: MessageEvent<FromWorkerMessage>): void => {
234
+ const msg = e.data;
235
+ if (!msg || typeof msg.type !== 'string') return;
236
+
237
+ switch (msg.type) {
238
+ case 'ready':
239
+ break;
240
+ case 'state':
241
+ if (startupStatePending) {
242
+ const eventStartNs = currentTimeNs();
243
+ recordStartupDiagnosticStage(
244
+ startupTelemetry,
245
+ 'state-delivery:message-receipt',
246
+ eventStartNs - startupDispatchCompletedNs!,
247
+ );
248
+ for (const [name, state] of Object.entries(msg.state.discrete ?? {})) {
249
+ runtime.applyState(name, state);
250
+ }
251
+ const callbackStartNs = currentTimeNs();
252
+ recordStartupDiagnosticStage(
253
+ startupTelemetry,
254
+ 'state-delivery:callback-queue-turn',
255
+ callbackStartNs - eventStartNs,
256
+ );
257
+ for (const cb of stateListeners) cb({ ...msg.state, resolvedStateGenerations: msg.resolvedStateGenerations });
258
+ recordStartupDiagnosticStage(
259
+ startupTelemetry,
260
+ 'state-delivery:host-callback-delivery',
261
+ currentTimeNs() - callbackStartNs,
262
+ );
263
+ startupStatePending = false;
264
+ startupDispatchCompletedNs = null;
265
+ break;
266
+ }
267
+ for (const [name, state] of Object.entries(msg.state.discrete ?? {})) {
268
+ runtime.applyState(name, state);
269
+ }
270
+ for (const cb of stateListeners) cb({ ...msg.state, resolvedStateGenerations: msg.resolvedStateGenerations });
271
+ break;
272
+ case 'resolved-state-ack':
273
+ if (!resolvedStateAckPending || resolvedStateDispatchCompletedNs === null) {
274
+ notifyResolvedStateSettled(startupTelemetry, toResolvedStateEntriesFromAck(msg));
275
+ break;
276
+ }
277
+ const eventStartNs = currentTimeNs();
278
+ recordStartupDiagnosticStage(
279
+ startupTelemetry,
280
+ 'state-delivery:message-receipt',
281
+ eventStartNs - resolvedStateDispatchCompletedNs,
282
+ );
283
+ const callbackStartNs = currentTimeNs();
284
+ recordStartupDiagnosticStage(
285
+ startupTelemetry,
286
+ 'state-delivery:callback-queue-turn',
287
+ callbackStartNs - eventStartNs,
288
+ );
289
+ notifyResolvedStateSettled(startupTelemetry, toResolvedStateEntriesFromAck(msg));
290
+ if (resolvedStateAckListeners.size > 0) {
291
+ for (const cb of resolvedStateAckListeners) cb(msg);
292
+ recordStartupDiagnosticStage(
293
+ startupTelemetry,
294
+ 'state-delivery:host-callback-delivery',
295
+ currentTimeNs() - callbackStartNs,
296
+ );
297
+ } else {
298
+ recordStartupDiagnosticStage(startupTelemetry, 'state-delivery:host-callback-delivery', 0);
299
+ }
300
+ resolvedStateAckPending = false;
301
+ resolvedStateDispatchCompletedNs = null;
302
+ break;
303
+ case 'metrics':
304
+ lastMetrics = { fps: msg.fps, budgetUsed: msg.budgetUsed };
305
+ for (const cb of metricsListeners) cb(msg.fps, msg.budgetUsed);
306
+ break;
307
+ case 'error':
308
+ lastWorkerError = msg.message;
309
+ Diagnostics.error({
310
+ source: 'czap/worker.compositor-worker',
311
+ code: 'worker-message-error',
312
+ message: 'Compositor worker reported an error.',
313
+ detail: msg.message,
314
+ });
315
+ break;
316
+ }
317
+ };
318
+
319
+ const handleError = (e: ErrorEvent): void => {
320
+ Diagnostics.error({
321
+ source: 'czap/worker.compositor-worker',
322
+ code: 'worker-unhandled-error',
323
+ message: 'Compositor worker raised an unhandled error.',
324
+ detail: e.message,
325
+ });
326
+ };
327
+
328
+ const listenerBindStartNs = currentTimeNs();
329
+ worker.addEventListener('message', handleMessage);
330
+ worker.addEventListener('error', handleError);
331
+ startupTelemetry?.recordStage('listener-bind', currentTimeNs() - listenerBindStartNs);
332
+
333
+ if (startupPacket.bootstrapMode === 'cold') {
334
+ _send(worker, { type: 'init' });
335
+ }
336
+
337
+ void lastMetrics;
338
+ void lastWorkerError;
339
+
340
+ return {
341
+ get worker(): Worker {
342
+ return worker;
343
+ },
344
+
345
+ get runtime(): RuntimeCoordinator.Shape {
346
+ return runtime;
347
+ },
348
+
349
+ addQuantizer(name, boundary) {
350
+ const registration = {
351
+ name,
352
+ boundaryId: boundary.id,
353
+ states: boundary.states,
354
+ thresholds: boundary.thresholds,
355
+ } satisfies BootstrapQuantizerRegistration;
356
+ const previousRequested = activeRegistrations.get(name);
357
+ if (sameBootstrapRegistration(previousRequested, registration)) {
358
+ if (startupPacket.bootstrapMode === 'warm-snapshot' && snapshotByName.has(name)) {
359
+ confirmedSnapshotNames.add(name);
360
+ }
361
+ return;
362
+ }
363
+
364
+ preparedRegistrationCache.delete(name);
365
+ activeRegistrations.set(name, registration);
366
+ const snapshotRegistration = snapshotByName.get(name);
367
+ const isSnapshotMatch = sameBootstrapRegistration(snapshotRegistration, registration);
368
+
369
+ if (runtime.hasQuantizer(name) && !isSnapshotMatch) {
370
+ runtime.removeQuantizer(name);
371
+ }
372
+ if (!runtime.hasQuantizer(name)) {
373
+ runtime.registerQuantizer(name, boundary.states);
374
+ }
375
+
376
+ if (startupPacket.bootstrapMode === 'warm-snapshot' && isSnapshotMatch) {
377
+ confirmedSnapshotNames.add(name);
378
+ return;
379
+ }
380
+
381
+ confirmedSnapshotNames.delete(name);
382
+ if (snapshotRegistration || startupPacket.bootstrapMode === 'warm-snapshot') {
383
+ markStartupBootstrapForRebuild();
384
+ }
385
+
386
+ if (startupMode) {
387
+ setStartupPacketRegistration(startupPacket, registration);
388
+ return;
389
+ }
390
+
391
+ const { registrations: transferRegistrations, buffers } = consumePreparedRegistrations([registration]);
392
+ _send(worker, { type: 'add-quantizer', ...transferRegistrations[0]! }, buffers);
393
+ },
394
+
395
+ removeQuantizer(name) {
396
+ preparedRegistrationCache.delete(name);
397
+ activeRegistrations.delete(name);
398
+ confirmedSnapshotNames.delete(name);
399
+ runtime.removeQuantizer(name);
400
+ if (snapshotByName.has(name)) {
401
+ markStartupBootstrapForRebuild();
402
+ }
403
+
404
+ if (startupMode) {
405
+ removeStartupPacketEntries(startupPacket, name);
406
+ return;
407
+ }
408
+ queueUpdate({ type: 'remove-quantizer', name });
409
+ },
410
+
411
+ evaluate(name, value) {
412
+ if (startupMode && snapshotByName.has(name) && !confirmedSnapshotNames.has(name)) {
413
+ markStartupBootstrapForRebuild();
414
+ }
415
+ if (startupMode) {
416
+ const activeRegistration = activeRegistrations.get(name);
417
+ if (activeRegistration) {
418
+ const nextState = evaluateRegistrationState(activeRegistration, value);
419
+ if (nextState !== activeRegistration.states[0]) {
420
+ confirmedSnapshotNames.delete(name);
421
+ }
422
+ setStartupPacketInitialState(startupPacket, activeRegistration, nextState);
423
+ runtime.markDirty(name);
424
+ return;
425
+ }
426
+ }
427
+ runtime.markDirty(name);
428
+ queueUpdate({ type: 'evaluate', name, value });
429
+ },
430
+
431
+ setBlendWeights(name, weights) {
432
+ if (startupMode && snapshotByName.has(name) && !confirmedSnapshotNames.has(name)) {
433
+ markStartupBootstrapForRebuild();
434
+ }
435
+ if (startupMode && setStartupPacketBlendWeights(startupPacket, name, weights)) {
436
+ confirmedSnapshotNames.delete(name);
437
+ runtime.markDirty(name);
438
+ return;
439
+ }
440
+ runtime.markDirty(name);
441
+ queueUpdate({ type: 'set-blend', name, weights });
442
+ },
443
+
444
+ bootstrapResolvedState(states) {
445
+ sendResolvedStateMessage('bootstrap-resolved-state', states);
446
+ },
447
+
448
+ applyResolvedState(states) {
449
+ sendResolvedStateMessage('apply-resolved-state', states);
450
+ },
451
+
452
+ requestCompute() {
453
+ const wasStartupMode = startupMode;
454
+ startupMode = false;
455
+
456
+ if (wasStartupMode) {
457
+ startupStatePending = true;
458
+ const runtimeSeed = getStartupPacketRuntimeSeed(startupPacket);
459
+ if (
460
+ startupPacket.bootstrapMode === 'warm-snapshot' &&
461
+ activeRegistrations.size === snapshotByName.size &&
462
+ confirmedSnapshotNames.size === snapshotByName.size &&
463
+ runtimeMatchesStartupSeed(runtime, runtimeSeed)
464
+ ) {
465
+ const dispatchStartNs = currentTimeNs();
466
+ _send(worker, { type: 'warm-reset' });
467
+ _send(worker, { type: 'compute' });
468
+ startupDispatchCompletedNs = currentTimeNs();
469
+ recordStartupDiagnosticStage(
470
+ startupTelemetry,
471
+ 'request-compute:dispatch-send',
472
+ startupDispatchCompletedNs - dispatchStartNs,
473
+ );
474
+ recordStartupDiagnosticStage(startupTelemetry, 'request-compute:packet-finalize', 0);
475
+ recordStartupDiagnosticStage(startupTelemetry, 'request-compute:post-send-bookkeeping', 0);
476
+ return;
477
+ } else {
478
+ const packetFinalizeStartNs = currentTimeNs();
479
+ const packet = buildStartupComputePacket(startupPacket);
480
+ if (startupPacket.bootstrapMode === 'rebuild') {
481
+ if (!runtimeMatchesStartupSeed(runtime, runtimeSeed)) {
482
+ runtime.reset(runtimeSeed);
483
+ }
484
+ }
485
+ const packetFinalizeEndNs = currentTimeNs();
486
+ recordStartupDiagnosticStage(
487
+ startupTelemetry,
488
+ 'request-compute:packet-finalize',
489
+ packetFinalizeEndNs - packetFinalizeStartNs,
490
+ );
491
+
492
+ flushScheduled = false;
493
+ const { registrations: transferRegs, buffers } = consumePreparedRegistrations(packet.registrations);
494
+ const transferPacket = { ...packet, registrations: transferRegs };
495
+ const dispatchStartNs = currentTimeNs();
496
+ _send(
497
+ worker,
498
+ {
499
+ type: 'startup-compute',
500
+ packet: transferPacket,
501
+ },
502
+ buffers,
503
+ );
504
+ startupDispatchCompletedNs = currentTimeNs();
505
+ recordStartupDiagnosticStage(
506
+ startupTelemetry,
507
+ 'request-compute:dispatch-send',
508
+ startupDispatchCompletedNs - dispatchStartNs,
509
+ );
510
+ recordStartupDiagnosticStage(startupTelemetry, 'request-compute:post-send-bookkeeping', 0);
511
+ return;
512
+ }
513
+ }
514
+
515
+ flushPendingUpdates();
516
+ _send(worker, { type: 'compute' });
517
+ },
518
+
519
+ onState(callback) {
520
+ stateListeners.add(callback);
521
+ return () => {
522
+ stateListeners.delete(callback);
523
+ };
524
+ },
525
+
526
+ onResolvedStateAck(callback) {
527
+ resolvedStateAckListeners.add(callback);
528
+ return () => {
529
+ resolvedStateAckListeners.delete(callback);
530
+ };
531
+ },
532
+
533
+ onMetrics(callback) {
534
+ metricsListeners.add(callback);
535
+ return () => {
536
+ metricsListeners.delete(callback);
537
+ };
538
+ },
539
+
540
+ dispose() {
541
+ resetStartupPacketTransientState(startupPacket);
542
+ steadyStatePendingUpdates = [];
543
+ flushScheduled = false;
544
+ stateListeners.clear();
545
+ resolvedStateAckListeners.clear();
546
+ metricsListeners.clear();
547
+ preparedRegistrationCache.clear();
548
+ lastMetrics = null;
549
+ lastWorkerError = null;
550
+ if (typeof worker.removeEventListener === 'function') {
551
+ worker.removeEventListener('message', handleMessage);
552
+ worker.removeEventListener('error', handleError);
553
+ }
554
+ parkOrDisposeCompositorLease({
555
+ worker,
556
+ runtime,
557
+ capacity,
558
+ bootstrapSnapshot: Array.from(activeRegistrations.values()),
559
+ });
560
+ },
561
+ };
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Export
566
+ // ---------------------------------------------------------------------------
567
+
568
+ /**
569
+ * Factory namespace for the compositor worker.
570
+ *
571
+ * Call {@link CompositorWorker.create} on the main thread to spin up a
572
+ * worker that evaluates quantizer boundaries and emits
573
+ * {@link CompositorWorkerState} snapshots. The returned
574
+ * {@link CompositorWorkerShape} owns the underlying `Worker` -- call
575
+ * `dispose()` (or park via the lease pool) when finished.
576
+ *
577
+ * @example
578
+ * ```ts
579
+ * import { CompositorWorker } from '@czap/worker';
580
+ *
581
+ * const compositor = CompositorWorker.create({ poolCapacity: 64 });
582
+ * compositor.addQuantizer('brightness', {
583
+ * id: 'boundary:brightness',
584
+ * states: ['dim', 'bright'],
585
+ * thresholds: [0.5],
586
+ * });
587
+ * const unsub = compositor.onState((state) => {
588
+ * // state.discrete.brightness === 'bright' | 'dim'
589
+ * });
590
+ * compositor.evaluate('brightness', 0.7);
591
+ * compositor.requestCompute();
592
+ * // ...later:
593
+ * unsub();
594
+ * compositor.dispose();
595
+ * ```
596
+ */
597
+ export const CompositorWorker = {
598
+ /**
599
+ * Spin up a new compositor worker. Returns immediately; the worker
600
+ * posts `ready` asynchronously. Optionally provide startup telemetry
601
+ * to capture per-stage timings.
602
+ */
603
+ create: _createCompositorWorker,
604
+ } as const;
605
+
606
+ export declare namespace CompositorWorker {
607
+ /** Public host-side surface returned by {@link CompositorWorker.create}. */
608
+ export type Shape = CompositorWorkerShape;
609
+ /** Named startup stage reported to telemetry sinks. */
610
+ export type StartupStage = CompositorWorkerStartupStage;
611
+ /** Telemetry sink accepted by {@link CompositorWorker.create}. */
612
+ export type StartupTelemetry = CompositorWorkerStartupTelemetry;
613
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared inline source for `evaluateThresholds` injected into worker blob scripts.
3
+ *
4
+ * Worker blob scripts cannot use ES module imports at runtime, so the
5
+ * threshold-evaluation logic must be inlined as a string. This module is the
6
+ * single source of truth for that string so both compositor-worker.ts and
7
+ * render-worker.ts stay in sync automatically.
8
+ *
9
+ * Canonical TypeScript implementation: `packages/quantizer/src/evaluate.ts`
10
+ * (`evaluate` / `Evaluate.evaluate` in `@czap/quantizer`).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /**
16
+ * Inline JavaScript source for the `evaluateThresholds` helper.
17
+ *
18
+ * This is a simplified (no-hysteresis) version of the canonical
19
+ * `Evaluate.evaluate` from `@czap/quantizer`, suitable for embedding in
20
+ * self-contained worker blob scripts.
21
+ */
22
+ export const EVALUATE_THRESHOLDS_SOURCE = `\
23
+ /**
24
+ * Evaluate which discrete state a value falls into based on thresholds.
25
+ * Thresholds are sorted ascending; the value maps to the state whose
26
+ * threshold it first exceeds (or the first state if below all thresholds).
27
+ *
28
+ * Canonical TypeScript implementation: packages/quantizer/src/evaluate.ts
29
+ *
30
+ * @param {number[]} thresholds
31
+ * @param {string[]} states
32
+ * @param {number} value
33
+ * @returns {string}
34
+ */
35
+ function evaluateThresholds(thresholds, states, value) {
36
+ for (let i = thresholds.length - 1; i >= 0; i--) {
37
+ if (value >= thresholds[i]) {
38
+ return states[i] || states[0] || "";
39
+ }
40
+ }
41
+ return states[0] || "";
42
+ }`;