@cutcraft/player 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1698 @@
1
+ import { E as trackItems, K as ENVELOPE_STEP_S, L as placementSourceTime, M as frameIndex, _ as fitRect, j as keyframedValue, n as buildFramePlan, tt as PICK_EPS } from "./frame-plan-BNwblf1s.mjs";
2
+ import { capabilitiesOf, freezeHoldsOf, lookupAsset, rampAdvance, rampInverse, rampSpeed, resolveScene, speedKeysOf } from "@cutcraft/kit";
3
+ import { ALL_FORMATS, AudioBufferSink, BlobSource, CanvasSink, FilePathSource, Input, UrlSource, VideoSampleSink } from "mediabunny";
4
+ // src/model/audio-plan.ts
5
+ /** Every audio-bearing chunk that overlaps [from, to). */
6
+ function buildAudioPlan(state, from, to, resolveNested) {
7
+ const out = [];
8
+ const anySolo = state.tracks.some((t) => t.solo);
9
+ for (const track of state.tracks) {
10
+ if (track.hidden || track.muted || anySolo && !track.solo) continue;
11
+ for (const p of [...state.items.values()].filter((q) => q.track === track.id && q.parent !== null && q.kind !== "transition")) {
12
+ if (!capabilitiesOf(p.kind).audio) continue;
13
+ if (p.end <= from || p.start >= to) continue;
14
+ if (p.kind === "nested") {
15
+ out.push(...nestedOccurrences(state, p, track, from, to, resolveNested));
16
+ continue;
17
+ }
18
+ const src = p.source;
19
+ if (!src || src.out <= src.in) continue;
20
+ const asset = lookupAsset(state.manifest, src.asset);
21
+ if (!asset || asset.kind === "image" || asset.kind === "video" && !asset.audio) continue;
22
+ const reverse = p.props.reverse === true;
23
+ const envelope = buildGainEnvelope(p, track, state, p.start, p.end);
24
+ const children = p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0);
25
+ const pan = panEnvelope(p, children);
26
+ for (const chunk of chunksOf(p, src, reverse, speedKeysOf(children), freezeHoldsOf(children))) {
27
+ if (chunk.compEnd <= from || chunk.compStart >= to) continue;
28
+ out.push({
29
+ id: p.id,
30
+ asset: asset.id,
31
+ chunk: chunk.index,
32
+ compStart: chunk.compStart,
33
+ compEnd: chunk.compEnd,
34
+ bufferFrom: chunk.bufferFrom,
35
+ bufferTo: chunk.bufferTo,
36
+ speed: src.speed,
37
+ ...chunk.rate ? { rate: chunk.rate } : {},
38
+ gain: envelope,
39
+ ...pan ? { pan } : {},
40
+ ...reverse ? { reverse } : {}
41
+ });
42
+ }
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+ /**
48
+ * Pieces of the source range in source order: cut every `AUDIO_CHUNK_S` when
49
+ * long, and where each freeze hold starts, so a hold is a gap with no audio.
50
+ * A piece maps to composition time through the speed (or the speed-key ramp's
51
+ * inverse) plus the holds before it. A reversed placement plays its last
52
+ * piece first, so its range counts back from `src.out`.
53
+ */
54
+ function chunksOf(p, src, reverse, keys, holds) {
55
+ const length = src.out - src.in;
56
+ const ramp = keys.length > 0;
57
+ const advance = (u) => ramp ? rampAdvance(keys, u) : u * src.speed;
58
+ const inverse = (a) => ramp ? rampInverse(keys, a) : a / src.speed;
59
+ let before = 0;
60
+ const holdAt = holds.map((h) => {
61
+ const u = h.start - before;
62
+ before += h.duration;
63
+ return {
64
+ u,
65
+ duration: h.duration
66
+ };
67
+ });
68
+ const cuts = /* @__PURE__ */ new Set([0, length]);
69
+ if (length > 60) for (let s = 30; s < length; s += 30) cuts.add(s);
70
+ for (const { u } of holdAt) {
71
+ const a = Math.min(length, Math.max(0, advance(u)));
72
+ cuts.add(reverse ? length - a : a);
73
+ }
74
+ const sorted = [...cuts].sort((x, y) => x - y);
75
+ const chunks = [];
76
+ for (let i = 0; i < sorted.length - 1; i++) {
77
+ const s1 = sorted[i], s2 = sorted[i + 1];
78
+ if (s2 - s1 < 1e-9) continue;
79
+ const [u1, u2] = (reverse ? [length - s2, length - s1] : [s1, s2]).map(inverse);
80
+ const mid = (u1 + u2) / 2;
81
+ const offset = p.start + holdAt.reduce((n, h) => h.u <= mid ? n + h.duration : n, 0);
82
+ const compStart = round(offset + u1), compEnd = round(offset + u2);
83
+ const rate = ramp ? rateEnvelope(keys, u1, u2).map(([u, v]) => [round(offset + u), v]) : void 0;
84
+ chunks.push({
85
+ index: chunks.length,
86
+ compStart,
87
+ compEnd,
88
+ bufferFrom: src.in + s1,
89
+ bufferTo: src.in + s2,
90
+ ...rate ? { rate } : {}
91
+ });
92
+ }
93
+ return chunks;
94
+ }
95
+ /** Ramp speed over clip seconds [u1, u2] (holds taken out): the key times plus a fixed step inside ramps, as the gain envelope samples. */
96
+ function rateEnvelope(keys, u1, u2) {
97
+ const times = /* @__PURE__ */ new Set([u1, u2]);
98
+ for (let i = 0; i < keys.length; i++) {
99
+ const a = keys[i], b = keys[i + 1];
100
+ if (a.at > u1 && a.at < u2) times.add(a.at);
101
+ if (b && a.speed !== b.speed) {
102
+ for (let u = a.at + ENVELOPE_STEP_S; u < b.at; u += ENVELOPE_STEP_S) if (u > u1 && u < u2) times.add(u);
103
+ }
104
+ }
105
+ return [...times].sort((x, y) => x - y).map((u) => [u, round(rampSpeed(keys, u))]);
106
+ }
107
+ function nestedOccurrences(state, p, track, from, to, resolveNested) {
108
+ const inner = resolveNested?.(p);
109
+ const src = p.source;
110
+ if (!inner || !src) return [];
111
+ const toInner = (t) => src.in + (t - p.start) * src.speed;
112
+ const toOuter = (t) => p.start + (t - src.in) / src.speed;
113
+ const outerEnvelope = buildGainEnvelope(p, track, state, p.start, p.end);
114
+ return buildAudioPlan(inner, toInner(Math.max(from, p.start)), toInner(Math.min(to, p.end)), resolveNested).map((o) => {
115
+ const compStart = Math.max(p.start, toOuter(o.compStart));
116
+ const compEnd = Math.min(p.end, toOuter(o.compEnd));
117
+ const inner = o.gain.map(([t, v]) => [toOuter(t), v]);
118
+ return {
119
+ ...o,
120
+ id: `${p.id}/${o.id}`,
121
+ compStart,
122
+ compEnd,
123
+ speed: o.speed * src.speed,
124
+ ...o.rate ? { rate: o.rate.map(([t, v]) => [round(toOuter(t)), v * src.speed]) } : {},
125
+ gain: multiplyEnvelopes([inner, outerEnvelope], compStart, compEnd)
126
+ };
127
+ }).filter((o) => o.compEnd > o.compStart);
128
+ }
129
+ /**
130
+ * Gain over [winFrom, winTo] in composition seconds: volume × keyframed
131
+ * `audio.volume` × fades × automation × ducking. The factors are multiplied
132
+ * and sampled at their breakpoints plus a fixed step inside ramps, so the
133
+ * product stays close to piecewise linear.
134
+ */
135
+ function buildGainEnvelope(p, track, state, winFrom, winTo) {
136
+ const children = p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0);
137
+ const volume = typeof p.props.volume === "number" ? p.props.volume : 1;
138
+ const factors = [];
139
+ const breaks = /* @__PURE__ */ new Set([winFrom, winTo]);
140
+ const ramps = [];
141
+ const kfs = children.filter((c) => c.kind === "keyframe" && c.props.property === "audio.volume");
142
+ if (kfs.length > 0) {
143
+ factors.push((t) => Number(keyframedValue(children, "audio.volume", t - p.start, volume)));
144
+ const ats = kfs.map((k) => p.start + Number(k.props.at ?? 0)).sort((a, b) => a - b);
145
+ ats.forEach((at) => breaks.add(at));
146
+ for (let i = 0; i < ats.length - 1; i++) ramps.push([ats[i], ats[i + 1]]);
147
+ } else factors.push(() => volume);
148
+ const fade = p.props.fade;
149
+ if (fade?.in && fade.in > 0) {
150
+ const a = p.start, b = p.start + fade.in;
151
+ factors.push((t) => t <= a ? 0 : t >= b ? 1 : (t - a) / (b - a));
152
+ breaks.add(a);
153
+ breaks.add(b);
154
+ }
155
+ if (fade?.out && fade.out > 0) {
156
+ const a = p.end - fade.out, b = p.end;
157
+ factors.push((t) => t <= a ? 1 : t >= b ? 0 : (b - t) / (b - a));
158
+ breaks.add(a);
159
+ breaks.add(b);
160
+ }
161
+ const automation = p.props.automation?.volume;
162
+ if (automation && automation.length > 0) {
163
+ const pts = [...automation].sort((x, y) => x[0] - y[0]).map(([rel, v]) => [p.start + rel, v]);
164
+ factors.push((t) => interpolate(pts, t));
165
+ pts.forEach(([t]) => breaks.add(t));
166
+ }
167
+ const duck = p.props.duck;
168
+ if (duck) {
169
+ const attack = duck.attack ?? .1, release = duck.release ?? .3;
170
+ const intervals = duckIntervals(state, duck.under, p.id);
171
+ for (const [a, b] of intervals) {
172
+ breaks.add(a - attack);
173
+ breaks.add(a);
174
+ breaks.add(b);
175
+ breaks.add(b + release);
176
+ if (attack > 0) ramps.push([a - attack, a]);
177
+ if (release > 0) ramps.push([b, b + release]);
178
+ }
179
+ factors.push((t) => {
180
+ let f = 1;
181
+ for (const [a, b] of intervals) {
182
+ let g = 1;
183
+ if (t >= a && t <= b) g = duck.to;
184
+ else if (t < a && t > a - attack) g = 1 - (1 - duck.to) * ((t - (a - attack)) / attack);
185
+ else if (t > b && t < b + release) g = duck.to + (1 - duck.to) * ((t - b) / release);
186
+ f = Math.min(f, g);
187
+ }
188
+ return f;
189
+ });
190
+ }
191
+ const times = [...breaks].filter((t) => t >= winFrom && t <= winTo).sort((a, b) => a - b);
192
+ const sampled = new Set(times);
193
+ for (const [a, b] of ramps) for (let t = a + ENVELOPE_STEP_S; t < b; t += ENVELOPE_STEP_S) if (t > winFrom && t < winTo) sampled.add(round(t));
194
+ const value = (t) => factors.reduce((acc, f) => acc * f(t), 1);
195
+ return [...sampled].sort((a, b) => a - b).map((t) => [round(t), round(value(t))]);
196
+ }
197
+ /** Composition intervals where sound plays on the track `under` names by id, else on the tracks of that role; silent assets and muted clips do not duck. */
198
+ function duckIntervals(state, under, exceptId) {
199
+ const tracks = state.tracks.some((t) => t.id === under) ? [under] : state.tracks.filter((t) => t.role === under).map((t) => t.id);
200
+ const ranges = [...state.items.values()].filter((q) => q.track !== null && tracks.includes(q.track) && q.id !== exceptId && q.parent !== null && capabilitiesOf(q.kind).audio && hasSound(state, q)).filter((q) => q.end > q.start).map((q) => [q.start, q.end]).sort((a, b) => a[0] - b[0]);
201
+ const merged = [];
202
+ for (const r of ranges) {
203
+ const last = merged[merged.length - 1];
204
+ if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
205
+ else merged.push([r[0], r[1]]);
206
+ }
207
+ return merged;
208
+ }
209
+ function hasSound(state, p) {
210
+ if (p.kind === "nested") return true;
211
+ const asset = p.source ? lookupAsset(state.manifest, p.source.asset) : null;
212
+ return asset !== null && asset.kind !== "image" && (asset.kind === "audio" || asset.audio !== void 0) && p.props.volume !== 0;
213
+ }
214
+ /** Pan over the placement: its `audio.pan` keyframes (sampled inside each segment), else `automation.pan`; keyframes win when both are written. */
215
+ function panEnvelope(p, children) {
216
+ const ats = children.filter((c) => c.kind === "keyframe" && c.props.property === "audio.pan").map((k) => Number(k.props.at ?? 0)).sort((a, b) => a - b);
217
+ if (ats.length > 0) {
218
+ const rels = new Set(ats);
219
+ for (let i = 0; i < ats.length - 1; i++) for (let r = ats[i] + ENVELOPE_STEP_S; r < ats[i + 1]; r += ENVELOPE_STEP_S) rels.add(r);
220
+ return [...rels].sort((a, b) => a - b).map((rel) => [round(p.start + rel), round(Number(keyframedValue(children, "audio.pan", rel, 0)))]);
221
+ }
222
+ const automation = p.props.automation?.pan;
223
+ if (!automation || automation.length === 0) return void 0;
224
+ return [...automation].sort((x, y) => x[0] - y[0]).map(([rel, v]) => [round(p.start + rel), v]);
225
+ }
226
+ function interpolate(pts, t) {
227
+ const first = pts[0], last = pts[pts.length - 1];
228
+ if (t <= first[0]) return first[1];
229
+ if (t >= last[0]) return last[1];
230
+ for (let i = 0; i < pts.length - 1; i++) {
231
+ const a = pts[i], b = pts[i + 1];
232
+ if (t >= a[0] && t < b[0]) return b[0] === a[0] ? b[1] : a[1] + (b[1] - a[1]) * (t - a[0]) / (b[0] - a[0]);
233
+ }
234
+ return last[1];
235
+ }
236
+ function multiplyEnvelopes(envs, from, to) {
237
+ const times = /* @__PURE__ */ new Set([from, to]);
238
+ for (const e of envs) for (const [t] of e) if (t >= from && t <= to) times.add(t);
239
+ return [...times].sort((a, b) => a - b).map((t) => [round(t), round(envs.reduce((acc, e) => acc * (e.length ? interpolate(e, t) : 1), 1))]);
240
+ }
241
+ function round(v) {
242
+ return Math.round(v * 1e6) / 1e6;
243
+ }
244
+ // src/clock/clock.ts
245
+ var ManualClock = class {
246
+ t = 0;
247
+ now() {
248
+ return this.t;
249
+ }
250
+ advance(dt) {
251
+ this.t += dt;
252
+ }
253
+ set(t) {
254
+ this.t = t;
255
+ }
256
+ };
257
+ var PerformanceClock = class {
258
+ now() {
259
+ return performance.now() / 1e3;
260
+ }
261
+ };
262
+ // src/media/resolver.ts
263
+ /** A resolver over a map of asset id → Blob or URL. */
264
+ function fromMap(map) {
265
+ return { resolve: (assetId) => {
266
+ const v = map[assetId];
267
+ if (v === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no media for asset "${assetId}"`));
268
+ return Promise.resolve(typeof v === "string" ? {
269
+ kind: "url",
270
+ url: v
271
+ } : {
272
+ kind: "blob",
273
+ blob: v
274
+ });
275
+ } };
276
+ }
277
+ function toResolver(media) {
278
+ return typeof media.resolve === "function" ? media : fromMap(media);
279
+ }
280
+ // src/media/frame-source.ts
281
+ function defaultCreateCanvas(width, height) {
282
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
283
+ const c = document.createElement("canvas");
284
+ c.width = width;
285
+ c.height = height;
286
+ return c;
287
+ }
288
+ /** A frame backed by an image we own (canvas or bitmap); `retain` is free. */
289
+ var ImageFrame = class {
290
+ image;
291
+ timestamp;
292
+ duration;
293
+ owned;
294
+ constructor(image, timestamp, duration, owned) {
295
+ this.image = image;
296
+ this.timestamp = timestamp;
297
+ this.duration = duration;
298
+ this.owned = owned;
299
+ }
300
+ get width() {
301
+ return this.image.width;
302
+ }
303
+ get height() {
304
+ return this.image.height;
305
+ }
306
+ draw(ctx, dx, dy, dw, dh, crop) {
307
+ if (crop) ctx.drawImage(this.image, crop.x, crop.y, crop.w, crop.h, dx, dy, dw, dh);
308
+ else ctx.drawImage(this.image, dx, dy, dw, dh);
309
+ }
310
+ retain() {
311
+ return this;
312
+ }
313
+ close() {
314
+ if (this.owned && "close" in this.image && typeof this.image.close === "function") this.image.close();
315
+ }
316
+ };
317
+ /** Copies any frame into a canvas we own. */
318
+ function copyFrame(frame, createCanvas) {
319
+ const canvas = createCanvas(Math.max(1, frame.width), Math.max(1, frame.height));
320
+ const ctx = canvas.getContext("2d");
321
+ if (ctx) frame.draw(ctx, 0, 0, frame.width, frame.height);
322
+ return new ImageFrame(canvas, frame.timestamp, frame.duration, false);
323
+ }
324
+ /** Still images: one bitmap, returned for every time. */
325
+ var ImageFrameSource = class {
326
+ load;
327
+ bitmap = null;
328
+ constructor(load) {
329
+ this.load = load;
330
+ }
331
+ async open() {
332
+ const f = await this.frame();
333
+ return {
334
+ width: f.width,
335
+ height: f.height,
336
+ duration: 0,
337
+ decodable: true
338
+ };
339
+ }
340
+ async frameAt() {
341
+ return this.frame();
342
+ }
343
+ async *stream() {
344
+ yield await this.frame();
345
+ }
346
+ close() {
347
+ this.bitmap?.then((f) => f.close()).catch(() => void 0);
348
+ this.bitmap = null;
349
+ }
350
+ frame() {
351
+ this.bitmap ??= this.load().then((img) => new ImageFrame(img, 0, Number.POSITIVE_INFINITY, "close" in img));
352
+ return this.bitmap;
353
+ }
354
+ };
355
+ /** Draws a grey box with a label; used for missing or undecodable assets. */
356
+ var ErrorFrame = class {
357
+ width;
358
+ height;
359
+ label;
360
+ timestamp = 0;
361
+ duration = Number.POSITIVE_INFINITY;
362
+ constructor(width, height, label) {
363
+ this.width = width;
364
+ this.height = height;
365
+ this.label = label;
366
+ }
367
+ draw(ctx, dx, dy, dw, dh) {
368
+ ctx.fillStyle = "#444444";
369
+ ctx.fillRect(dx, dy, dw, dh);
370
+ ctx.fillStyle = "#cccccc";
371
+ ctx.font = "24px monospace";
372
+ ctx.textAlign = "center";
373
+ ctx.textBaseline = "middle";
374
+ ctx.fillText(this.label, dx + dw / 2, dy + dh / 2);
375
+ }
376
+ retain() {
377
+ return this;
378
+ }
379
+ close() {}
380
+ };
381
+ var ErrorFrameSource = class {
382
+ frame;
383
+ constructor(label, width = 640, height = 360) {
384
+ this.frame = new ErrorFrame(width, height, label);
385
+ }
386
+ open() {
387
+ return Promise.resolve({
388
+ width: this.frame.width,
389
+ height: this.frame.height,
390
+ duration: 0,
391
+ decodable: false
392
+ });
393
+ }
394
+ frameAt() {
395
+ return Promise.resolve(this.frame);
396
+ }
397
+ async *stream() {
398
+ yield this.frame;
399
+ }
400
+ close() {}
401
+ };
402
+ // src/media/asset-sources.ts
403
+ /**
404
+ * One FrameSource per asset, shared by every clip of that asset and by nested
405
+ * compositors. Ref-counted; an unreferenced source stays open for a while
406
+ * (a seek back is cheap) and is closed when idle too long or when too many
407
+ * are open.
408
+ */
409
+ var AssetSources = class {
410
+ deps;
411
+ entries = /* @__PURE__ */ new Map();
412
+ errored = /* @__PURE__ */ new Set();
413
+ constructor(deps) {
414
+ this.deps = deps;
415
+ }
416
+ /** Acquire a reference; the source opens lazily on first use. */
417
+ acquire(assetId) {
418
+ let e = this.entries.get(assetId);
419
+ if (!e) {
420
+ const source = new LazyFrameSource(() => this.open(assetId));
421
+ e = {
422
+ source,
423
+ info: source.open(),
424
+ refs: 0,
425
+ idleSince: null,
426
+ lastUse: this.deps.clock.now()
427
+ };
428
+ e.info.catch(() => void 0);
429
+ this.entries.set(assetId, e);
430
+ }
431
+ e.refs++;
432
+ e.idleSince = null;
433
+ e.lastUse = this.deps.clock.now();
434
+ return {
435
+ source: e.source,
436
+ info: e.info
437
+ };
438
+ }
439
+ release(assetId) {
440
+ const e = this.entries.get(assetId);
441
+ if (!e) return;
442
+ e.refs = Math.max(0, e.refs - 1);
443
+ if (e.refs === 0) e.idleSince = this.deps.clock.now();
444
+ }
445
+ /** Close sources idle for too long, then the least recently used idle ones beyond the cap. */
446
+ sweep() {
447
+ const now = this.deps.clock.now();
448
+ for (const [id, e] of this.entries) if (e.idleSince !== null && now - e.idleSince >= 10) this.close(id);
449
+ const idle = [...this.entries].filter(([, e]) => e.idleSince !== null).sort((a, b) => a[1].lastUse - b[1].lastUse);
450
+ let open = this.entries.size;
451
+ for (const [id] of idle) {
452
+ if (open <= 6) break;
453
+ this.close(id);
454
+ open--;
455
+ }
456
+ }
457
+ get openCount() {
458
+ return this.entries.size;
459
+ }
460
+ isOpen(assetId) {
461
+ return this.entries.has(assetId);
462
+ }
463
+ dispose() {
464
+ for (const id of [...this.entries.keys()]) this.close(id);
465
+ }
466
+ close(id) {
467
+ const e = this.entries.get(id);
468
+ if (!e) return;
469
+ e.source.close();
470
+ this.entries.delete(id);
471
+ }
472
+ async open(assetId) {
473
+ const asset = lookupAsset(this.deps.manifest, assetId);
474
+ const fail = async (error) => {
475
+ if (!this.errored.has(assetId)) {
476
+ this.errored.add(assetId);
477
+ this.deps.onError({
478
+ asset: assetId,
479
+ error,
480
+ fatal: false
481
+ });
482
+ }
483
+ const source = new ErrorFrameSource(assetId, asset?.width ?? 640, asset?.height ?? 360);
484
+ return {
485
+ source,
486
+ info: await source.open()
487
+ };
488
+ };
489
+ try {
490
+ const handle = await this.deps.media.resolve(assetId);
491
+ const source = this.deps.frameSourceFactory(assetId, handle, asset?.kind ?? "video");
492
+ const info = await source.open();
493
+ if (!info.decodable) {
494
+ source.close();
495
+ return fail(/* @__PURE__ */ new Error(`asset "${assetId}" cannot be decoded in this browser`));
496
+ }
497
+ return {
498
+ source,
499
+ info
500
+ };
501
+ } catch (error) {
502
+ return fail(error instanceof Error ? error : new Error(String(error)));
503
+ }
504
+ }
505
+ };
506
+ /** Opens the real source once, on first use, and forwards every call to it. */
507
+ var LazyFrameSource = class {
508
+ factory;
509
+ inner = null;
510
+ closed = false;
511
+ constructor(factory) {
512
+ this.factory = factory;
513
+ }
514
+ get() {
515
+ this.inner ??= this.factory();
516
+ return this.inner;
517
+ }
518
+ async open() {
519
+ return (await this.get()).info;
520
+ }
521
+ async frameAt(t) {
522
+ return (await this.get()).source.frameAt(t);
523
+ }
524
+ async *stream(from, to) {
525
+ const { source } = await this.get();
526
+ yield* source.stream(from, to);
527
+ }
528
+ close() {
529
+ if (this.closed) return;
530
+ this.closed = true;
531
+ this.inner?.then((o) => o.source.close()).catch(() => void 0);
532
+ }
533
+ };
534
+ // src/media/clip-decoder.ts
535
+ /** How far the play head may run past the newest queued frame before the stream is restarted from the head. */
536
+ const BEHIND_RESEEK_S = 1;
537
+ /**
538
+ * Decoded frames for one placement. Forward playback streams from the source
539
+ * into a short queue; reverse pre-decodes windows behind the cursor; seek
540
+ * mode (high speeds, scrubbing) fetches single frames. Freezes hold one
541
+ * retained frame. The decoder never blocks a playing tick: when the frame for
542
+ * `t` is not ready it returns the last frame it delivered.
543
+ */
544
+ var ClipDecoder = class {
545
+ source;
546
+ effectiveSpeed;
547
+ id;
548
+ sourceKey;
549
+ queue = [];
550
+ current = null;
551
+ generation = 0;
552
+ gen = null;
553
+ pumping = null;
554
+ streamEnd = 0;
555
+ seekPending = null;
556
+ reverseLow = Number.POSITIVE_INFINITY;
557
+ reverseLoading = null;
558
+ freezes = /* @__PURE__ */ new Map();
559
+ disposed = false;
560
+ primed = null;
561
+ _placement;
562
+ _children;
563
+ constructor(source, placement, children, effectiveSpeed) {
564
+ this.source = source;
565
+ this.effectiveSpeed = effectiveSpeed;
566
+ this.id = placement.id;
567
+ this.sourceKey = sourceKeyOf(placement);
568
+ this._placement = placement;
569
+ this._children = children;
570
+ }
571
+ /** Asset and direction: everything else (trim, speed, freezes) may change under a live decoder. */
572
+ static sourceKey(p) {
573
+ return sourceKeyOf(p);
574
+ }
575
+ get placement() {
576
+ return this._placement;
577
+ }
578
+ get children() {
579
+ return this._children;
580
+ }
581
+ /**
582
+ * Take a new resolve of the same placement. A trim or speed change moves
583
+ * where source time lands; the queue is kept and the next `frameFor` reseeks
584
+ * only if the new source time falls outside what is queued.
585
+ */
586
+ update(placement, children) {
587
+ this._placement = placement;
588
+ this._children = children;
589
+ }
590
+ get mode() {
591
+ if (this.placement.props.reverse === true) return "reverse";
592
+ return Math.abs(this.effectiveSpeed()) > 4 ? "seek" : "forward";
593
+ }
594
+ get queued() {
595
+ return this.queue.length;
596
+ }
597
+ get ready() {
598
+ return this.primed ?? Promise.resolve();
599
+ }
600
+ sourceTime(t) {
601
+ return placementSourceTime(this.placement, this.children, t);
602
+ }
603
+ /** Jump to composition time `t`: cancel streaming, fetch the frame, restart from there. */
604
+ prime(t) {
605
+ this.cancelStream();
606
+ const st = this.sourceTime(t);
607
+ const run = async () => {
608
+ const gen = this.generation;
609
+ const frame = await this.source.frameAt(st);
610
+ if (gen !== this.generation || this.disposed) {
611
+ frame?.close();
612
+ return;
613
+ }
614
+ this.setCurrent(frame);
615
+ if (this.mode === "forward") this.startStream(st);
616
+ else if (this.mode === "reverse") this.reverseLow = st;
617
+ };
618
+ this.primed = run();
619
+ return this.primed;
620
+ }
621
+ /**
622
+ * The frame to draw at composition time `t`. With `wait`, fetch it if it is
623
+ * not queued, for at most `FRAME_WAIT_MS`; with `exact` as well, for as long
624
+ * as it takes, so an export never draws a frame it did not wait for.
625
+ */
626
+ async frameFor(t, wait, exact = false) {
627
+ if (this.disposed) return null;
628
+ const freeze = this.activeFreeze(t);
629
+ if (freeze) return this.freezeFrame(freeze, wait);
630
+ const st = this.sourceTime(t);
631
+ if (this.mode === "seek") return this.seekFrame(st, wait);
632
+ if (this.mode === "reverse") return this.reverseFrame(st, wait);
633
+ return this.forwardFrame(st, wait, exact);
634
+ }
635
+ /** Close consumed frames; called after a frame was painted. */
636
+ advance(t) {
637
+ if (this.mode !== "forward") return;
638
+ const st = this.sourceTime(t);
639
+ while (this.queue.length > 0 && this.queue[0].timestamp + this.queue[0].duration <= st && this.queue.length > 1 && this.queue[1].timestamp <= st + .001) this.queue.shift().close();
640
+ this.pump();
641
+ }
642
+ dispose() {
643
+ this.disposed = true;
644
+ this.cancelStream();
645
+ this.setCurrent(null);
646
+ for (const p of this.freezes.values()) p.then((f) => f?.close());
647
+ this.freezes.clear();
648
+ }
649
+ async forwardFrame(st, wait, exact = false) {
650
+ const newest = this.queue[this.queue.length - 1] ?? this.current;
651
+ const behind = newest !== null && st - (newest.timestamp + newest.duration) > BEHIND_RESEEK_S;
652
+ const before = this.current !== null && st + .001 < this.current.timestamp && !this.queue.some((f) => f.timestamp <= st + .001);
653
+ if (this.gen === null && this.queue.length === 0 && this.current === null) {
654
+ if (wait) {
655
+ await this.primeAtSource(st);
656
+ return this.current;
657
+ }
658
+ this.primeAtSource(st);
659
+ return null;
660
+ }
661
+ if (behind || before) {
662
+ if (wait) {
663
+ await this.primeAtSource(st);
664
+ return this.current;
665
+ }
666
+ this.primeAtSource(st);
667
+ return this.current;
668
+ }
669
+ if (!wait) {
670
+ const picked = this.forwardPick(st);
671
+ this.pump();
672
+ return picked ?? this.current;
673
+ }
674
+ const deadline = exact ? Number.POSITIVE_INFINITY : Date.now() + 250;
675
+ for (;;) {
676
+ const picked = this.forwardPick(st);
677
+ const next = this.queue[0];
678
+ if (next !== void 0 && next.timestamp > st + .001 || this.gen === null || picked !== null && picked.timestamp + picked.duration > st + .001 || Date.now() >= deadline || this.disposed) {
679
+ this.pump();
680
+ return picked ?? this.current;
681
+ }
682
+ await this.pumpOnce();
683
+ }
684
+ }
685
+ /** The newest queued frame at or before `st` becomes current; older ones are consumed. */
686
+ forwardPick(st) {
687
+ let best = null;
688
+ let index = -1;
689
+ for (let i = 0; i < this.queue.length; i++) if (this.queue[i].timestamp <= st + .001) {
690
+ best = this.queue[i];
691
+ index = i;
692
+ } else break;
693
+ if (!best) return null;
694
+ for (let i = 0; i < index; i++) this.queue[i].close();
695
+ this.queue.splice(0, index + 1);
696
+ this.setCurrent(best);
697
+ return best;
698
+ }
699
+ primeAtSource(st) {
700
+ this.cancelStream();
701
+ const gen = this.generation;
702
+ this.primed = this.source.frameAt(st).then((frame) => {
703
+ if (gen !== this.generation || this.disposed) {
704
+ frame?.close();
705
+ return;
706
+ }
707
+ this.setCurrent(frame);
708
+ this.startStream(st);
709
+ });
710
+ return this.primed;
711
+ }
712
+ startStream(from) {
713
+ this.streamEnd = Number.POSITIVE_INFINITY;
714
+ this.gen = this.source.stream(from, this.streamEnd);
715
+ this.pump();
716
+ }
717
+ pump() {
718
+ if (this.pumping || this.gen === null || this.queue.length >= 8 || this.disposed) return;
719
+ this.pumpOnce();
720
+ }
721
+ /** Pull frames until the queue is at target; a caller that finds a pump running awaits that one. */
722
+ pumpOnce() {
723
+ if (this.pumping) return this.pumping;
724
+ if (this.gen === null) return Promise.resolve();
725
+ const gen = this.gen, generation = this.generation;
726
+ const run = async () => {
727
+ try {
728
+ while (this.queue.length < 8 && this.gen === gen) {
729
+ let next;
730
+ try {
731
+ next = await gen.next();
732
+ } catch (error) {
733
+ if (generation !== this.generation || this.disposed) return;
734
+ throw error;
735
+ }
736
+ if (generation !== this.generation || this.disposed) {
737
+ if (!next.done) next.value.close();
738
+ return;
739
+ }
740
+ if (next.done) {
741
+ this.gen = null;
742
+ return;
743
+ }
744
+ const frame = next.value;
745
+ const last = this.queue[this.queue.length - 1];
746
+ if (last && frame.timestamp <= last.timestamp) {
747
+ frame.close();
748
+ continue;
749
+ }
750
+ this.queue.push(frame);
751
+ while (this.queue.length > 10) this.queue.shift().close();
752
+ }
753
+ } finally {
754
+ this.pumping = null;
755
+ }
756
+ };
757
+ this.pumping = run();
758
+ return this.pumping;
759
+ }
760
+ seekFrame(st, wait) {
761
+ if (this.current && this.current.timestamp <= st + .001 && st + .001 < this.current.timestamp + this.current.duration) return Promise.resolve(this.current);
762
+ if (!this.seekPending) {
763
+ const gen = this.generation;
764
+ this.seekPending = this.source.frameAt(st).then((frame) => {
765
+ this.seekPending = null;
766
+ if (gen !== this.generation || this.disposed) {
767
+ frame?.close();
768
+ return;
769
+ }
770
+ this.setCurrent(frame);
771
+ });
772
+ }
773
+ return wait ? this.seekPending.then(() => this.current) : Promise.resolve(this.current);
774
+ }
775
+ async reverseFrame(st, wait) {
776
+ const pick = () => {
777
+ let best = null;
778
+ let index = -1;
779
+ for (let i = 0; i < this.queue.length; i++) if (this.queue[i].timestamp <= st) {
780
+ best = this.queue[i];
781
+ index = i;
782
+ }
783
+ if (!best) return null;
784
+ for (let i = index + 1; i < this.queue.length; i++) {
785
+ const f = this.queue[i];
786
+ if (f === this.current) this.current = null;
787
+ f.close();
788
+ }
789
+ this.queue.length = index + 1;
790
+ this.setCurrent(best);
791
+ return best;
792
+ };
793
+ const have = pick();
794
+ const needEarlier = this.queue.length === 0 || this.queue[0].timestamp > st - 1e-6 || st < this.reverseLow;
795
+ const src = this.placement.source;
796
+ const low = Math.min(this.reverseLow, this.queue[0]?.timestamp ?? Number.POSITIVE_INFINITY);
797
+ if (needEarlier && src && low > src.in && !this.reverseLoading) this.reverseLoading = this.loadReverseWindow(Math.min(low, st + 1e-6), src.in);
798
+ if (have && (!needEarlier || !wait)) return have;
799
+ if (wait && this.reverseLoading) {
800
+ await this.reverseLoading;
801
+ return pick() ?? this.current;
802
+ }
803
+ return have ?? this.current;
804
+ }
805
+ async loadReverseWindow(to, floor) {
806
+ const gen = this.generation;
807
+ const from = Math.max(floor, to - 1);
808
+ const frames = [];
809
+ try {
810
+ for await (const f of this.source.stream(from, to)) {
811
+ if (gen !== this.generation || this.disposed) {
812
+ f.close();
813
+ return;
814
+ }
815
+ if (f.timestamp >= to) {
816
+ f.close();
817
+ break;
818
+ }
819
+ frames.push(f);
820
+ if (frames.length > 16) frames.shift().close();
821
+ }
822
+ if (gen !== this.generation || this.disposed) {
823
+ frames.forEach((f) => f.close());
824
+ return;
825
+ }
826
+ this.queue = [...frames, ...this.queue];
827
+ this.reverseLow = from;
828
+ } finally {
829
+ this.reverseLoading = null;
830
+ }
831
+ }
832
+ activeFreeze(t) {
833
+ const rel = t - this.placement.start;
834
+ for (const c of this.children) {
835
+ if (c.kind !== "freeze") continue;
836
+ const fs = Number(c.props.start ?? 0), fd = Number(c.props.duration ?? 0);
837
+ if (rel >= fs && rel < fs + fd) return c;
838
+ }
839
+ return null;
840
+ }
841
+ freezeFrame(freeze, wait) {
842
+ let p = this.freezes.get(freeze.id);
843
+ if (!p) {
844
+ p = this.source.frameAt(Number(freeze.props.at ?? 0)).then((f) => {
845
+ if (!f) return null;
846
+ const held = f.retain();
847
+ if (held !== f) f.close();
848
+ return held;
849
+ });
850
+ this.freezes.set(freeze.id, p);
851
+ }
852
+ return wait ? p : Promise.race([p, Promise.resolve(this.current)]);
853
+ }
854
+ setCurrent(frame) {
855
+ if (this.current && this.current !== frame && !this.queue.includes(this.current)) this.current.close();
856
+ this.current = frame;
857
+ }
858
+ cancelStream() {
859
+ this.generation++;
860
+ const gen = this.gen;
861
+ this.gen = null;
862
+ if (gen) gen.return(void 0).catch(() => void 0);
863
+ for (const f of this.queue) if (f !== this.current) f.close();
864
+ this.queue = [];
865
+ this.reverseLow = Number.POSITIVE_INFINITY;
866
+ }
867
+ };
868
+ function sourceKeyOf(p) {
869
+ const s = p.source;
870
+ return s ? `${s.asset}|${p.props.reverse === true ? "r" : "f"}` : p.id;
871
+ }
872
+ // src/media/decoder-pool.ts
873
+ /**
874
+ * Keeps a ClipDecoder for every media placement near the play head and drops
875
+ * the rest. Asset sources are shared through `AssetSources`, so nested
876
+ * compositors handed the same registry never open an asset twice.
877
+ */
878
+ var DecoderPool = class {
879
+ sources;
880
+ decoders = /* @__PURE__ */ new Map();
881
+ rate = 1;
882
+ constructor(sources) {
883
+ this.sources = sources;
884
+ }
885
+ setRate(rate) {
886
+ this.rate = rate;
887
+ }
888
+ /** Mount decoders for placements within the window around `t`; unmount the rest. */
889
+ sync(state, t) {
890
+ const want = /* @__PURE__ */ new Map();
891
+ for (const track of state.tracks) {
892
+ if (track.hidden) continue;
893
+ for (const p of trackItems(state, track)) {
894
+ if (p.kind !== "clip" || !p.source) continue;
895
+ if (!lookupAsset(state.manifest, p.source.asset)) continue;
896
+ if (t < p.start - 2 || t >= p.end + .5) continue;
897
+ want.set(p.id, {
898
+ p,
899
+ children: p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0)
900
+ });
901
+ }
902
+ }
903
+ for (const [id, d] of this.decoders) {
904
+ const w = want.get(id);
905
+ if (!w || ClipDecoder.sourceKey(w.p) !== d.sourceKey) this.unmount(id);
906
+ else if (w.p !== d.placement) d.update(w.p, w.children);
907
+ }
908
+ for (const [id, w] of want) {
909
+ if (this.decoders.has(id)) continue;
910
+ const { source } = this.sources.acquire(w.p.source.asset);
911
+ const d = new ClipDecoder(source, w.p, w.children, () => w.p.source.speed * this.rate);
912
+ this.decoders.set(id, d);
913
+ d.prime(Math.max(t, w.p.start));
914
+ }
915
+ this.sources.sweep();
916
+ }
917
+ decoderFor(id) {
918
+ return this.decoders.get(id);
919
+ }
920
+ get mounted() {
921
+ return [...this.decoders.keys()];
922
+ }
923
+ /** After a seek: every decoder jumps to `t`. */
924
+ seekAll(t) {
925
+ return Promise.all([...this.decoders.values()].map((d) => d.prime(Math.max(t, d.placement.start)))).then(() => void 0);
926
+ }
927
+ /** Resolves when every decoder has its first frame. */
928
+ settle() {
929
+ return Promise.all([...this.decoders.values()].map((d) => d.ready)).then(() => void 0);
930
+ }
931
+ dispose() {
932
+ for (const id of [...this.decoders.keys()]) this.unmount(id);
933
+ }
934
+ unmount(id) {
935
+ const d = this.decoders.get(id);
936
+ if (!d) return;
937
+ d.dispose();
938
+ this.decoders.delete(id);
939
+ if (d.placement.source) this.sources.release(d.placement.source.asset);
940
+ }
941
+ };
942
+ // src/media/mediabunny-input.ts
943
+ /** One mediabunny `Input` per asset, shared by the video and audio sources of that asset. */
944
+ var MediabunnyInputs = class {
945
+ entries = /* @__PURE__ */ new Map();
946
+ acquire(assetId, handle) {
947
+ let e = this.entries.get(assetId);
948
+ if (!e) {
949
+ const source = handle.kind === "blob" ? new BlobSource(handle.blob) : handle.kind === "path" ? new FilePathSource(handle.path) : new UrlSource(handle.url);
950
+ e = {
951
+ input: new Input({
952
+ source,
953
+ formats: ALL_FORMATS
954
+ }),
955
+ refs: 0
956
+ };
957
+ this.entries.set(assetId, e);
958
+ }
959
+ e.refs++;
960
+ return e.input;
961
+ }
962
+ release(assetId) {
963
+ const e = this.entries.get(assetId);
964
+ if (!e) return;
965
+ e.refs--;
966
+ if (e.refs <= 0) {
967
+ e.input.dispose();
968
+ this.entries.delete(assetId);
969
+ }
970
+ }
971
+ get openCount() {
972
+ return this.entries.size;
973
+ }
974
+ dispose() {
975
+ for (const e of this.entries.values()) e.input.dispose();
976
+ this.entries.clear();
977
+ }
978
+ };
979
+ // src/media/mediabunny-frame-source.ts
980
+ /** A decoded `VideoSample`: GPU-backed, drawn straight to the context, closed when consumed. */
981
+ var SampleFrame = class {
982
+ sample;
983
+ createCanvas;
984
+ timestamp;
985
+ duration;
986
+ width;
987
+ height;
988
+ constructor(sample, createCanvas) {
989
+ this.sample = sample;
990
+ this.createCanvas = createCanvas;
991
+ this.timestamp = sample.timestamp;
992
+ this.duration = sample.duration;
993
+ this.width = sample.displayWidth;
994
+ this.height = sample.displayHeight;
995
+ }
996
+ draw(ctx, dx, dy, dw, dh, crop) {
997
+ if (crop) this.sample.draw(ctx, crop.x, crop.y, crop.w, crop.h, dx, dy, dw, dh);
998
+ else this.sample.draw(ctx, dx, dy, dw, dh);
999
+ }
1000
+ retain() {
1001
+ return copyFrame(this, this.createCanvas);
1002
+ }
1003
+ close() {
1004
+ this.sample.close();
1005
+ }
1006
+ };
1007
+ /** Frames of one video asset through a shared `VideoSampleSink`. */
1008
+ var MediabunnyFrameSource = class {
1009
+ assetId;
1010
+ handle;
1011
+ inputs;
1012
+ createCanvas;
1013
+ hooks;
1014
+ input = null;
1015
+ track = null;
1016
+ sink = null;
1017
+ stillSink = null;
1018
+ constructor(assetId, handle, inputs, createCanvas, hooks = {}) {
1019
+ this.assetId = assetId;
1020
+ this.handle = handle;
1021
+ this.inputs = inputs;
1022
+ this.createCanvas = createCanvas;
1023
+ this.hooks = hooks;
1024
+ }
1025
+ wrap(sample) {
1026
+ return this.hooks.makeFrame ? this.hooks.makeFrame(sample) : new SampleFrame(sample, this.createCanvas);
1027
+ }
1028
+ async open() {
1029
+ this.input = this.inputs.acquire(this.assetId, this.handle);
1030
+ const track = await this.input.getPrimaryVideoTrack();
1031
+ if (!track) return {
1032
+ width: 0,
1033
+ height: 0,
1034
+ duration: 0,
1035
+ decodable: false
1036
+ };
1037
+ const decodable = await track.canDecode();
1038
+ const [width, height, duration] = await Promise.all([
1039
+ track.getDisplayWidth(),
1040
+ track.getDisplayHeight(),
1041
+ track.computeDuration()
1042
+ ]);
1043
+ this.track = track;
1044
+ this.sink = new VideoSampleSink(track, {
1045
+ hardwareAcceleration: this.hooks.hardwareAcceleration ?? "no-preference",
1046
+ optimizeForLatency: true
1047
+ });
1048
+ return {
1049
+ width,
1050
+ height,
1051
+ duration,
1052
+ decodable
1053
+ };
1054
+ }
1055
+ async frameAt(sourceTime) {
1056
+ if (!this.sink) return null;
1057
+ const sample = await this.sink.getSample(Math.max(0, sourceTime) + PICK_EPS);
1058
+ return sample ? this.wrap(sample) : null;
1059
+ }
1060
+ async *stream(from, to) {
1061
+ if (!this.sink) return;
1062
+ for await (const sample of this.sink.samples(Math.max(0, from), to)) yield await this.wrap(sample);
1063
+ }
1064
+ /** A canvas copy of the frame at `sourceTime`, for freeze frames; pooled canvases are copied right away. */
1065
+ async still(sourceTime) {
1066
+ if (!this.track) return null;
1067
+ this.stillSink ??= new CanvasSink(this.track, { poolSize: 2 });
1068
+ const wrapped = await this.stillSink.getCanvas(Math.max(0, sourceTime));
1069
+ if (!wrapped) return null;
1070
+ const canvas = this.createCanvas(wrapped.canvas.width, wrapped.canvas.height);
1071
+ canvas.getContext("2d")?.drawImage(wrapped.canvas, 0, 0);
1072
+ return copyFrame({
1073
+ timestamp: wrapped.timestamp,
1074
+ duration: wrapped.duration,
1075
+ width: canvas.width,
1076
+ height: canvas.height,
1077
+ draw: (ctx, dx, dy, dw, dh) => ctx.drawImage(canvas, dx, dy, dw, dh),
1078
+ retain: () => {
1079
+ throw new Error("unreachable");
1080
+ },
1081
+ close: () => void 0
1082
+ }, this.createCanvas);
1083
+ }
1084
+ close() {
1085
+ this.sink = null;
1086
+ this.stillSink = null;
1087
+ this.track = null;
1088
+ if (this.input) this.inputs.release(this.assetId);
1089
+ this.input = null;
1090
+ }
1091
+ };
1092
+ async function loadImage(handle) {
1093
+ if (handle.kind === "path") throw new Error("a file path needs a host image loader (hooks.loadImage)");
1094
+ const blob = handle.kind === "blob" ? handle.blob : await (await fetch(handle.url)).blob();
1095
+ return createImageBitmap(blob);
1096
+ }
1097
+ function mediabunnyFrameSourceFactory(inputs, createCanvas, hooks = {}) {
1098
+ return (assetId, handle, kind) => kind === "image" ? new ImageFrameSource(() => (hooks.loadImage ?? loadImage)(handle)) : new MediabunnyFrameSource(assetId, handle, inputs, createCanvas, hooks);
1099
+ }
1100
+ // src/media/mediabunny-audio-source.ts
1101
+ /** PCM ranges of one audio track, decoded on demand into AudioBuffers of the track's sample rate. */
1102
+ var MediabunnyAudioSource = class {
1103
+ assetId;
1104
+ handle;
1105
+ inputs;
1106
+ context;
1107
+ trackIndex;
1108
+ input = null;
1109
+ track = null;
1110
+ sink = null;
1111
+ sampleRate = 48e3;
1112
+ channels = 2;
1113
+ constructor(assetId, handle, inputs, context, trackIndex = 0) {
1114
+ this.assetId = assetId;
1115
+ this.handle = handle;
1116
+ this.inputs = inputs;
1117
+ this.context = context;
1118
+ this.trackIndex = trackIndex;
1119
+ }
1120
+ async open() {
1121
+ this.input = this.inputs.acquire(this.assetId, this.handle);
1122
+ const track = (await this.input.getAudioTracks())[this.trackIndex] ?? await this.input.getPrimaryAudioTrack();
1123
+ if (!track) return {
1124
+ sampleRate: 0,
1125
+ channels: 0,
1126
+ duration: 0,
1127
+ decodable: false
1128
+ };
1129
+ const [decodable, sampleRate, channels, duration] = await Promise.all([
1130
+ track.canDecode(),
1131
+ track.getSampleRate(),
1132
+ track.getNumberOfChannels(),
1133
+ track.computeDuration()
1134
+ ]);
1135
+ this.track = track;
1136
+ this.sampleRate = sampleRate;
1137
+ this.channels = channels;
1138
+ this.sink = new AudioBufferSink(track);
1139
+ return {
1140
+ sampleRate,
1141
+ channels,
1142
+ duration,
1143
+ decodable
1144
+ };
1145
+ }
1146
+ async range(from, to) {
1147
+ const sr = this.sampleRate;
1148
+ const length = Math.max(1, Math.ceil((to - from) * sr));
1149
+ const out = this.context.createBuffer(Math.max(1, this.channels), length, sr);
1150
+ if (!this.sink) return out;
1151
+ for await (const { buffer, timestamp } of this.sink.buffers(Math.max(0, from), to)) {
1152
+ const offset = Math.round((timestamp - from) * sr);
1153
+ const srcStart = Math.max(0, -offset);
1154
+ const dstStart = Math.max(0, offset);
1155
+ const n = Math.min(buffer.length - srcStart, length - dstStart);
1156
+ if (n <= 0) continue;
1157
+ for (let ch = 0; ch < out.numberOfChannels; ch++) {
1158
+ const data = buffer.getChannelData(Math.min(ch, buffer.numberOfChannels - 1));
1159
+ out.copyToChannel(data.subarray(srcStart, srcStart + n), ch, dstStart);
1160
+ }
1161
+ }
1162
+ return out;
1163
+ }
1164
+ close() {
1165
+ this.sink = null;
1166
+ this.track = null;
1167
+ if (this.input) this.inputs.release(this.assetId);
1168
+ this.input = null;
1169
+ }
1170
+ };
1171
+ function mediabunnyAudioSourceFactory(inputs) {
1172
+ return (assetId, handle, context) => new MediabunnyAudioSource(assetId, handle, inputs, context);
1173
+ }
1174
+ // src/render/masks.ts
1175
+ /**
1176
+ * Trace the mask outline onto the context's current path, in local element
1177
+ * pixels. Shape coordinates are fractions of the element box; bezier paths
1178
+ * are in pixels with handles. Returns false when the spec has no usable shape.
1179
+ */
1180
+ function traceMask(ctx, mask, rect) {
1181
+ const w = rect.w, h = rect.h;
1182
+ const shape = mask.shape ?? {};
1183
+ if ("rect" in shape && shape.rect) {
1184
+ const r = shape.rect;
1185
+ const x = r.x * w, y = r.y * h, rw = r.width * w, rh = r.height * h;
1186
+ if (r.cornerRadius && r.cornerRadius > 0) roundedRect(ctx, x, y, rw, rh, r.cornerRadius);
1187
+ else ctx.rect(x, y, rw, rh);
1188
+ return true;
1189
+ }
1190
+ if ("ellipse" in shape && shape.ellipse) {
1191
+ const e = shape.ellipse;
1192
+ ctx.ellipse(e.cx * w, e.cy * h, e.rx * w, e.ry * h, 0, 0, Math.PI * 2);
1193
+ return true;
1194
+ }
1195
+ if ("polygon" in shape && Array.isArray(shape.polygon)) {
1196
+ shape.polygon.forEach((p, i) => i === 0 ? ctx.moveTo(p.x * w, p.y * h) : ctx.lineTo(p.x * w, p.y * h));
1197
+ ctx.closePath();
1198
+ return true;
1199
+ }
1200
+ if (mask.path && mask.path.points.length >= 2) {
1201
+ const pts = mask.path.points;
1202
+ ctx.moveTo(pts[0].x, pts[0].y);
1203
+ for (let i = 1; i <= (mask.path.closed ? pts.length : pts.length - 1); i++) {
1204
+ const a = pts[(i - 1) % pts.length], b = pts[i % pts.length];
1205
+ const c1 = a.handleOut ?? a, c2 = b.handleIn ?? b;
1206
+ ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, b.x, b.y);
1207
+ }
1208
+ if (mask.path.closed) ctx.closePath();
1209
+ return true;
1210
+ }
1211
+ return false;
1212
+ }
1213
+ function roundedRect(ctx, x, y, w, h, r) {
1214
+ const rr = Math.min(r, w / 2, h / 2);
1215
+ ctx.moveTo(x + rr, y);
1216
+ ctx.lineTo(x + w - rr, y);
1217
+ ctx.arcTo(x + w, y, x + w, y + rr, rr);
1218
+ ctx.lineTo(x + w, y + h - rr);
1219
+ ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
1220
+ ctx.lineTo(x + rr, y + h);
1221
+ ctx.arcTo(x, y + h, x, y + h - rr, rr);
1222
+ ctx.lineTo(x, y + rr);
1223
+ ctx.arcTo(x, y, x + rr, y, rr);
1224
+ ctx.closePath();
1225
+ }
1226
+ // src/render/painter.ts
1227
+ /**
1228
+ * Executes a FramePlan on a 2D context of composition size. Each op is
1229
+ * drawn under its own transform, alpha, blend and filter; masks clip; an
1230
+ * adjustment op re-filters everything drawn so far through a scratch canvas.
1231
+ */
1232
+ var CanvasPainter = class {
1233
+ width;
1234
+ height;
1235
+ createCanvas;
1236
+ scratch = null;
1237
+ /** An element drawn on its own before compositing: a feathered mask, vignette, grain. */
1238
+ layer = null;
1239
+ grain = null;
1240
+ constructor(width, height, createCanvas) {
1241
+ this.width = width;
1242
+ this.height = height;
1243
+ this.createCanvas = createCanvas;
1244
+ }
1245
+ paint(ctx, plan, inputs) {
1246
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1247
+ ctx.globalAlpha = 1;
1248
+ ctx.globalCompositeOperation = "source-over";
1249
+ ctx.filter = "none";
1250
+ if (plan.clear) ctx.clearRect(0, 0, this.width, this.height);
1251
+ else paintBackground(ctx, plan, this.width, this.height);
1252
+ if (plan.backgroundBlur) this.blurBackground(ctx, plan, plan.backgroundBlur.id, inputs);
1253
+ for (const op of plan.ops) if (op.kind === "adjustment") this.adjust(ctx, op);
1254
+ else this.drawOp(ctx, op, inputs);
1255
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1256
+ }
1257
+ drawOp(ctx, op, inputs) {
1258
+ const frame = op.kind === "nested" ? null : inputs.frames.get(op.id) ?? null;
1259
+ const nested = op.kind === "nested" ? inputs.nested.get(op.id) ?? null : null;
1260
+ if (op.kind !== "nested" && !frame) return;
1261
+ if (op.kind === "nested" && !nested) return;
1262
+ ctx.save();
1263
+ ctx.globalAlpha = op.opacity;
1264
+ ctx.globalCompositeOperation = op.blend;
1265
+ ctx.filter = op.filter || "none";
1266
+ clipFrame(ctx, op);
1267
+ setMatrix(ctx, op.matrix);
1268
+ const clipped = this.clip(ctx, op);
1269
+ const feathered = !!op.mask && op.mask.feather > 0 && clipped;
1270
+ if (feathered || op.post) {
1271
+ ctx.restore();
1272
+ this.drawLayered(ctx, op, frame, nested, feathered);
1273
+ return;
1274
+ }
1275
+ this.blit(ctx, op, frame, nested);
1276
+ ctx.restore();
1277
+ }
1278
+ /** The footage's picture, blurred and scaled to cover the frame, under everything; skipped when the footage covers the frame itself. */
1279
+ blurBackground(ctx, plan, id, inputs) {
1280
+ const op = plan.ops.find((o) => o.id === id);
1281
+ if (!op || coversCanvas(op, this.width, this.height)) return;
1282
+ const nested = op.kind === "nested" ? inputs.nested.get(id) ?? null : null;
1283
+ const frame = op.kind === "nested" ? null : inputs.frames.get(id) ?? null;
1284
+ const picture = nested ?? frame;
1285
+ if (!picture) return;
1286
+ const r = Math.round(.04 * this.height);
1287
+ const { x, y, w, h } = fitRect(this, picture, "cover");
1288
+ ctx.save();
1289
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1290
+ ctx.filter = `blur(${r}px)`;
1291
+ if (nested) ctx.drawImage(nested, x - 2 * r, y - 2 * r, w + 4 * r, h + 4 * r);
1292
+ else frame.draw(ctx, x - 2 * r, y - 2 * r, w + 4 * r, h + 4 * r);
1293
+ ctx.restore();
1294
+ }
1295
+ blit(ctx, op, frame, nested) {
1296
+ const { w, h } = op.rect;
1297
+ if (nested) ctx.drawImage(nested, 0, 0, w, h);
1298
+ else if (frame && !(coversCanvas(op, this.width, this.height) && frame.paintFull?.(ctx, this.width, this.height))) {
1299
+ const reference = op.cropReference;
1300
+ const crop = op.crop && reference ? {
1301
+ x: op.crop.x * frame.width / reference.width,
1302
+ y: op.crop.y * frame.height / reference.height,
1303
+ w: op.crop.w * frame.width / reference.width,
1304
+ h: op.crop.h * frame.height / reference.height
1305
+ } : op.crop;
1306
+ frame.draw(ctx, 0, 0, w, h, crop);
1307
+ }
1308
+ }
1309
+ /** Applies the border radius and mask as a clip in local coordinates. Returns true when a mask clip was set. */
1310
+ clip(ctx, op) {
1311
+ if (op.borderRadius > 0) {
1312
+ ctx.beginPath();
1313
+ roundedRect(ctx, 0, 0, op.rect.w, op.rect.h, op.borderRadius);
1314
+ ctx.clip();
1315
+ }
1316
+ if (!op.mask) return false;
1317
+ ctx.beginPath();
1318
+ if (!traceMask(ctx, op.mask, op.rect)) return false;
1319
+ if (op.mask.inverted) {
1320
+ ctx.rect(0, 0, op.rect.w, op.rect.h);
1321
+ ctx.clip("evenodd");
1322
+ } else ctx.clip();
1323
+ return true;
1324
+ }
1325
+ /**
1326
+ * The element alone on the layer canvas, then composited: a feathered mask punches it out with a
1327
+ * blurred alpha there, and vignette and grain darken or speckle only its own pixels.
1328
+ */
1329
+ drawLayered(ctx, op, frame, nested, feathered) {
1330
+ this.layer ??= this.createCanvas(this.width, this.height);
1331
+ const layer = this.layer;
1332
+ const lctx = layer.getContext("2d");
1333
+ if (!lctx) return;
1334
+ lctx.setTransform(1, 0, 0, 1, 0, 0);
1335
+ lctx.clearRect(0, 0, this.width, this.height);
1336
+ lctx.save();
1337
+ setMatrix(lctx, op.matrix);
1338
+ if (!feathered) this.clip(lctx, op);
1339
+ this.blit(lctx, op, frame, nested);
1340
+ lctx.restore();
1341
+ if (feathered) {
1342
+ lctx.save();
1343
+ lctx.globalCompositeOperation = "destination-in";
1344
+ lctx.filter = `blur(${op.mask.feather}px)`;
1345
+ setMatrix(lctx, op.matrix);
1346
+ lctx.beginPath();
1347
+ traceMask(lctx, op.mask, op.rect);
1348
+ if (op.mask.inverted) {
1349
+ lctx.rect(0, 0, op.rect.w, op.rect.h);
1350
+ lctx.fill("evenodd");
1351
+ } else lctx.fill();
1352
+ lctx.restore();
1353
+ }
1354
+ for (const fx of op.post ?? []) {
1355
+ lctx.save();
1356
+ lctx.globalCompositeOperation = "source-atop";
1357
+ if (fx.type === "vignette") {
1358
+ setMatrix(lctx, op.matrix);
1359
+ const { w, h } = op.rect;
1360
+ const r = Math.hypot(w, h) / 2;
1361
+ const g = lctx.createRadialGradient(w / 2, h / 2, r * .4, w / 2, h / 2, r);
1362
+ g.addColorStop(0, "rgba(0,0,0,0)");
1363
+ g.addColorStop(1, `rgba(0,0,0,${fx.amount})`);
1364
+ lctx.fillStyle = g;
1365
+ lctx.fillRect(0, 0, w, h);
1366
+ } else {
1367
+ const pattern = lctx.createPattern(this.noise(fx.seed, fx.amount), "repeat");
1368
+ if (pattern) {
1369
+ lctx.fillStyle = pattern;
1370
+ lctx.fillRect(0, 0, this.width, this.height);
1371
+ }
1372
+ }
1373
+ lctx.restore();
1374
+ }
1375
+ ctx.save();
1376
+ ctx.globalAlpha = op.opacity;
1377
+ ctx.globalCompositeOperation = op.blend;
1378
+ ctx.filter = op.filter || "none";
1379
+ clipFrame(ctx, op);
1380
+ if (op.borderRadius > 0) {
1381
+ setMatrix(ctx, op.matrix);
1382
+ ctx.beginPath();
1383
+ roundedRect(ctx, 0, 0, op.rect.w, op.rect.h, op.borderRadius);
1384
+ ctx.clip();
1385
+ }
1386
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1387
+ ctx.drawImage(layer, 0, 0);
1388
+ ctx.restore();
1389
+ }
1390
+ /** A tile of black and white specks, the same for the same seed; alpha up to `amount`. */
1391
+ noise(seed, amount) {
1392
+ this.grain ??= this.createCanvas(GRAIN_TILE, GRAIN_TILE);
1393
+ const gctx = this.grain.getContext("2d");
1394
+ if (!gctx) return this.grain;
1395
+ const img = gctx.createImageData(GRAIN_TILE, GRAIN_TILE);
1396
+ const rand = mulberry32(seed);
1397
+ for (let i = 0; i < img.data.length; i += 4) {
1398
+ const r = rand();
1399
+ const v = r < .5 ? 0 : 255;
1400
+ img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
1401
+ img.data[i + 3] = Math.round(Math.abs(r - .5) * 2 * amount * 255);
1402
+ }
1403
+ gctx.putImageData(img, 0, 0);
1404
+ return this.grain;
1405
+ }
1406
+ adjust(ctx, op) {
1407
+ if (!op.filter && op.opacity >= 1) return;
1408
+ this.scratch ??= this.createCanvas(this.width, this.height);
1409
+ const sctx = this.scratch.getContext("2d");
1410
+ if (!sctx) return;
1411
+ sctx.setTransform(1, 0, 0, 1, 0, 0);
1412
+ sctx.globalCompositeOperation = "copy";
1413
+ sctx.filter = "none";
1414
+ sctx.globalAlpha = 1;
1415
+ sctx.drawImage(canvasOf(ctx), 0, 0);
1416
+ ctx.save();
1417
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1418
+ ctx.globalCompositeOperation = "source-over";
1419
+ ctx.globalAlpha = op.opacity;
1420
+ ctx.filter = op.filter || "none";
1421
+ ctx.drawImage(this.scratch, 0, 0);
1422
+ ctx.restore();
1423
+ }
1424
+ };
1425
+ const GRAIN_TILE = 128;
1426
+ /** A small seeded PRNG on 0..1. */
1427
+ function mulberry32(seed) {
1428
+ let a = seed >>> 0;
1429
+ return () => {
1430
+ a = a + 1831565813 >>> 0;
1431
+ let t = a;
1432
+ t = Math.imul(t ^ t >>> 15, t | 1);
1433
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1434
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1435
+ };
1436
+ }
1437
+ function setMatrix(ctx, m) {
1438
+ ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
1439
+ }
1440
+ /** Clips to the op's shown part in composition pixels (a wipe), leaving the transform at identity. */
1441
+ function clipFrame(ctx, op) {
1442
+ if (!op.clip) return;
1443
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1444
+ ctx.beginPath();
1445
+ for (const r of op.clip) ctx.rect(r.x, r.y, r.w, r.h);
1446
+ ctx.clip();
1447
+ }
1448
+ function canvasOf(ctx) {
1449
+ return ctx.canvas;
1450
+ }
1451
+ /** Whether a draw op puts its picture over the whole canvas as it is: no transform, crop, clip, fade, filter or blend. */
1452
+ function coversCanvas(op, width, height) {
1453
+ const m = op.matrix;
1454
+ return m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0 && op.rect.w === width && op.rect.h === height && !op.crop && op.opacity >= 1 && op.blend === "source-over" && !op.filter && op.borderRadius === 0 && !op.mask && !op.clip && !op.post;
1455
+ }
1456
+ /** The composition background (a colour or a gradient) over the whole frame. */
1457
+ function paintBackground(ctx, plan, w, h) {
1458
+ ctx.save();
1459
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1460
+ ctx.globalAlpha = 1;
1461
+ ctx.globalCompositeOperation = "source-over";
1462
+ ctx.filter = "none";
1463
+ ctx.fillStyle = plan.backgroundGradient ? backgroundFill(ctx, plan.backgroundGradient, w, h) : plan.background;
1464
+ ctx.fillRect(0, 0, w, h);
1465
+ ctx.restore();
1466
+ }
1467
+ /** The same background as CSS, for the preview's ground under backdrop overlays. */
1468
+ function backgroundCss(plan) {
1469
+ const g = plan.backgroundGradient;
1470
+ if (!g) return plan.background;
1471
+ const stops = [...g.stops].sort((a, b) => a.offset - b.offset).map((s) => `${withOpacity(s.color, s.opacity)} ${Math.round(Math.max(0, Math.min(1, s.offset)) * 1e4) / 100}%`).join(", ");
1472
+ if (g.type === "radial") return `radial-gradient(farthest-corner at ${(g.center?.x ?? .5) * 100}% ${(g.center?.y ?? .5) * 100}%, ${stops})`;
1473
+ if (g.type === "conic") return `conic-gradient(from ${g.angle ?? 0}deg at ${(g.center?.x ?? .5) * 100}% ${(g.center?.y ?? .5) * 100}%, ${stops})`;
1474
+ return `linear-gradient(${g.angle ?? 180}deg, ${stops})`;
1475
+ }
1476
+ /** A canvas gradient for the frame, laid out the way CSS lays out the same gradient on a box of this size. */
1477
+ function backgroundFill(ctx, g, w, h) {
1478
+ const c = g.center ?? {
1479
+ x: .5,
1480
+ y: .5
1481
+ };
1482
+ const cx = c.x * w, cy = c.y * h;
1483
+ let grad;
1484
+ if (g.type === "radial") {
1485
+ const r = Math.max(Math.hypot(cx, cy), Math.hypot(w - cx, cy), Math.hypot(cx, h - cy), Math.hypot(w - cx, h - cy));
1486
+ grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
1487
+ } else if (g.type === "conic" && typeof ctx.createConicGradient === "function") grad = ctx.createConicGradient(((g.angle ?? 0) - 90) * Math.PI / 180, cx, cy);
1488
+ else {
1489
+ const rad = (g.angle ?? 180) * Math.PI / 180;
1490
+ const dx = Math.sin(rad), dy = -Math.cos(rad);
1491
+ const half = (Math.abs(w * dx) + Math.abs(h * dy)) / 2;
1492
+ grad = ctx.createLinearGradient(w / 2 - dx * half, h / 2 - dy * half, w / 2 + dx * half, h / 2 + dy * half);
1493
+ }
1494
+ for (const s of [...g.stops].sort((a, b) => a.offset - b.offset)) grad.addColorStop(Math.max(0, Math.min(1, s.offset)), withOpacity(s.color, s.opacity));
1495
+ return grad;
1496
+ }
1497
+ function withOpacity(color, opacity) {
1498
+ if (opacity === void 0 || opacity >= 1) return color;
1499
+ const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
1500
+ if (!hex) return color;
1501
+ const h = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
1502
+ return `rgba(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)}, ${Math.max(0, opacity)})`;
1503
+ }
1504
+ // src/render/compositor.ts
1505
+ /**
1506
+ * One tick: plan the frame, sync decoders, gather frames, paint. A tick that
1507
+ * arrives while another is in flight is coalesced into one re-run; a tick on
1508
+ * the same frame index as the last paint only keeps decoders prefetching.
1509
+ */
1510
+ var Compositor = class Compositor {
1511
+ deps;
1512
+ pool;
1513
+ painter;
1514
+ ctx = null;
1515
+ state;
1516
+ inFlight = null;
1517
+ pending = null;
1518
+ lastFrame = -1;
1519
+ lastPlan = null;
1520
+ nested = /* @__PURE__ */ new Map();
1521
+ disposed = false;
1522
+ constructor(state, deps) {
1523
+ this.deps = deps;
1524
+ this.state = state;
1525
+ this.pool = new DecoderPool(deps.sources);
1526
+ this.painter = new CanvasPainter(state.composition.width, state.composition.height, deps.createCanvas);
1527
+ }
1528
+ attach(ctx) {
1529
+ this.ctx = ctx;
1530
+ this.lastFrame = -1;
1531
+ }
1532
+ get renderedFrame() {
1533
+ return this.lastFrame;
1534
+ }
1535
+ get attachedContext() {
1536
+ return this.ctx;
1537
+ }
1538
+ get lastPlanBuilt() {
1539
+ return this.lastPlan;
1540
+ }
1541
+ get currentState() {
1542
+ return this.state;
1543
+ }
1544
+ setState(state) {
1545
+ this.state = state;
1546
+ if (state.composition.width !== this.painter.width || state.composition.height !== this.painter.height) this.painter = new CanvasPainter(state.composition.width, state.composition.height, this.deps.createCanvas);
1547
+ this.lastFrame = -1;
1548
+ }
1549
+ setRate(rate) {
1550
+ this.pool.setRate(rate);
1551
+ for (const n of this.nested.values()) n.compositor.setRate(rate);
1552
+ }
1553
+ plan(t) {
1554
+ return buildFramePlan(this.state, t, this.deps.resolveNested);
1555
+ }
1556
+ /** Jump every decoder (nested ones too) to `t`. */
1557
+ seek(t) {
1558
+ const nested = this.plan(t).ops.filter((op) => op.nested).map((op) => this.nested.get(op.id)?.compositor.seek(op.nested.time) ?? Promise.resolve());
1559
+ return Promise.all([this.pool.seekAll(t), ...nested]).then(() => void 0);
1560
+ }
1561
+ /**
1562
+ * The next frame of a sequential render (export): decoders keep streaming
1563
+ * from where they are instead of being re-primed, and every frame is waited
1564
+ * for. Paint order and output are the same as `seek` then `tick`, at a
1565
+ * fraction of the decode work.
1566
+ */
1567
+ step(t, intercept) {
1568
+ return this.tick(t, {
1569
+ wait: true,
1570
+ exact: true,
1571
+ ...intercept ? { intercept } : {}
1572
+ });
1573
+ }
1574
+ tick(t, opts) {
1575
+ if (this.disposed) return Promise.resolve();
1576
+ if (this.inFlight) {
1577
+ if (this.pending) {
1578
+ this.pending.t = t;
1579
+ const intercept = opts.intercept ?? this.pending.opts.intercept;
1580
+ this.pending.opts = {
1581
+ wait: opts.wait || this.pending.opts.wait,
1582
+ exact: Boolean(opts.exact || this.pending.opts.exact),
1583
+ ...intercept ? { intercept } : {}
1584
+ };
1585
+ return this.pending.done;
1586
+ }
1587
+ let resolve = () => void 0;
1588
+ const done = new Promise((r) => {
1589
+ resolve = r;
1590
+ });
1591
+ this.pending = {
1592
+ t,
1593
+ opts,
1594
+ done,
1595
+ resolve
1596
+ };
1597
+ return done;
1598
+ }
1599
+ const run = this.run(t, opts).catch(() => void 0).then(() => {
1600
+ this.inFlight = null;
1601
+ const next = this.pending;
1602
+ this.pending = null;
1603
+ if (next) this.tick(next.t, next.opts).then(next.resolve, next.resolve);
1604
+ });
1605
+ this.inFlight = run;
1606
+ return run;
1607
+ }
1608
+ dispose() {
1609
+ this.disposed = true;
1610
+ this.pool.dispose();
1611
+ for (const n of this.nested.values()) n.compositor.dispose();
1612
+ this.nested.clear();
1613
+ }
1614
+ async run(t, opts) {
1615
+ const frame = frameIndex(t, this.state.composition.fps);
1616
+ this.pool.sync(this.state, t);
1617
+ if (frame === this.lastFrame && !opts.wait) return;
1618
+ const plan = this.plan(t);
1619
+ this.lastPlan = plan;
1620
+ this.syncNested(plan);
1621
+ if (opts.wait) await this.pool.settle();
1622
+ const frames = /* @__PURE__ */ new Map();
1623
+ const nestedCanvases = /* @__PURE__ */ new Map();
1624
+ await Promise.all(plan.ops.map(async (op) => {
1625
+ if (op.nested) {
1626
+ const n = this.nested.get(op.id);
1627
+ if (!n) return;
1628
+ await n.compositor.tick(op.nested.time, {
1629
+ wait: opts.wait,
1630
+ exact: Boolean(opts.exact)
1631
+ });
1632
+ nestedCanvases.set(op.id, n.canvas);
1633
+ return;
1634
+ }
1635
+ if (op.kind !== "video" && op.kind !== "image" && op.kind !== "error") return;
1636
+ const d = this.pool.decoderFor(op.id);
1637
+ frames.set(op.id, d ? await d.frameFor(t, opts.wait, opts.exact) : null);
1638
+ }));
1639
+ if (this.disposed) return;
1640
+ if (!opts.intercept?.(plan, frames)) {
1641
+ await Promise.all([...frames.values()].map((f) => f?.prepare?.()));
1642
+ if (this.disposed) return;
1643
+ if (this.ctx) this.painter.paint(this.ctx, plan, {
1644
+ frames,
1645
+ nested: nestedCanvases
1646
+ });
1647
+ }
1648
+ for (const op of plan.ops) this.pool.decoderFor(op.id)?.advance(t);
1649
+ this.lastFrame = frame;
1650
+ this.deps.onFrame?.(t, frame);
1651
+ }
1652
+ syncNested(plan) {
1653
+ const want = /* @__PURE__ */ new Set();
1654
+ for (const op of plan.ops) {
1655
+ if (!op.nested) continue;
1656
+ want.add(op.id);
1657
+ const existing = this.nested.get(op.id);
1658
+ if (existing && existing.compositor.currentState === op.nested.state) continue;
1659
+ existing?.compositor.dispose();
1660
+ const inner = op.nested.state;
1661
+ const canvas = this.deps.createCanvas(inner.composition.width, inner.composition.height);
1662
+ const compositor = new Compositor(inner, {
1663
+ ...this.deps,
1664
+ onFrame: void 0
1665
+ });
1666
+ compositor.attach(canvas.getContext("2d"));
1667
+ this.nested.set(op.id, {
1668
+ compositor,
1669
+ canvas
1670
+ });
1671
+ }
1672
+ for (const [id, n] of this.nested) {
1673
+ if (want.has(id)) continue;
1674
+ n.compositor.dispose();
1675
+ this.nested.delete(id);
1676
+ }
1677
+ }
1678
+ };
1679
+ // src/nested.ts
1680
+ /**
1681
+ * Resolves `<Nested of={Helper}>` through the kit's `resolveScene`, once per
1682
+ * helper. File references (`of="file:..."`) have no loader yet and resolve
1683
+ * to nothing.
1684
+ */
1685
+ function makeNestedResolver(parent) {
1686
+ const cache = /* @__PURE__ */ new WeakMap();
1687
+ return (p) => {
1688
+ const of = p.props.of;
1689
+ if (typeof of !== "function") return null;
1690
+ const hit = cache.get(of);
1691
+ if (hit) return hit;
1692
+ const outer = parent();
1693
+ const state = resolveScene(of, p.props, outer.composition, outer.manifest);
1694
+ cache.set(of, state);
1695
+ return state;
1696
+ };
1697
+ }
1698
+ export { buildAudioPlan as A, ImageFrameSource as C, toResolver as D, fromMap as E, duckIntervals as M, ManualClock as O, ImageFrame as S, defaultCreateCanvas as T, DecoderPool as _, backgroundFill as a, ErrorFrame as b, setMatrix as c, MediabunnyAudioSource as d, mediabunnyAudioSourceFactory as f, MediabunnyInputs as g, mediabunnyFrameSourceFactory as h, backgroundCss as i, buildGainEnvelope as j, PerformanceClock as k, roundedRect as l, SampleFrame as m, Compositor as n, coversCanvas as o, MediabunnyFrameSource as p, CanvasPainter as r, paintBackground as s, makeNestedResolver as t, traceMask as u, ClipDecoder as v, copyFrame as w, ErrorFrameSource as x, AssetSources as y };