@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/LICENSE +201 -0
- package/README.md +42 -0
- package/dist/audio.d.ts +44 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +359 -0
- package/dist/audio.js.map +1 -0
- package/dist/buffer.d.ts +50 -0
- package/dist/buffer.d.ts.map +1 -0
- package/dist/buffer.js +137 -0
- package/dist/buffer.js.map +1 -0
- package/dist/clock.d.ts +63 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +161 -0
- package/dist/clock.js.map +1 -0
- package/dist/engine.d.ts +99 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1261 -0
- package/dist/engine.js.map +1 -0
- package/dist/frame-math.d.ts +10 -0
- package/dist/frame-math.d.ts.map +1 -0
- package/dist/frame-math.js +16 -0
- package/dist/frame-math.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/waveform.d.ts +25 -0
- package/dist/waveform.d.ts.map +1 -0
- package/dist/waveform.js +76 -0
- package/dist/waveform.js.map +1 -0
- package/dist/worker-protocol.d.ts +159 -0
- package/dist/worker-protocol.d.ts.map +1 -0
- package/dist/worker-protocol.js +24 -0
- package/dist/worker-protocol.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +336 -0
- package/dist/worker.js.map +1 -0
- package/package.json +36 -0
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
|