@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,666 @@
1
+ /**
2
+ * Startup packet building, startup mode management, and compositor lease
3
+ * lifecycle helpers for the CompositorWorker module.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ import { RuntimeCoordinator } from '@czap/core';
9
+ import type {
10
+ ToWorkerMessage,
11
+ WorkerUpdate,
12
+ BootstrapQuantizerRegistration,
13
+ StartupComputePacket,
14
+ ResolvedStateEntry,
15
+ } from './messages.js';
16
+ import type {
17
+ CompositorWorkerStartupTelemetry,
18
+ CompositorWorkerStartupDiagnosticStage,
19
+ ResolvedStateAckPayload,
20
+ StandbyCompositorLease,
21
+ StartupPacketState,
22
+ } from './compositor-types.js';
23
+ import { COMPOSITOR_WORKER_SCRIPT } from './compositor-script.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Module-level cached state
27
+ // ---------------------------------------------------------------------------
28
+
29
+ let cachedCompositorWorkerUrl: string | null = null;
30
+ let cachedCreateObjectUrl: typeof URL.createObjectURL | null = null;
31
+ let cleanupRegistered = false;
32
+ let standbyCompositorLease: StandbyCompositorLease | null = null;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Timing helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Return the current high-resolution wall-clock time in nanoseconds.
40
+ *
41
+ * Uses `performance.now()` when available; falls back to `Date.now()`
42
+ * in environments without the performance timeline.
43
+ */
44
+ export function currentTimeNs(): number {
45
+ const currentTimeMs = typeof performance !== 'undefined' ? performance.now() : Date.now();
46
+ return currentTimeMs * 1e6;
47
+ }
48
+
49
+ /**
50
+ * Forward a fine-grained startup-diagnostic duration sample to a
51
+ * telemetry sink (if the sink opts into diagnostic stages).
52
+ *
53
+ * Safe to call when `telemetry` is undefined or does not implement
54
+ * `recordDiagnosticStage` -- the call becomes a no-op.
55
+ */
56
+ export function recordStartupDiagnosticStage(
57
+ telemetry: CompositorWorkerStartupTelemetry | undefined,
58
+ stage: CompositorWorkerStartupDiagnosticStage,
59
+ durationNs: number,
60
+ ): void {
61
+ const recordDiagnosticStage = (
62
+ telemetry as
63
+ | (CompositorWorkerStartupTelemetry & {
64
+ readonly recordDiagnosticStage?: (
65
+ diagnosticStage: CompositorWorkerStartupDiagnosticStage,
66
+ diagnosticDurationNs: number,
67
+ ) => void;
68
+ })
69
+ | undefined
70
+ )?.recordDiagnosticStage;
71
+
72
+ recordDiagnosticStage?.(stage, durationNs);
73
+ }
74
+
75
+ /**
76
+ * Notify a telemetry sink that the worker acknowledged a resolved-state
77
+ * hydration. Safe to call when the sink does not implement
78
+ * `onResolvedStateSettled`.
79
+ */
80
+ export function notifyResolvedStateSettled(
81
+ telemetry: CompositorWorkerStartupTelemetry | undefined,
82
+ states: readonly ResolvedStateEntry[],
83
+ ): void {
84
+ const onResolvedStateSettled = (
85
+ telemetry as
86
+ | (CompositorWorkerStartupTelemetry & {
87
+ readonly onResolvedStateSettled?: (settledStates: readonly ResolvedStateEntry[]) => void;
88
+ })
89
+ | undefined
90
+ )?.onResolvedStateSettled;
91
+
92
+ onResolvedStateSettled?.(states);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Startup packet state management
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Project a set of bootstrap registrations down to the minimal
101
+ * `{ name, states }` shape the runtime coordinator needs to seed its
102
+ * quantizer registry.
103
+ */
104
+ export function registrationsToRuntimeSeed(registrations: readonly BootstrapQuantizerRegistration[]): readonly {
105
+ readonly name: string;
106
+ readonly states: readonly string[];
107
+ }[] {
108
+ return registrations.map((registration) => ({
109
+ name: registration.name,
110
+ states: registration.states,
111
+ }));
112
+ }
113
+
114
+ /**
115
+ * Build a fresh {@link StartupPacketState} seeded with an initial
116
+ * bootstrap mode and registration list. Used by the compositor worker to
117
+ * stage messages before flushing them in a single `startup-compute` post.
118
+ */
119
+ export function createStartupPacketState(
120
+ bootstrapMode: StartupComputePacket['bootstrapMode'],
121
+ initialRegistrations: readonly BootstrapQuantizerRegistration[] = [],
122
+ ): StartupPacketState {
123
+ return {
124
+ bootstrapMode,
125
+ registrations: new Map(initialRegistrations.map((registration) => [registration.name, registration] as const)),
126
+ registrationList: initialRegistrations.length > 0 ? [...initialRegistrations] : [],
127
+ runtimeSeedList: initialRegistrations.length > 0 ? registrationsToRuntimeSeed(initialRegistrations) : [],
128
+ updates: [],
129
+ runtimeSeedDirty: false,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Snapshot a {@link StartupPacketState} into an immutable
135
+ * {@link StartupComputePacket} suitable for `postMessage`.
136
+ */
137
+ export function buildStartupComputePacket(packet: StartupPacketState): StartupComputePacket {
138
+ const builtPacket = {
139
+ bootstrapMode: packet.bootstrapMode,
140
+ registrations: getStartupPacketRegistrations(packet),
141
+ updates: packet.updates,
142
+ };
143
+
144
+ return builtPacket;
145
+ }
146
+
147
+ /**
148
+ * Return the ordered list of registrations in the startup packet,
149
+ * caching the result so repeated reads are O(1).
150
+ */
151
+ export function getStartupPacketRegistrations(packet: StartupPacketState): readonly BootstrapQuantizerRegistration[] {
152
+ if (packet.registrationList !== null) {
153
+ return packet.registrationList;
154
+ }
155
+
156
+ packet.registrationList = Array.from(packet.registrations.values());
157
+ return packet.registrationList;
158
+ }
159
+
160
+ /**
161
+ * Return the runtime-seed projection of the startup packet's
162
+ * registrations, recomputing on demand if invalidated.
163
+ */
164
+ export function getStartupPacketRuntimeSeed(packet: StartupPacketState): readonly {
165
+ readonly name: string;
166
+ readonly states: readonly string[];
167
+ }[] {
168
+ if (packet.runtimeSeedList !== null && !packet.runtimeSeedDirty) {
169
+ return packet.runtimeSeedList;
170
+ }
171
+
172
+ packet.runtimeSeedList = registrationsToRuntimeSeed(getStartupPacketRegistrations(packet));
173
+ packet.runtimeSeedDirty = false;
174
+ return packet.runtimeSeedList;
175
+ }
176
+
177
+ /**
178
+ * Return `true` when the given runtime coordinator already has every
179
+ * quantizer referenced by the runtime seed registered (by name).
180
+ *
181
+ * Used to decide whether a pre-warmed lease's runtime can be reused
182
+ * as-is or must be reset before replay.
183
+ */
184
+ export function runtimeMatchesStartupSeed(
185
+ runtime: RuntimeCoordinator.Shape,
186
+ runtimeSeed: readonly {
187
+ readonly name: string;
188
+ readonly states: readonly string[];
189
+ }[],
190
+ ): boolean {
191
+ const registeredNames = runtime.registeredNames();
192
+ if (
193
+ !sameArray(
194
+ registeredNames,
195
+ runtimeSeed.map((registration) => registration.name),
196
+ )
197
+ ) {
198
+ return false;
199
+ }
200
+
201
+ for (const registration of runtimeSeed) {
202
+ if (!runtime.hasQuantizer(registration.name)) {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ return true;
208
+ }
209
+
210
+ /**
211
+ * Insert or overwrite a registration in the startup packet, invalidating
212
+ * derived caches. Pass `invalidateRuntimeSeed: false` when the caller
213
+ * already knows the runtime seed is structurally unchanged (e.g. only
214
+ * initial state or blend weights changed).
215
+ */
216
+ export function setStartupPacketRegistration(
217
+ packet: StartupPacketState,
218
+ registration: BootstrapQuantizerRegistration,
219
+ invalidateRuntimeSeed = true,
220
+ ): void {
221
+ packet.registrations.set(registration.name, registration);
222
+ packet.registrationList = null;
223
+ if (invalidateRuntimeSeed) {
224
+ packet.runtimeSeedList = null;
225
+ packet.runtimeSeedDirty = true;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Drop a registration by name from the startup packet and invalidate
231
+ * derived caches.
232
+ */
233
+ export function removeStartupPacketRegistration(packet: StartupPacketState, name: string): void {
234
+ packet.registrations.delete(name);
235
+ packet.registrationList = null;
236
+ packet.runtimeSeedList = null;
237
+ packet.runtimeSeedDirty = true;
238
+ }
239
+
240
+ /**
241
+ * Queue a {@link WorkerUpdate} to be replayed after bootstrap. Order is
242
+ * preserved to match main-thread issue order.
243
+ */
244
+ export function pushStartupPacketUpdate(packet: StartupPacketState, update: WorkerUpdate): void {
245
+ packet.updates.push(update);
246
+ }
247
+
248
+ /**
249
+ * Filter the packet's pending update queue in-place. Typically used to
250
+ * drop redundant updates (e.g. newer `set-blend` supersedes older ones).
251
+ */
252
+ export function filterStartupPacketUpdates(packet: StartupPacketState, keep: (update: WorkerUpdate) => boolean): void {
253
+ if (packet.updates.length === 0) {
254
+ return;
255
+ }
256
+ const filtered = packet.updates.filter(keep);
257
+ if (filtered.length !== packet.updates.length) {
258
+ packet.updates = filtered;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Structural equality check for `Record<string, number>` blend-weight
264
+ * maps. `undefined === undefined` is true; mismatched presence is false.
265
+ */
266
+ export function sameNumericRecord(
267
+ left: Record<string, number> | undefined,
268
+ right: Record<string, number> | undefined,
269
+ ): boolean {
270
+ if (left === right) {
271
+ return true;
272
+ }
273
+
274
+ if (!left || !right) {
275
+ return left === right;
276
+ }
277
+
278
+ const leftKeys = Object.keys(left);
279
+ const rightKeys = Object.keys(right);
280
+ if (leftKeys.length !== rightKeys.length) {
281
+ return false;
282
+ }
283
+
284
+ for (const key of leftKeys) {
285
+ if (left[key] !== right[key]) {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * Merge an updated initial-state assignment into an existing registration.
295
+ * Also scrubs any queued `evaluate` update targeting the same quantizer,
296
+ * since the new initial state supersedes it.
297
+ */
298
+ export function setStartupPacketInitialState(
299
+ packet: StartupPacketState,
300
+ registration: BootstrapQuantizerRegistration,
301
+ state: string,
302
+ ): void {
303
+ const currentRegistration = packet.registrations.get(registration.name)!;
304
+
305
+ const defaultState = currentRegistration.states[0];
306
+ const nextRegistration =
307
+ state === defaultState
308
+ ? (() => {
309
+ const { initialState: _initialState, ...withoutInitialState } = currentRegistration;
310
+ return withoutInitialState;
311
+ })()
312
+ : { ...currentRegistration, initialState: state };
313
+ const nextInitialState = 'initialState' in nextRegistration ? nextRegistration.initialState : undefined;
314
+ if (
315
+ currentRegistration.boundaryId !== nextRegistration.boundaryId ||
316
+ !sameArray(currentRegistration.states, nextRegistration.states) ||
317
+ !sameArray(currentRegistration.thresholds, nextRegistration.thresholds) ||
318
+ currentRegistration.initialState !== nextInitialState ||
319
+ !sameNumericRecord(currentRegistration.blendWeights, nextRegistration.blendWeights)
320
+ ) {
321
+ setStartupPacketRegistration(packet, nextRegistration, false);
322
+ }
323
+ filterStartupPacketUpdates(packet, (update) => !(update.type === 'evaluate' && update.name === registration.name));
324
+ }
325
+
326
+ /**
327
+ * Merge updated blend weights for an existing registration. Returns
328
+ * `false` when no registration with that name is present (the update is
329
+ * ignored). Scrubs superseded `set-blend` updates from the queue.
330
+ */
331
+ export function setStartupPacketBlendWeights(
332
+ packet: StartupPacketState,
333
+ name: string,
334
+ weights: Record<string, number>,
335
+ ): boolean {
336
+ const registration = packet.registrations.get(name);
337
+ if (!registration) {
338
+ return false;
339
+ }
340
+
341
+ if (!sameNumericRecord(registration.blendWeights, weights)) {
342
+ setStartupPacketRegistration(
343
+ packet,
344
+ {
345
+ ...registration,
346
+ blendWeights: weights,
347
+ },
348
+ false,
349
+ );
350
+ }
351
+ filterStartupPacketUpdates(packet, (update) => !(update.type === 'set-blend' && update.name === name));
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * Remove a registration and every pending update targeting it.
357
+ * Equivalent to undoing `add-quantizer` + any in-flight mutations.
358
+ */
359
+ export function removeStartupPacketEntries(packet: StartupPacketState, name: string): void {
360
+ removeStartupPacketRegistration(packet, name);
361
+ if (packet.updates.length === 0) {
362
+ return;
363
+ }
364
+
365
+ const filtered = packet.updates.filter((update) => update.name !== name);
366
+ if (filtered.length !== packet.updates.length) {
367
+ packet.updates = filtered;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Clear all transient state on a startup packet, leaving only the
373
+ * `bootstrapMode` in place. Used when the lease is recycled and the
374
+ * caller wants to start accumulating fresh messages.
375
+ */
376
+ export function resetStartupPacketTransientState(packet: StartupPacketState): void {
377
+ packet.registrations.clear();
378
+ packet.registrationList = [];
379
+ packet.runtimeSeedList = [];
380
+ packet.updates = [];
381
+ packet.runtimeSeedDirty = false;
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Compositor worker URL and blob management
386
+ // ---------------------------------------------------------------------------
387
+
388
+ function revokeCachedCompositorWorkerUrl(): void {
389
+ if (!cachedCompositorWorkerUrl) {
390
+ return;
391
+ }
392
+
393
+ URL.revokeObjectURL(cachedCompositorWorkerUrl);
394
+ cachedCompositorWorkerUrl = null;
395
+ cachedCreateObjectUrl = null;
396
+ }
397
+
398
+ function disposeStandbyCompositorLease(): void {
399
+ standbyCompositorLease?.worker.terminate();
400
+ standbyCompositorLease = null;
401
+ }
402
+
403
+ /** Typed helper that extracts globalThis.process without casting at call sites. */
404
+ function getNodeProcess(): { once?: (event: string, fn: () => void) => void } | null {
405
+ /* v8 ignore next — `globalThis` is available in every ES2020+ host (Node, browsers,
406
+ workers). The guard is defense-in-depth in case the module is ever loaded in a
407
+ pre-ES2020 sandbox where `globalThis` is missing. */
408
+ if (typeof globalThis === 'undefined' || !('process' in globalThis)) return null;
409
+ const p = (globalThis as unknown as { process?: unknown }).process;
410
+ /* v8 ignore next — Node's `process` is always the NodeJS.Process object; this guard
411
+ covers hosts that define `process` as a non-object (e.g. a compatibility shim that
412
+ sets it to `undefined` while still keeping the property slot). */
413
+ if (typeof p !== 'object' || p === null) return null;
414
+ return p as { once?: (event: string, fn: () => void) => void };
415
+ }
416
+
417
+ function registerCachedWorkerCleanup(): void {
418
+ if (cleanupRegistered) {
419
+ return;
420
+ }
421
+
422
+ cleanupRegistered = true;
423
+ const cleanup = (): void => {
424
+ disposeStandbyCompositorLease();
425
+ revokeCachedCompositorWorkerUrl();
426
+ };
427
+ if (typeof globalThis.addEventListener === 'function') {
428
+ globalThis.addEventListener('pagehide', cleanup, { once: true });
429
+ return;
430
+ }
431
+
432
+ const proc = getNodeProcess();
433
+ if (proc !== null && typeof proc.once === 'function') proc.once('exit', cleanup);
434
+ }
435
+
436
+ function getCompositorWorkerUrl(): string {
437
+ if (cachedCompositorWorkerUrl && cachedCreateObjectUrl === URL.createObjectURL) {
438
+ return cachedCompositorWorkerUrl;
439
+ }
440
+
441
+ if (cachedCompositorWorkerUrl) {
442
+ revokeCachedCompositorWorkerUrl();
443
+ }
444
+
445
+ cachedCompositorWorkerUrl = URL.createObjectURL(
446
+ new Blob([COMPOSITOR_WORKER_SCRIPT], { type: 'application/javascript' }),
447
+ );
448
+ cachedCreateObjectUrl = URL.createObjectURL;
449
+ registerCachedWorkerCleanup();
450
+ return cachedCompositorWorkerUrl;
451
+ }
452
+
453
+ function createRawCompositorWorker(): Worker {
454
+ const url = getCompositorWorkerUrl();
455
+ return new Worker(url, { type: 'classic', name: 'czap-compositor' });
456
+ }
457
+
458
+ function createRuntimeCoordinator(capacity: number): RuntimeCoordinator.Shape {
459
+ return RuntimeCoordinator.create({
460
+ capacity,
461
+ name: 'czap-worker-runtime',
462
+ });
463
+ }
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // Compositor lease lifecycle
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /**
470
+ * Claim a compositor lease: either hand back the standby pre-warmed
471
+ * worker (if one is parked and matches the requested capacity) or mint a
472
+ * fresh `Worker` + {@link RuntimeCoordinator}. Emits
473
+ * `claim-or-create` and `coordinator-reset-or-create` stage samples to
474
+ * the optional telemetry sink.
475
+ *
476
+ * @param capacity - Runtime coordinator capacity to request.
477
+ * @param startupTelemetry - Optional sink for stage timings.
478
+ * @returns The worker, its coordinator, and any bootstrap snapshot the
479
+ * parked lease brought with it.
480
+ */
481
+ export function claimCompositorLease(
482
+ capacity: number,
483
+ startupTelemetry?: CompositorWorkerStartupTelemetry,
484
+ ): {
485
+ readonly worker: Worker;
486
+ readonly runtime: RuntimeCoordinator.Shape;
487
+ readonly bootstrapSnapshot: readonly BootstrapQuantizerRegistration[];
488
+ } {
489
+ if (
490
+ standbyCompositorLease &&
491
+ (standbyCompositorLease.workerConstructor !== Worker ||
492
+ standbyCompositorLease.createObjectUrl !== URL.createObjectURL ||
493
+ standbyCompositorLease.capacity !== capacity)
494
+ ) {
495
+ disposeStandbyCompositorLease();
496
+ }
497
+
498
+ const claimStartNs = currentTimeNs();
499
+ const claimedLease = standbyCompositorLease;
500
+ standbyCompositorLease = null;
501
+ const worker = claimedLease?.worker ?? createRawCompositorWorker();
502
+ startupTelemetry?.recordStage('claim-or-create', currentTimeNs() - claimStartNs);
503
+
504
+ const coordinatorStartNs = currentTimeNs();
505
+ const runtime = claimedLease?.runtime ?? createRuntimeCoordinator(capacity);
506
+ const bootstrapSnapshot = claimedLease?.bootstrapSnapshot ?? [];
507
+ if (claimedLease) {
508
+ const runtimeResetStartNs = currentTimeNs();
509
+ runtime.reset();
510
+ recordStartupDiagnosticStage(
511
+ startupTelemetry,
512
+ 'coordinator-reset-or-create:runtime-reset-reuse',
513
+ currentTimeNs() - runtimeResetStartNs,
514
+ );
515
+ }
516
+ startupTelemetry?.recordStage('coordinator-reset-or-create', currentTimeNs() - coordinatorStartNs);
517
+
518
+ return {
519
+ worker,
520
+ runtime,
521
+ bootstrapSnapshot,
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Park a compositor lease in the module-level standby slot so a future
527
+ * {@link claimCompositorLease} can reuse it. If the standby slot is
528
+ * already occupied, the incoming lease is disposed (`dispose` message +
529
+ * `terminate()`) instead.
530
+ */
531
+ export function parkOrDisposeCompositorLease(lease: {
532
+ readonly worker: Worker;
533
+ readonly runtime: RuntimeCoordinator.Shape;
534
+ readonly capacity: number;
535
+ readonly bootstrapSnapshot: readonly BootstrapQuantizerRegistration[];
536
+ }): void {
537
+ if (
538
+ !standbyCompositorLease &&
539
+ typeof Worker !== 'undefined' &&
540
+ Worker === lease.worker.constructor &&
541
+ typeof URL.createObjectURL === 'function' &&
542
+ URL.createObjectURL === cachedCreateObjectUrl
543
+ ) {
544
+ standbyCompositorLease = {
545
+ ...lease,
546
+ workerConstructor: Worker,
547
+ createObjectUrl: URL.createObjectURL,
548
+ };
549
+ return;
550
+ }
551
+
552
+ _send(lease.worker, { type: 'dispose' });
553
+ lease.worker.terminate();
554
+ }
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // Utility helpers
558
+ // ---------------------------------------------------------------------------
559
+
560
+ /**
561
+ * Internal `postMessage` helper with an explicit transfer-list default.
562
+ * Named with a leading underscore to signal that host code should use
563
+ * the typed methods on {@link CompositorWorkerShape} instead.
564
+ */
565
+ export function _send(worker: Worker, msg: ToWorkerMessage, transfer?: Transferable[]): void {
566
+ worker.postMessage(msg, transfer ?? []);
567
+ }
568
+
569
+ /**
570
+ * Convert a registration's thresholds to a Float64Array for transfer.
571
+ * Returns a new registration object with the typed array and the ArrayBuffer to transfer.
572
+ */
573
+ export function prepareRegistrationForTransfer(registration: BootstrapQuantizerRegistration): {
574
+ registration: BootstrapQuantizerRegistration;
575
+ buffer: ArrayBuffer;
576
+ } {
577
+ const f64 = new Float64Array(registration.thresholds);
578
+ return {
579
+ registration: { ...registration, thresholds: f64 },
580
+ buffer: f64.buffer,
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Prepare a list of registrations for transfer, returning new registrations
586
+ * and the collected ArrayBuffers to include in the transfer list.
587
+ */
588
+ export function prepareRegistrationsForTransfer(registrations: readonly BootstrapQuantizerRegistration[]): {
589
+ registrations: readonly BootstrapQuantizerRegistration[];
590
+ buffers: ArrayBuffer[];
591
+ } {
592
+ const buffers: ArrayBuffer[] = [];
593
+ const prepared = registrations.map((reg) => {
594
+ const { registration, buffer } = prepareRegistrationForTransfer(reg);
595
+ buffers.push(buffer);
596
+ return registration;
597
+ });
598
+ return { registrations: prepared, buffers };
599
+ }
600
+
601
+ /**
602
+ * Structural equality for two `ArrayLike` sequences (same length, same
603
+ * `===` elements at every index). Works for both plain arrays and typed
604
+ * arrays.
605
+ */
606
+ export function sameArray<T>(left: ArrayLike<T>, right: ArrayLike<T>): boolean {
607
+ if (left.length !== right.length) {
608
+ return false;
609
+ }
610
+
611
+ for (let index = 0; index < left.length; index++) {
612
+ if (left[index] !== right[index]) {
613
+ return false;
614
+ }
615
+ }
616
+
617
+ return true;
618
+ }
619
+
620
+ /**
621
+ * Return `true` when two bootstrap registrations share the same
622
+ * `boundaryId`, state list, and threshold list. Used to elide redundant
623
+ * `add-quantizer` messages during bootstrap coalescing.
624
+ */
625
+ export function sameBootstrapRegistration(
626
+ left: BootstrapQuantizerRegistration | undefined,
627
+ right: BootstrapQuantizerRegistration,
628
+ ): boolean {
629
+ if (!left) {
630
+ return false;
631
+ }
632
+
633
+ return (
634
+ left.boundaryId === right.boundaryId &&
635
+ sameArray(left.states, right.states) &&
636
+ sameArray(left.thresholds, right.thresholds)
637
+ );
638
+ }
639
+
640
+ /**
641
+ * Quantize a numeric value against a registration's thresholds and
642
+ * return the corresponding state label. Falls back to `states[0]` if the
643
+ * value lies below every threshold.
644
+ */
645
+ export function evaluateRegistrationState(registration: BootstrapQuantizerRegistration, value: number): string {
646
+ for (let index = registration.thresholds.length - 1; index >= 0; index--) {
647
+ if (value >= registration.thresholds[index]!) {
648
+ return registration.states[index] ?? registration.states[0]!;
649
+ }
650
+ }
651
+
652
+ return registration.states[0]!;
653
+ }
654
+
655
+ /**
656
+ * Re-shape a {@link ResolvedStateAckPayload} into the flat
657
+ * {@link ResolvedStateEntry} form that the main-thread state store
658
+ * consumes. Propagates `ack.generation` into each entry.
659
+ */
660
+ export function toResolvedStateEntriesFromAck(ack: ResolvedStateAckPayload): readonly ResolvedStateEntry[] {
661
+ return ack.states.map((state) => ({
662
+ name: state.name,
663
+ state: state.state,
664
+ generation: ack.generation,
665
+ }));
666
+ }