@clipkit/playback 1.0.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.
package/dist/engine.js ADDED
@@ -0,0 +1,1261 @@
1
+ // PlaybackEngine — wires the clock, audio scheduler, frame producer
2
+ // worker, frame buffer, and RAF presenter into one public API.
3
+ //
4
+ // Architecture (see SPRINT.md):
5
+ // - TransportClock owns time. AudioContext is its precision substrate.
6
+ // - AudioScheduler subscribes to the clock, schedules each `audio`
7
+ // element via AudioBufferSourceNode.start(when, offset).
8
+ // - Worker renders frames into a private OffscreenCanvas and posts
9
+ // them as VideoFrames (zero-copy transfer).
10
+ // - FrameBuffer holds VideoFrames sorted by composition time; the
11
+ // RAF presenter peeks the matching frame and draws it to the
12
+ // display canvas.
13
+ // - The engine schedules buffer top-ups: keep `bufferTargetSeconds`
14
+ // worth of frames ahead of the playhead, capped by an inflight
15
+ // limit so the worker never gets piled up.
16
+ //
17
+ // Stale-frame discipline: every seek / setSource bumps the engine's
18
+ // sequence ID. The buffer drops mismatched frames at push and at
19
+ // `setSequenceId`. The worker doesn't know about cancellation — it
20
+ // always finishes the frame it's mid-render on, and main throws it
21
+ // away if stale. Simpler protocol; one or two wasted renders per seek.
22
+ import { mapToMediaTime, rateOf, timeRemapOf, trimDurationOf } from '@clipkit/runtime';
23
+ import { TransportClock } from './clock.js';
24
+ import { stepFrameTime } from './frame-math.js';
25
+ import { AudioScheduler } from './audio.js';
26
+ import { FrameBuffer } from './buffer.js';
27
+ // Look-ahead window the engine maintains while NOT interactive
28
+ // (i.e., normal playback / idle). Set high enough that heavy
29
+ // compositions get a real cushion before the buffer can drain — at
30
+ // 0.5s a worker that takes 60ms per frame produces only ~8 frames
31
+ // in the lead window, and the presenter starves the first time it
32
+ // hits a slow frame. At 3s the same composition gets ~50 frames of
33
+ // headroom, so transient slow frames don't cause visible stutter.
34
+ const DEFAULT_BUFFER_TARGET_SEC = 3.0;
35
+ const DEFAULT_TIME_UPDATE_MS = 100;
36
+ const DEFAULT_BACKEND = 'auto';
37
+ const DEFAULT_FRAME_RATE = 30;
38
+ /**
39
+ * The schema requires frame_rate to be positive, but the engine must
40
+ * survive bad input: frame_rate ≤ 0 / NaN turns the produce cadence
41
+ * into ±Infinity/NaN request times and wedges frame production
42
+ * PERMANENTLY (NaN defeats the catch-up reset; in-flight NaN produces
43
+ * jam the counter). Clamp to a sane positive range instead.
44
+ */
45
+ function sanitizeFrameRate(v) {
46
+ return typeof v === 'number' && Number.isFinite(v) && v > 0
47
+ ? Math.min(v, 240)
48
+ : DEFAULT_FRAME_RATE;
49
+ }
50
+ const MAX_INFLIGHT_PRODUCE = 4;
51
+ /** Buffer capacity = targetSec * frameRate * this. Headroom for over-production + in-flight. */
52
+ const BUFFER_CAPACITY_HEADROOM = 1.6;
53
+ export class PlaybackEngine {
54
+ // ── Public state ────────────────────────────────────────────────────
55
+ source;
56
+ duration;
57
+ ready;
58
+ get playing() {
59
+ return this.#clock?.playing ?? false;
60
+ }
61
+ get time() {
62
+ return this.#clock?.now() ?? 0;
63
+ }
64
+ // ── Private state ───────────────────────────────────────────────────
65
+ #displayCanvas;
66
+ #displayCtx;
67
+ #audioContext;
68
+ #ownsAudioContext;
69
+ #bufferTargetSec;
70
+ #timeUpdateIntervalMs;
71
+ /**
72
+ * "Interactive" mode: skip look-ahead frame production. The engine
73
+ * still renders the current playhead's frame on every setSource, so
74
+ * the canvas updates live as the user edits — but it doesn't waste
75
+ * cycles pre-rendering frames that the next edit will invalidate.
76
+ * Toggle on at the start of a drag, off when the drag ends.
77
+ */
78
+ #interactive = false;
79
+ #loop = false;
80
+ // ── Perf stats (for the in-app diagnostic HUD) ──────────────────
81
+ // Sliding window of presenter-tick timestamps (ms since origin),
82
+ // capped at ~2s of samples. Used to compute presented-FPS and the
83
+ // ms-gap-between-paints jitter.
84
+ #presentTimes = [];
85
+ // Per-produce request start timestamps, keyed by the composition
86
+ // time of the requested frame (each pending produce has a unique
87
+ // `time`). Trimmed on receive. Used to compute worker latency.
88
+ #pendingProduceStart = new Map();
89
+ /** Pending renderFrameAt() requests, keyed by requestId. */
90
+ #stillRequests = new Map();
91
+ #stillRequestId = 0;
92
+ // Sliding window of measured worker latencies (request → receive).
93
+ #workerLatencies = [];
94
+ // Sliding window of worker-reported runtime.frame() durations.
95
+ #workerFrameMs = [];
96
+ #workerPrepareMs = [];
97
+ #workerBlurSamples = [];
98
+ // Sliding window of worker-reported `new VideoFrame()` durations.
99
+ #workerVideoFrameMs = [];
100
+ // Sliding window of worker-reported full handleProduce durations.
101
+ #workerTotalMs = [];
102
+ // Sliding window of time-from-worker-send → main-receive (cross
103
+ // thread). Worker has its own performance.timeOrigin (set at
104
+ // worker creation, NOT at navigation), so subtractions across
105
+ // threads need the offset captured at boot.
106
+ #queueLagMs = [];
107
+ // ms to add to worker `performance.now()` values to convert into
108
+ // main's clock. Captured from the worker's ready message.
109
+ #workerClockOffset = 0;
110
+ // Sliding window of drawImage durations on the display canvas.
111
+ // Main-thread cost per presented frame; if this is large the
112
+ // presenter is blocking its own rAF loop.
113
+ #drawImageMs = [];
114
+ // Count of presenter ticks where peekAt() returned null — i.e.
115
+ // the buffer was empty and the canvas couldn't be repainted.
116
+ #starvationCount = 0;
117
+ #clock;
118
+ #scheduler;
119
+ #buffer;
120
+ #worker;
121
+ /**
122
+ * FALLBACK main-thread video decoders, keyed by absolute source URL.
123
+ * The worker self-decodes MP4 video deterministically via WebCodecs;
124
+ * for URLs it reports it CANNOT decode (`videoFallback` message —
125
+ * non-MP4 container, unsupported codec), the engine owns a muted
126
+ * `<video>` per URL, keeps it in sync with the transport clock
127
+ * (#syncVideos), and pumps decoded ImageBitmaps to the worker.
128
+ * Pump preview is live-sync grade; the WebCodecs path is exact.
129
+ */
130
+ #videoPump = new Map();
131
+ /** Last absolutized source — pump specs are rebuilt from this when the worker reports fallbacks. */
132
+ #lastResolvedSource = null;
133
+ #frameRate = DEFAULT_FRAME_RATE;
134
+ #sequenceId = 1;
135
+ #highestRequestedTime = 0;
136
+ #inflight = 0;
137
+ #workerReady = false;
138
+ #disposed = false;
139
+ #rafHandle = 0;
140
+ #lastTimeEmit = 0;
141
+ /**
142
+ * Debounce timer for the paused-playhead "final quality" refine —
143
+ * one motion-blurred produce once scrubbing settles (see
144
+ * #scheduleFinalRefine).
145
+ */
146
+ #finalRefineTimer = null;
147
+ #lastBufferStarved = null;
148
+ /**
149
+ * Resolves the first time a frame matching the current sequenceId
150
+ * arrives. Reset on every seek / setSource so the engine can gate
151
+ * "have we got something to show yet?" without a polling loop.
152
+ */
153
+ #firstFrameResolve = null;
154
+ #listeners = {
155
+ time: new Set(),
156
+ playing: new Set(),
157
+ error: new Set(),
158
+ buffer: new Set(),
159
+ };
160
+ // ── Construction ────────────────────────────────────────────────────
161
+ constructor(options) {
162
+ this.source = options.source;
163
+ this.duration = computeDuration(options.source);
164
+ this.#frameRate = sanitizeFrameRate(options.source.frame_rate);
165
+ // Seed below the playhead so the first #topUpBuffer call schedules
166
+ // a produce AT t=0 (next = -1/fps + 1/fps = 0). The default of 0
167
+ // caused the first produce to land at t=1/fps, leaving the buffer
168
+ // empty at t=0 and the canvas blank until the user seeks or plays.
169
+ this.#highestRequestedTime = -1 / this.#frameRate;
170
+ this.#displayCanvas = options.displayCanvas;
171
+ const ctx = options.displayCanvas.getContext('2d');
172
+ if (!ctx) {
173
+ throw new Error('PlaybackEngine: failed to acquire 2d context on displayCanvas');
174
+ }
175
+ this.#displayCtx = ctx;
176
+ this.#displayCanvas.width = options.source.width ?? 1920;
177
+ this.#displayCanvas.height = options.source.height ?? 1080;
178
+ this.#bufferTargetSec = options.bufferTargetSeconds ?? DEFAULT_BUFFER_TARGET_SEC;
179
+ this.#timeUpdateIntervalMs = options.timeUpdateIntervalMs ?? DEFAULT_TIME_UPDATE_MS;
180
+ if (options.audioContext) {
181
+ this.#audioContext = options.audioContext;
182
+ this.#ownsAudioContext = false;
183
+ }
184
+ else {
185
+ this.#audioContext = new AudioContext();
186
+ this.#ownsAudioContext = true;
187
+ }
188
+ // Set up the ready Promise, then kick off async init. We can't await
189
+ // inside the constructor, so the work happens in #init() and resolves
190
+ // / rejects this Promise.
191
+ let resolveReady;
192
+ let rejectReady;
193
+ this.ready = new Promise((res, rej) => {
194
+ resolveReady = res;
195
+ rejectReady = rej;
196
+ });
197
+ this.#init(options.backend ?? DEFAULT_BACKEND)
198
+ .then(resolveReady)
199
+ .catch((err) => {
200
+ const error = err instanceof Error ? err : new Error(String(err));
201
+ rejectReady(error);
202
+ this.#emitError(error);
203
+ });
204
+ }
205
+ // ── Public control ──────────────────────────────────────────────────
206
+ async play() {
207
+ if (this.#disposed)
208
+ throw new Error('PlaybackEngine is disposed');
209
+ await this.ready;
210
+ // If we're at (or very near) the end, restart from the beginning
211
+ // — pressing play after the source ended should replay, not be a
212
+ // no-op. Use a 1-frame tolerance so a hand-paused-near-end seek
213
+ // (e.g. playhead at duration - 0.5ms) also rewinds cleanly.
214
+ if (this.#clock.now() >= this.duration - 1 / this.#frameRate) {
215
+ this.seek(0);
216
+ }
217
+ await this.#clock.play();
218
+ this.#emitPlayingChange(true);
219
+ }
220
+ pause() {
221
+ if (this.#disposed)
222
+ return;
223
+ if (!this.#clock)
224
+ return;
225
+ const wasPlaying = this.#clock.playing;
226
+ this.#clock.pause();
227
+ if (wasPlaying)
228
+ this.#emitPlayingChange(false);
229
+ this.#scheduleFinalRefine();
230
+ }
231
+ seek(time) {
232
+ if (this.#disposed)
233
+ return;
234
+ if (!this.#clock)
235
+ return;
236
+ const clamped = Math.max(0, Math.min(time, this.duration));
237
+ this.#clock.seek(clamped);
238
+ this.#invalidateAfterSeek(clamped);
239
+ }
240
+ /**
241
+ * Frame-quantized transport step (the editor's prev/next-frame
242
+ * buttons). Pauses playback and seeks to the frame `frames` away
243
+ * from the current one (negative steps back), clamped to the
244
+ * composition. Quantization math is shared via frame-math.ts.
245
+ */
246
+ stepFrame(frames = 1) {
247
+ if (this.#disposed)
248
+ return;
249
+ this.pause();
250
+ const fps = this.source.frame_rate ?? 30;
251
+ this.seek(stepFrameTime(this.currentTime, fps, frames, this.duration));
252
+ }
253
+ /**
254
+ * Render ONE export-quality still at `time` (motion blur included
255
+ * when the source configures it) off the playback path, returned as
256
+ * an ImageBitmap. `width` downscales preserving aspect — thumbnail
257
+ * strips and scrub-hover previews stay cheap to transfer. Requests
258
+ * serialize behind frame production in the worker.
259
+ */
260
+ async renderFrameAt(time, options = {}) {
261
+ if (this.#disposed)
262
+ throw new Error('PlaybackEngine is disposed');
263
+ await this.ready;
264
+ const requestId = ++this.#stillRequestId;
265
+ const clamped = Math.max(0, Math.min(time, this.duration));
266
+ return new Promise((resolve, reject) => {
267
+ this.#stillRequests.set(requestId, { resolve, reject });
268
+ this.#postToWorker({
269
+ type: 'still',
270
+ time: clamped,
271
+ requestId,
272
+ width: options.width,
273
+ });
274
+ });
275
+ }
276
+ async setSource(source) {
277
+ if (this.#disposed)
278
+ throw new Error('PlaybackEngine is disposed');
279
+ await this.ready;
280
+ const previousTime = this.#clock.now();
281
+ this.source = source;
282
+ this.duration = computeDuration(source);
283
+ this.#frameRate = sanitizeFrameRate(source.frame_rate);
284
+ // ⚠ Assigning to canvas.width / .height — even with the same value —
285
+ // resets the canvas bitmap (HTML spec). Only assign on real changes
286
+ // so the canvas keeps its last drawn frame visible during the brief
287
+ // window between setSource and the next frame arriving from the
288
+ // worker. Without this guard, every drag dispatch flickers to black.
289
+ const nextW = source.width ?? this.#displayCanvas.width;
290
+ const nextH = source.height ?? this.#displayCanvas.height;
291
+ if (this.#displayCanvas.width !== nextW)
292
+ this.#displayCanvas.width = nextW;
293
+ if (this.#displayCanvas.height !== nextH)
294
+ this.#displayCanvas.height = nextH;
295
+ const clamped = Math.min(previousTime, this.duration);
296
+ this.#clock.seek(clamped);
297
+ // Same ordering rule as patchElements: invalidate (no top-up) →
298
+ // post setSource → THEN top up. If we top up before posting,
299
+ // produces land in the worker queue ahead of setSource and the
300
+ // worker renders them with the previous currentSource.
301
+ this.#invalidate(clamped);
302
+ const resolved = absolutizeAssetUrls(source);
303
+ this.#lastResolvedSource = resolved;
304
+ // Interactive mode (during a drag/resize): skip re-scheduling audio
305
+ // on every dispatch. The user isn't hearing playback while editing,
306
+ // and the scheduler does enough main-thread work per call to add
307
+ // perceptible lag at 60Hz. `setInteractive(false)` runs one final
308
+ // scheduler.setSource so audio re-syncs to the canonical source.
309
+ if (this.#interactive) {
310
+ this.#postToWorker({
311
+ type: 'setSource',
312
+ source: stripAudioForWorker(resolved),
313
+ sequenceId: this.#sequenceId,
314
+ });
315
+ this.#topUpBuffer();
316
+ return;
317
+ }
318
+ // Re-decode audio + tell worker about new source in parallel.
319
+ const audioPromise = this.#scheduler.setSource(resolved);
320
+ this.#postToWorker({
321
+ type: 'setSource',
322
+ source: stripAudioForWorker(resolved),
323
+ sequenceId: this.#sequenceId,
324
+ });
325
+ this.#topUpBuffer();
326
+ await audioPromise;
327
+ }
328
+ /**
329
+ * Toggle interactive (edit) mode. While `true`, the engine produces
330
+ * only one frame at the current playhead on each `setSource` —
331
+ * no look-ahead buffering. Use during drag/resize/rotate operations
332
+ * so live mutation dispatches don't queue dozens of doomed frames.
333
+ * Set back to `false` to resume normal buffered playback.
334
+ */
335
+ /**
336
+ * Apply partial updates to existing elements without re-sending the
337
+ * full source. The fast path for live drag / resize / rotate
338
+ * dispatches:
339
+ *
340
+ * - Worker applies patches via Object.assign to its in-memory source
341
+ * - No `runtime.preload()` (assets unchanged)
342
+ * - No audio scheduling
343
+ * - Tiny message payload (~50 bytes vs full source's KBs)
344
+ *
345
+ * Caller passes both the new source (so this.source stays in sync as
346
+ * the diff baseline for the next tick) and the patches to send to
347
+ * the worker. We never mutate — the source from the store is
348
+ * Immer-frozen and that's fine; we just swap the reference.
349
+ *
350
+ * Patches should be for visual/spatial fields only — time/track/
351
+ * duration changes affect frame-buffer alignment and should go
352
+ * through `setSource`. The Editor's source-diff function decides
353
+ * which path to use.
354
+ */
355
+ patchElements(nextSource, patches) {
356
+ if (this.#disposed)
357
+ return;
358
+ if (patches.length === 0)
359
+ return;
360
+ // Replace the reference — no in-place mutation. The new source is
361
+ // already the post-patch state from the store.
362
+ this.source = nextSource;
363
+ // Critical ordering: invalidate (bump seq + clear buffer) →
364
+ // post the patch → THEN top up. Worker processes messages in
365
+ // arrival order; if produces are posted before the patch, they
366
+ // render with the stale currentSource and we get a frame with
367
+ // pre-patch values for the first cycle after every patch. Most
368
+ // visible on undo — see `#invalidate` for the longer note.
369
+ this.#invalidate(this.#clock.now());
370
+ this.#postToWorker({
371
+ type: 'patchElements',
372
+ patches,
373
+ sequenceId: this.#sequenceId,
374
+ });
375
+ this.#topUpBuffer();
376
+ }
377
+ /** When true, playback wraps to 0 at the end instead of pausing. */
378
+ /**
379
+ * PREVIEW-ONLY per-element audio gains (the mixer's mute/solo).
380
+ * Multiplies on top of authored volume; never written into the
381
+ * Source — the lens rule's engine half. Unlisted ids reset to 1.
382
+ */
383
+ setPreviewGains(gains) {
384
+ if (this.#disposed)
385
+ return;
386
+ this.#scheduler?.setPreviewGains(gains);
387
+ }
388
+ /** Mixer meter levels (0..1 peaks); zeros before audio init. */
389
+ getAudioLevels() {
390
+ return this.#scheduler?.getLevels() ?? { master: { l: 0, r: 0 }, elements: {} };
391
+ }
392
+ setLoop(value) {
393
+ this.#loop = value;
394
+ }
395
+ /**
396
+ * Current transport time, in seconds. Reads the clock directly —
397
+ * advances continuously during playback regardless of how often
398
+ * onTime emits. UI elements that need 60Hz-smooth motion (the
399
+ * timeline playhead) read this on every rAF tick instead of
400
+ * subscribing to the store's playback.time (which is throttled
401
+ * to 100ms).
402
+ */
403
+ get currentTime() {
404
+ if (this.#disposed || !this.#clock)
405
+ return 0;
406
+ return this.#clock.now();
407
+ }
408
+ /**
409
+ * Snapshot of presenter / worker / buffer health for the in-app
410
+ * diagnostic HUD. All values are derived from sliding windows the
411
+ * engine already maintains for instrumentation; this is a cheap
412
+ * read intended to be polled at ~4Hz by a UI overlay.
413
+ */
414
+ getStats() {
415
+ const now = typeof performance !== 'undefined' ? performance.now() : 0;
416
+ // Presented FPS — count present timestamps in the last second.
417
+ let fps = 0;
418
+ for (let i = this.#presentTimes.length - 1; i >= 0; i--) {
419
+ if (now - this.#presentTimes[i] > 1000)
420
+ break;
421
+ fps++;
422
+ }
423
+ // Gap stats over the same ~1s window.
424
+ let gapSum = 0;
425
+ let gapCount = 0;
426
+ let gapMax = 0;
427
+ for (let i = this.#presentTimes.length - 1; i > 0; i--) {
428
+ const a = this.#presentTimes[i - 1];
429
+ const b = this.#presentTimes[i];
430
+ if (now - b > 1000)
431
+ break;
432
+ const gap = b - a;
433
+ gapSum += gap;
434
+ gapCount += 1;
435
+ if (gap > gapMax)
436
+ gapMax = gap;
437
+ }
438
+ const frameGapMs = gapCount > 0 ? gapSum / gapCount : 0;
439
+ // Worker latency — average over our ring buffer.
440
+ const avg = (xs) => {
441
+ if (xs.length === 0)
442
+ return 0;
443
+ let s = 0;
444
+ for (const x of xs)
445
+ s += x;
446
+ return s / xs.length;
447
+ };
448
+ return {
449
+ fps,
450
+ targetFps: this.#frameRate,
451
+ bufferAheadSec: this.#disposed
452
+ ? 0
453
+ : this.#buffer.aheadSec(this.#clock.now()),
454
+ frameGapMs,
455
+ frameGapMaxMs: gapMax,
456
+ workerLatencyMs: avg(this.#workerLatencies),
457
+ renderMs: avg(this.#workerFrameMs),
458
+ prepareMs: avg(this.#workerPrepareMs),
459
+ blurSamples: avg(this.#workerBlurSamples),
460
+ videoFrameMs: avg(this.#workerVideoFrameMs),
461
+ workerTotalMs: avg(this.#workerTotalMs),
462
+ queueLagMs: avg(this.#queueLagMs),
463
+ drawImageMs: avg(this.#drawImageMs),
464
+ starvationCount: this.#starvationCount,
465
+ inflight: this.#inflight,
466
+ };
467
+ }
468
+ /** Zero out starvation count + latency windows. Useful at play start. */
469
+ resetStats() {
470
+ this.#starvationCount = 0;
471
+ this.#workerLatencies.length = 0;
472
+ this.#presentTimes.length = 0;
473
+ this.#workerFrameMs.length = 0;
474
+ this.#workerPrepareMs.length = 0;
475
+ this.#workerBlurSamples.length = 0;
476
+ this.#workerVideoFrameMs.length = 0;
477
+ this.#workerTotalMs.length = 0;
478
+ this.#queueLagMs.length = 0;
479
+ this.#drawImageMs.length = 0;
480
+ }
481
+ setInteractive(value) {
482
+ if (this.#interactive === value)
483
+ return;
484
+ this.#interactive = value;
485
+ if (!value) {
486
+ // Resync the audio scheduler with the canonical source — we
487
+ // skipped its updates during interactive mode.
488
+ void this.#scheduler.setSource(absolutizeAssetUrls(this.source));
489
+ // Resume look-ahead buffering.
490
+ this.#topUpBuffer();
491
+ }
492
+ }
493
+ // ── Subscriptions ───────────────────────────────────────────────────
494
+ onTime(listener) {
495
+ this.#listeners.time.add(listener);
496
+ return () => {
497
+ this.#listeners.time.delete(listener);
498
+ };
499
+ }
500
+ onPlayingChange(listener) {
501
+ this.#listeners.playing.add(listener);
502
+ return () => {
503
+ this.#listeners.playing.delete(listener);
504
+ };
505
+ }
506
+ onError(listener) {
507
+ this.#listeners.error.add(listener);
508
+ return () => {
509
+ this.#listeners.error.delete(listener);
510
+ };
511
+ }
512
+ onBufferStatus(listener) {
513
+ this.#listeners.buffer.add(listener);
514
+ return () => {
515
+ this.#listeners.buffer.delete(listener);
516
+ };
517
+ }
518
+ // ── Cleanup ─────────────────────────────────────────────────────────
519
+ dispose() {
520
+ if (this.#disposed)
521
+ return;
522
+ this.#disposed = true;
523
+ if (this.#finalRefineTimer !== null)
524
+ clearTimeout(this.#finalRefineTimer);
525
+ if (this.#rafHandle)
526
+ cancelAnimationFrame(this.#rafHandle);
527
+ if (typeof document !== 'undefined') {
528
+ document.removeEventListener('visibilitychange', this.#onVisibilityChange);
529
+ }
530
+ this.#worker?.removeEventListener('message', this.#onWorkerMessage);
531
+ this.#worker?.removeEventListener('error', this.#onWorkerError);
532
+ this.#disposeVideoPump();
533
+ this.#postToWorker({ type: 'dispose' });
534
+ this.#worker?.terminate();
535
+ this.#scheduler?.dispose();
536
+ this.#clock?.dispose();
537
+ this.#buffer?.dispose();
538
+ if (this.#ownsAudioContext) {
539
+ void this.#audioContext.close();
540
+ }
541
+ for (const pending of this.#stillRequests.values()) {
542
+ pending.reject(new Error('PlaybackEngine disposed'));
543
+ }
544
+ this.#stillRequests.clear();
545
+ for (const set of Object.values(this.#listeners))
546
+ set.clear();
547
+ }
548
+ // ── Internal: init + main loop ──────────────────────────────────────
549
+ async #init(backend) {
550
+ this.#clock = new TransportClock(this.#audioContext, 0);
551
+ this.#scheduler = new AudioScheduler(this.#audioContext, this.#clock);
552
+ const targetFrames = Math.ceil(this.#bufferTargetSec * this.#frameRate);
553
+ const capacity = Math.max(4, Math.ceil(targetFrames * BUFFER_CAPACITY_HEADROOM));
554
+ this.#buffer = new FrameBuffer(capacity);
555
+ this.#buffer.setSequenceId(this.#sequenceId);
556
+ this.#worker = new Worker(new URL('./worker.js', import.meta.url), {
557
+ type: 'module',
558
+ });
559
+ this.#worker.addEventListener('message', this.#onWorkerMessage);
560
+ this.#worker.addEventListener('error', this.#onWorkerError);
561
+ const workerReady = new Promise((resolve, reject) => {
562
+ const off = () => {
563
+ this.#worker.removeEventListener('message', onReady);
564
+ this.#worker.removeEventListener('error', onWorkerInitError);
565
+ };
566
+ const onReady = (event) => {
567
+ const data = event.data;
568
+ if (data.type === 'ready') {
569
+ off();
570
+ this.#workerReady = true;
571
+ // Workers don't share performance.timeOrigin with their
572
+ // parent. Capture the offset now so cross-thread time
573
+ // comparisons (queue lag) are valid.
574
+ this.#workerClockOffset = data.timeOrigin - performance.timeOrigin;
575
+ resolve();
576
+ }
577
+ else if (data.type === 'error') {
578
+ off();
579
+ reject(new Error(`Worker init failed: ${data.message}`));
580
+ }
581
+ };
582
+ const onWorkerInitError = (event) => {
583
+ off();
584
+ reject(new Error(`Worker init error: ${event.message}`));
585
+ };
586
+ this.#worker.addEventListener('message', onReady);
587
+ this.#worker.addEventListener('error', onWorkerInitError);
588
+ });
589
+ const resolved = absolutizeAssetUrls(this.source);
590
+ this.#lastResolvedSource = resolved;
591
+ this.#postToWorker({
592
+ type: 'init',
593
+ source: stripAudioForWorker(resolved),
594
+ backend,
595
+ });
596
+ // Wait for worker to come online + start audio decode in parallel.
597
+ const audioPromise = this.#scheduler.setSource(resolved);
598
+ await Promise.all([workerReady, audioPromise]);
599
+ // Prime the buffer + start the presenter loop. Wait for the first
600
+ // frame to land before resolving so play() can assume there's
601
+ // something on screen.
602
+ const firstFrame = new Promise((resolve) => {
603
+ this.#firstFrameResolve = resolve;
604
+ });
605
+ this.#startPresenter();
606
+ this.#topUpBuffer();
607
+ document.addEventListener('visibilitychange', this.#onVisibilityChange);
608
+ await firstFrame;
609
+ }
610
+ /**
611
+ * When the tab is hidden, stop topping up the buffer (no point
612
+ * rendering frames the user can't see). When it comes back, resume.
613
+ * RAF naturally throttles to ~1Hz when hidden; the presenter loop
614
+ * is harmless to leave running.
615
+ */
616
+ #onVisibilityChange = () => {
617
+ if (!document.hidden)
618
+ this.#topUpBuffer();
619
+ };
620
+ #startPresenter() {
621
+ const tick = () => {
622
+ if (this.#disposed)
623
+ return;
624
+ let now = this.#clock.now();
625
+ // End-of-source: loop or pause depending on #loop.
626
+ // - loop on: seek to 0 and keep playing (use #invalidate +
627
+ // #topUpBuffer so the buffer doesn't drop the wrap-around
628
+ // frames as stale, and audio re-syncs to t=0).
629
+ // - loop off: pause and clamp to duration. Without this the
630
+ // clock kept advancing forever, the playhead drifted past
631
+ // the last frame, and topUpBuffer refused to produce
632
+ // anything beyond `duration` — so the canvas froze on the
633
+ // last frame while the timeline kept incrementing.
634
+ if (this.#clock.playing && now >= this.duration) {
635
+ if (this.#loop && this.duration > 0) {
636
+ this.#clock.seek(0);
637
+ this.#invalidate(0);
638
+ this.#topUpBuffer();
639
+ now = 0;
640
+ this.#lastTimeEmit = performance.now();
641
+ this.#emitTime(now);
642
+ }
643
+ else {
644
+ this.#clock.pause();
645
+ this.#clock.seek(this.duration);
646
+ now = this.duration;
647
+ this.#emitPlayingChange(false);
648
+ this.#lastTimeEmit = performance.now();
649
+ this.#emitTime(now);
650
+ }
651
+ }
652
+ // Present the latest frame at or before `now`.
653
+ const frame = this.#buffer.peekAt(now);
654
+ if (frame) {
655
+ const drawStart = typeof performance !== 'undefined' ? performance.now() : 0;
656
+ this.#displayCtx.drawImage(frame, 0, 0, this.#displayCanvas.width, this.#displayCanvas.height);
657
+ if (typeof performance !== 'undefined') {
658
+ this.#drawImageMs.push(performance.now() - drawStart);
659
+ if (this.#drawImageMs.length > 60)
660
+ this.#drawImageMs.shift();
661
+ }
662
+ if (typeof performance !== 'undefined') {
663
+ // Record presenter-tick timestamp for FPS + jitter stats.
664
+ // Trim to a ~2s window (covers our worst-case sample rate).
665
+ const ts = performance.now();
666
+ this.#presentTimes.push(ts);
667
+ while (this.#presentTimes.length > 0 &&
668
+ ts - this.#presentTimes[0] > 2000) {
669
+ this.#presentTimes.shift();
670
+ }
671
+ try {
672
+ performance.mark('ck.present', { detail: { time: now } });
673
+ }
674
+ catch {
675
+ /* see ck.produce */
676
+ }
677
+ }
678
+ }
679
+ else if (this.#clock.playing) {
680
+ // Buffer was empty while we were trying to play — visible
681
+ // stutter. Bumps the starvation counter so the HUD can show it.
682
+ this.#starvationCount += 1;
683
+ }
684
+ // Prune frames the playhead has already passed.
685
+ this.#buffer.prune(now);
686
+ // Keep the main-thread video decoders aligned with the clock.
687
+ this.#syncVideos(now);
688
+ // Top up production.
689
+ this.#topUpBuffer();
690
+ // Throttled UI events.
691
+ const nowMs = performance.now();
692
+ if (nowMs - this.#lastTimeEmit >= this.#timeUpdateIntervalMs) {
693
+ this.#lastTimeEmit = nowMs;
694
+ this.#emitTime(now);
695
+ }
696
+ this.#emitBufferStatusIfChanged(now);
697
+ this.#rafHandle = requestAnimationFrame(tick);
698
+ };
699
+ this.#rafHandle = requestAnimationFrame(tick);
700
+ }
701
+ #topUpBuffer() {
702
+ if (!this.#workerReady || this.#disposed)
703
+ return;
704
+ // Don't render frames the user can't see.
705
+ if (typeof document !== 'undefined' && document.hidden)
706
+ return;
707
+ const frameInterval = 1 / this.#frameRate;
708
+ const playhead = this.#clock.now();
709
+ // Defense against a poisoned cadence (frame_rate ≤ 0 ever slipped
710
+ // in → ±Infinity/NaN request times wedge production permanently;
711
+ // NaN also defeats the `<` reset below). Sanitized #frameRate
712
+ // should make this unreachable, but a NaN here is unrecoverable
713
+ // without it.
714
+ if (!Number.isFinite(frameInterval) || frameInterval <= 0)
715
+ return;
716
+ if (!Number.isFinite(this.#highestRequestedTime)) {
717
+ this.#highestRequestedTime = playhead - frameInterval;
718
+ }
719
+ // Don't request frames the playhead has already moved past.
720
+ if (this.#highestRequestedTime < playhead) {
721
+ this.#highestRequestedTime = playhead - frameInterval;
722
+ }
723
+ // Interactive mode is restrictive on two axes:
724
+ // - effectiveBufferSec = 0 → no look-ahead (the next edit invalidates
725
+ // anyway)
726
+ // - effectiveMaxInflight = 1 → at most one produce in flight at a
727
+ // time. With cap > 1 the worker queue piles up during fast drags;
728
+ // each pending produce wastes ~10-30ms of render time on stale
729
+ // state. With cap = 1 the worker always renders the latest
730
+ // accumulated patches when it gets to the next produce, and the
731
+ // "final frame after mouseup" lands on the very next render
732
+ // cycle (~33ms), not after 4 stale renders (~130ms).
733
+ // Never request more look-ahead than the buffer can HOLD. The
734
+ // buffer is sized at init from the INITIAL frame rate; raising
735
+ // frame_rate past that sizing (>48fps with the 3s/30fps default)
736
+ // made every push evict the frame the presenter needed next —
737
+ // playback froze while the clock ran (Ian's 50/60fps caption bug).
738
+ const capacityAheadSec = (this.#buffer.capacity - MAX_INFLIGHT_PRODUCE) * frameInterval;
739
+ const effectiveBufferSec = this.#interactive
740
+ ? 0
741
+ : Math.min(this.#bufferTargetSec, capacityAheadSec);
742
+ const effectiveMaxInflight = this.#interactive ? 1 : MAX_INFLIGHT_PRODUCE;
743
+ while (this.#inflight < effectiveMaxInflight) {
744
+ const nextTime = this.#highestRequestedTime + frameInterval;
745
+ if (nextTime > this.duration)
746
+ return;
747
+ if (nextTime - playhead > effectiveBufferSec)
748
+ return;
749
+ // Stats + DevTools mark — record when the produce was posted
750
+ // so we can compute round-trip latency when the frame comes
751
+ // back. The mark gives DevTools Performance a labeled event.
752
+ if (typeof performance !== 'undefined') {
753
+ this.#pendingProduceStart.set(nextTime, performance.now());
754
+ try {
755
+ performance.mark('ck.produce', {
756
+ detail: { time: nextTime, seq: this.#sequenceId },
757
+ });
758
+ }
759
+ catch {
760
+ /* older browsers may not accept the detail arg */
761
+ }
762
+ }
763
+ this.#postToWorker({
764
+ type: 'produce',
765
+ time: nextTime,
766
+ sequenceId: this.#sequenceId,
767
+ });
768
+ this.#highestRequestedTime = nextTime;
769
+ this.#inflight += 1;
770
+ }
771
+ }
772
+ #invalidateAfterSeek(newTime) {
773
+ this.#invalidate(newTime);
774
+ this.#topUpBuffer();
775
+ this.#scheduleFinalRefine();
776
+ }
777
+ /**
778
+ * While paused, once the playhead has settled for a beat, request
779
+ * ONE export-quality frame (motion-blurred supersampling) at the
780
+ * playhead. The realtime frame shows instantly during scrubbing;
781
+ * the refined frame lands ~120ms after the drag stops and replaces
782
+ * it (the buffer keeps the later same-time frame). No-op when
783
+ * playing or when the source has no motion_blur.
784
+ */
785
+ #scheduleFinalRefine() {
786
+ if (this.#finalRefineTimer !== null) {
787
+ clearTimeout(this.#finalRefineTimer);
788
+ this.#finalRefineTimer = null;
789
+ }
790
+ const mb = this.source?.motion_blur;
791
+ const samples = mb && typeof mb.samples === 'number' ? mb.samples : mb ? 8 : 0;
792
+ if (!mb || samples <= 1)
793
+ return;
794
+ if (this.#clock?.playing)
795
+ return;
796
+ this.#finalRefineTimer = setTimeout(() => {
797
+ this.#finalRefineTimer = null;
798
+ if (this.#disposed || !this.#workerReady)
799
+ return;
800
+ if (this.#clock?.playing)
801
+ return;
802
+ this.#postToWorker({
803
+ type: 'produce',
804
+ time: this.#clock?.now() ?? 0,
805
+ sequenceId: this.#sequenceId,
806
+ quality: 'final',
807
+ });
808
+ this.#inflight += 1;
809
+ }, 120);
810
+ }
811
+ /**
812
+ * Bump the sequenceId + clear the buffer + reset `highestRequestedTime`,
813
+ * WITHOUT topping up. Use when you need to post a `setSource` or
814
+ * `patchElements` message to the worker BEFORE the produce messages
815
+ * that follow — otherwise the worker processes the produces first
816
+ * (synchronous, using its pre-update source) and we get a frame
817
+ * rendered with stale data sitting in the buffer.
818
+ *
819
+ * This bit users on undo: bounding box (read from the React store)
820
+ * showed the reverted position, but the canvas (rendered by the
821
+ * worker) showed the pre-undo position, because the first produce
822
+ * after the patch ran before the patch was applied.
823
+ */
824
+ #invalidate(newTime) {
825
+ this.#sequenceId += 1;
826
+ this.#buffer.setSequenceId(this.#sequenceId);
827
+ this.#highestRequestedTime = newTime - 1 / this.#frameRate;
828
+ // inflight is NOT reset — pending responses arrive with old IDs and
829
+ // get closed by the buffer's sequenceId check.
830
+ }
831
+ // ── Worker message handlers ─────────────────────────────────────────
832
+ #onWorkerMessage = (event) => {
833
+ const msg = event.data;
834
+ switch (msg.type) {
835
+ case 'ready':
836
+ // Handled by the one-shot listener in #init().
837
+ return;
838
+ case 'frame': {
839
+ this.#inflight = Math.max(0, this.#inflight - 1);
840
+ // Latency tracking — match against the pending request keyed
841
+ // by the frame's composition time. Keep up to 60 samples
842
+ // (~2s of frames at 30fps); older entries roll off.
843
+ if (typeof performance !== 'undefined') {
844
+ const start = this.#pendingProduceStart.get(msg.time);
845
+ if (start !== undefined) {
846
+ this.#pendingProduceStart.delete(msg.time);
847
+ const lat = performance.now() - start;
848
+ this.#workerLatencies.push(lat);
849
+ if (this.#workerLatencies.length > 60)
850
+ this.#workerLatencies.shift();
851
+ }
852
+ // Worker-reported per-step durations for the HUD.
853
+ if (msg.timings) {
854
+ this.#workerFrameMs.push(msg.timings.frameMs);
855
+ if (this.#workerFrameMs.length > 60)
856
+ this.#workerFrameMs.shift();
857
+ this.#workerPrepareMs.push(msg.timings.prepareMs ?? 0);
858
+ if (this.#workerPrepareMs.length > 60)
859
+ this.#workerPrepareMs.shift();
860
+ this.#workerBlurSamples.push(msg.timings.blurSamples ?? 0);
861
+ if (this.#workerBlurSamples.length > 60)
862
+ this.#workerBlurSamples.shift();
863
+ this.#workerVideoFrameMs.push(msg.timings.videoFrameMs);
864
+ if (this.#workerVideoFrameMs.length > 60)
865
+ this.#workerVideoFrameMs.shift();
866
+ this.#workerTotalMs.push(msg.timings.workerTotalMs);
867
+ if (this.#workerTotalMs.length > 60)
868
+ this.#workerTotalMs.shift();
869
+ // sentAt is in worker's clock; convert into main's clock
870
+ // before subtracting. Without the offset this value
871
+ // shows the delta between navigation start and worker
872
+ // creation (often tens of seconds), not actual lag.
873
+ const sentAtMain = msg.timings.sentAt + this.#workerClockOffset;
874
+ const lag = performance.now() - sentAtMain;
875
+ this.#queueLagMs.push(lag);
876
+ if (this.#queueLagMs.length > 60)
877
+ this.#queueLagMs.shift();
878
+ }
879
+ try {
880
+ performance.mark('ck.frame', {
881
+ detail: { time: msg.time, seq: msg.sequenceId },
882
+ });
883
+ }
884
+ catch {
885
+ /* see ck.produce */
886
+ }
887
+ }
888
+ // Buffer's setSequenceId check handles stale frames — close + drop.
889
+ this.#buffer.push(msg.time, msg.frame, msg.sequenceId);
890
+ if (msg.sequenceId === this.#sequenceId && this.#firstFrameResolve) {
891
+ this.#firstFrameResolve();
892
+ this.#firstFrameResolve = null;
893
+ }
894
+ return;
895
+ }
896
+ case 'videoFallback': {
897
+ // Build (or tear down) the main-thread pump for exactly the
898
+ // URLs the worker couldn't self-decode.
899
+ if (msg.urls.length > 0) {
900
+ // eslint-disable-next-line no-console
901
+ console.info('[clipkit] worker could not self-decode these videos; using main-thread pump (live-sync preview):', msg.urls);
902
+ }
903
+ if (this.#lastResolvedSource) {
904
+ this.#buildVideoPump(this.#lastResolvedSource, new Set(msg.urls));
905
+ }
906
+ return;
907
+ }
908
+ case 'stillResult': {
909
+ const pending = this.#stillRequests.get(msg.requestId);
910
+ if (pending) {
911
+ this.#stillRequests.delete(msg.requestId);
912
+ pending.resolve(msg.bitmap);
913
+ }
914
+ else {
915
+ msg.bitmap.close();
916
+ }
917
+ return;
918
+ }
919
+ case 'stillError': {
920
+ const pending = this.#stillRequests.get(msg.requestId);
921
+ if (pending) {
922
+ this.#stillRequests.delete(msg.requestId);
923
+ pending.reject(new Error(`renderFrameAt: ${msg.message}`));
924
+ }
925
+ return;
926
+ }
927
+ case 'error':
928
+ this.#emitError(new Error(`Worker: ${msg.message}`));
929
+ return;
930
+ }
931
+ };
932
+ #onWorkerError = (event) => {
933
+ this.#emitError(new Error(`Worker error: ${event.message}`));
934
+ };
935
+ #postToWorker(message, transfer = []) {
936
+ if (this.#disposed && message.type !== 'dispose')
937
+ return;
938
+ this.#worker?.postMessage(message, transfer);
939
+ }
940
+ // ── Main-thread video pump ──────────────────────────────────────────
941
+ /**
942
+ * (Re)create the fallback decoding `<video>` elements for the given
943
+ * subset of video URLs (those the worker can't self-decode). Each one
944
+ * pumps decoded ImageBitmaps into the worker via
945
+ * requestVideoFrameCallback (33ms polling fallback). While paused, a
946
+ * pumped frame triggers a one-shot re-render so scrubbing shows the
947
+ * right video frame.
948
+ */
949
+ #buildVideoPump(source, only) {
950
+ this.#disposeVideoPump();
951
+ if (typeof document === 'undefined')
952
+ return;
953
+ if (only.size === 0)
954
+ return;
955
+ const specs = new Map();
956
+ collectVideoSpecs(source.elements, 0, computeDuration(source), specs);
957
+ for (const url of specs.keys()) {
958
+ if (!only.has(url))
959
+ specs.delete(url);
960
+ }
961
+ for (const [url, spec] of specs) {
962
+ const video = document.createElement('video');
963
+ video.crossOrigin = 'anonymous';
964
+ video.muted = true;
965
+ video.playsInline = true;
966
+ video.preload = 'auto';
967
+ video.loop = spec.loop;
968
+ video.src = url;
969
+ let cancelled = false;
970
+ const schedule = () => {
971
+ if (cancelled || this.#disposed)
972
+ return;
973
+ const rvfc = video.requestVideoFrameCallback;
974
+ if (typeof rvfc === 'function') {
975
+ rvfc.call(video, pushFrame);
976
+ }
977
+ else {
978
+ setTimeout(pushFrame, 33);
979
+ }
980
+ };
981
+ const pushFrame = () => {
982
+ if (cancelled || this.#disposed)
983
+ return;
984
+ if (this.#workerReady && video.readyState >= 2) {
985
+ void createImageBitmap(video)
986
+ .then((bitmap) => {
987
+ if (cancelled || this.#disposed) {
988
+ bitmap.close();
989
+ return;
990
+ }
991
+ this.#postToWorker({ type: 'videoFrame', url, bitmap }, [bitmap]);
992
+ // Paused scrub: any frame already in the buffer was
993
+ // rendered with the previous bitmap. Re-render once at
994
+ // the playhead so the canvas shows the seeked frame.
995
+ // (rVFC only fires on real new frames, so no feedback
996
+ // loop while idle.)
997
+ if (this.#clock && !this.#clock.playing) {
998
+ this.#invalidate(this.#clock.now());
999
+ this.#topUpBuffer();
1000
+ }
1001
+ })
1002
+ .catch(() => {
1003
+ /* decode hiccup — next callback retries */
1004
+ });
1005
+ }
1006
+ schedule();
1007
+ };
1008
+ video.addEventListener('loadeddata', pushFrame, { once: true });
1009
+ this.#videoPump.set(url, {
1010
+ ...spec,
1011
+ url,
1012
+ video,
1013
+ cancel: () => {
1014
+ cancelled = true;
1015
+ video.pause();
1016
+ video.removeAttribute('src');
1017
+ video.load();
1018
+ },
1019
+ });
1020
+ }
1021
+ }
1022
+ /**
1023
+ * Align each decoder with the transport clock: play/pause to match,
1024
+ * map composition time → media time through the element's start +
1025
+ * trim + playback_rate (shared mapToMediaTime), and correct drift
1026
+ * beyond 0.3s (playing) / 0.05s (scrubbing).
1027
+ */
1028
+ #syncVideos(now) {
1029
+ if (this.#videoPump.size === 0)
1030
+ return;
1031
+ for (const entry of this.#videoPump.values()) {
1032
+ const { video } = entry;
1033
+ if (video.readyState < 1 /* HAVE_METADATA */)
1034
+ continue;
1035
+ const active = now >= entry.elStart && now <= entry.elStart + entry.elDuration;
1036
+ const mediaDur = Number.isFinite(video.duration) ? video.duration : 0;
1037
+ const desired = mapToMediaTime(now, {
1038
+ elementStart: entry.elStart,
1039
+ trimStart: entry.trimStart,
1040
+ trimDuration: entry.trimDuration,
1041
+ rate: entry.rate,
1042
+ loop: entry.loop,
1043
+ timeRemap: entry.timeRemap,
1044
+ }, mediaDur);
1045
+ if (this.#clock.playing && active) {
1046
+ video.playbackRate = entry.rate;
1047
+ if (video.paused) {
1048
+ void video.play().catch(() => {
1049
+ /* autoplay rejection — muted videos shouldn't hit this */
1050
+ });
1051
+ }
1052
+ if (Math.abs(video.currentTime - desired) > 0.3) {
1053
+ video.currentTime = desired;
1054
+ }
1055
+ }
1056
+ else {
1057
+ if (!video.paused)
1058
+ video.pause();
1059
+ if (Math.abs(video.currentTime - desired) > 0.05) {
1060
+ video.currentTime = desired;
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ #disposeVideoPump() {
1066
+ for (const entry of this.#videoPump.values())
1067
+ entry.cancel();
1068
+ this.#videoPump.clear();
1069
+ }
1070
+ // ── Emit helpers ────────────────────────────────────────────────────
1071
+ #emitTime(t) {
1072
+ for (const listener of this.#listeners.time) {
1073
+ try {
1074
+ listener(t);
1075
+ }
1076
+ catch (err) {
1077
+ // eslint-disable-next-line no-console
1078
+ console.error('[PlaybackEngine] onTime listener threw:', err);
1079
+ }
1080
+ }
1081
+ }
1082
+ #emitPlayingChange(playing) {
1083
+ for (const listener of this.#listeners.playing) {
1084
+ try {
1085
+ listener(playing);
1086
+ }
1087
+ catch (err) {
1088
+ // eslint-disable-next-line no-console
1089
+ console.error('[PlaybackEngine] onPlayingChange listener threw:', err);
1090
+ }
1091
+ }
1092
+ }
1093
+ #emitError(error) {
1094
+ for (const listener of this.#listeners.error) {
1095
+ try {
1096
+ listener(error);
1097
+ }
1098
+ catch (err) {
1099
+ // eslint-disable-next-line no-console
1100
+ console.error('[PlaybackEngine] onError listener threw:', err);
1101
+ }
1102
+ }
1103
+ }
1104
+ #emitBufferStatusIfChanged(now) {
1105
+ const ahead = this.#buffer.aheadSec(now);
1106
+ const starved = this.#buffer.peekAt(now) === null;
1107
+ if (starved === this.#lastBufferStarved)
1108
+ return;
1109
+ this.#lastBufferStarved = starved;
1110
+ const status = { ahead, starved };
1111
+ for (const listener of this.#listeners.buffer) {
1112
+ try {
1113
+ listener(status);
1114
+ }
1115
+ catch (err) {
1116
+ // eslint-disable-next-line no-console
1117
+ console.error('[PlaybackEngine] onBufferStatus listener threw:', err);
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ /**
1123
+ * Collect one pump spec per unique video URL. Groups offset their
1124
+ * children's times. When the same URL appears in several elements, the
1125
+ * first occurrence's timing wins — a v1 simplification (one decoder per
1126
+ * URL; per-element media-time divergence needs per-element decoders).
1127
+ */
1128
+ function collectVideoSpecs(elements, timeOffset, parentDuration, out) {
1129
+ for (const el of elements) {
1130
+ const localStart = toNum(el.time, 0);
1131
+ const start = timeOffset + localStart;
1132
+ const duration = toNum(el.duration, Math.max(0, parentDuration - localStart));
1133
+ if (el.type === 'video' && typeof el.source === 'string' && el.source) {
1134
+ if (!out.has(el.source)) {
1135
+ out.set(el.source, {
1136
+ elStart: start,
1137
+ trimStart: toNum(el.trim_start, 0),
1138
+ trimDuration: trimDurationOf(el.trim_duration),
1139
+ elDuration: duration,
1140
+ rate: rateOf(el.playback_rate),
1141
+ loop: el.loop === true,
1142
+ timeRemap: timeRemapOf(el.time_remap),
1143
+ });
1144
+ }
1145
+ }
1146
+ else if (el.type === 'group') {
1147
+ collectVideoSpecs(el.elements, start, duration, out);
1148
+ }
1149
+ }
1150
+ }
1151
+ function toNum(v, fallback) {
1152
+ if (typeof v === 'number' && Number.isFinite(v))
1153
+ return v;
1154
+ if (typeof v === 'string') {
1155
+ const n = parseFloat(v);
1156
+ if (Number.isFinite(n))
1157
+ return n;
1158
+ }
1159
+ return fallback;
1160
+ }
1161
+ function computeDuration(source) {
1162
+ if (typeof source.duration === 'number')
1163
+ return source.duration;
1164
+ let max = 0;
1165
+ for (const el of source.elements) {
1166
+ const elTime = typeof el.time === 'number' ? el.time : 0;
1167
+ const elDuration = typeof el.duration === 'number' ? el.duration : 0;
1168
+ if (elTime + elDuration > max)
1169
+ max = elTime + elDuration;
1170
+ }
1171
+ return max;
1172
+ }
1173
+ /**
1174
+ * Worker contexts can't resolve relative URLs (`/mux-audio.mp3`,
1175
+ * `./logo.png`, `images/foo.jpg`) — `self.location` doesn't carry a
1176
+ * useful base for asset paths. Main-thread code resolves against
1177
+ * `window.location` automatically; the worker doesn't get that for free.
1178
+ *
1179
+ * Before handing a Source to the worker (or to the main-thread audio
1180
+ * scheduler, for symmetry), resolve every `source: string` field to an
1181
+ * absolute URL. Recurses into compositions. Returns a fresh Source so
1182
+ * the consumer's input isn't mutated; `engine.source` keeps the
1183
+ * original reference for round-trip integrity.
1184
+ */
1185
+ function absolutizeAssetUrls(source) {
1186
+ if (typeof window === 'undefined')
1187
+ return source;
1188
+ const origin = window.location.origin;
1189
+ const next = {
1190
+ ...source,
1191
+ elements: absolutizeElements(source.elements, origin),
1192
+ };
1193
+ // Source.fonts srcs are typically root-relative (`/snapshot-fonts/...`).
1194
+ // The worker's own location is the Next.js chunk URL, not the page —
1195
+ // so its relative-URL resolution can drift and FontFace.load() then
1196
+ // fails with a network error. Resolving against the page origin here
1197
+ // means the worker only ever sees absolute URLs.
1198
+ if (source.fonts && source.fonts.length > 0) {
1199
+ next.fonts = source.fonts.map((f) => {
1200
+ if (/^[a-z][a-z0-9+.-]*:/i.test(f.src))
1201
+ return f;
1202
+ try {
1203
+ return { ...f, src: new URL(f.src, origin).href };
1204
+ }
1205
+ catch {
1206
+ return f;
1207
+ }
1208
+ });
1209
+ }
1210
+ return next;
1211
+ }
1212
+ function absolutizeElements(elements, origin) {
1213
+ return elements.map((el) => {
1214
+ let next = el;
1215
+ if ((el.type === 'video' || el.type === 'image' || el.type === 'audio') &&
1216
+ typeof el.source === 'string' &&
1217
+ !/^[a-z][a-z0-9+.-]*:/i.test(el.source)) {
1218
+ // No scheme — relative or root-relative. Resolve against origin.
1219
+ try {
1220
+ const absolute = new URL(el.source, origin).href;
1221
+ next = { ...el, source: absolute };
1222
+ }
1223
+ catch {
1224
+ // Unparseable — leave as-is and let the runtime / scheduler
1225
+ // surface the error normally.
1226
+ }
1227
+ }
1228
+ if (next.type === 'group') {
1229
+ next = { ...next, elements: absolutizeElements(next.elements, origin) };
1230
+ }
1231
+ return next;
1232
+ });
1233
+ }
1234
+ /**
1235
+ * The runtime's `preload()` decodes audio via `new AudioContext()` —
1236
+ * but `AudioContext` doesn't exist in workers. Audio elements have no
1237
+ * visual representation, so the worker doesn't need them at all; the
1238
+ * main-thread AudioScheduler handles playback independently.
1239
+ *
1240
+ * Strip `audio` elements (and recurse into compositions) before sending
1241
+ * the Source into the worker. Returns a fresh Source so the version
1242
+ * the AudioScheduler sees is unchanged.
1243
+ */
1244
+ function stripAudioForWorker(source) {
1245
+ return { ...source, elements: stripAudioFromElements(source.elements) };
1246
+ }
1247
+ function stripAudioFromElements(elements) {
1248
+ const out = [];
1249
+ for (const el of elements) {
1250
+ if (el.type === 'audio')
1251
+ continue;
1252
+ if (el.type === 'group') {
1253
+ out.push({ ...el, elements: stripAudioFromElements(el.elements) });
1254
+ }
1255
+ else {
1256
+ out.push(el);
1257
+ }
1258
+ }
1259
+ return out;
1260
+ }
1261
+ //# sourceMappingURL=engine.js.map