@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.
package/dist/index.mjs ADDED
@@ -0,0 +1,1410 @@
1
+ import { $ as MIN_RATE, A as hasKeyframes, B as devWarn, C as transformMatrix, D as visiblePlacements, E as trackItems, F as localT, G as DUCK_RELEASE_S, H as AUDIO_CHUNK_S, I as nestedSourceTime, J as FRAME_WAIT_MS, K as ENVELOPE_STEP_S, L as placementSourceTime, M as frameIndex, N as frameTime, O as EASING_FN, P as layerClock, Q as MAX_RATE, R as timecode, S as scaleM, T as isVisibleAt, U as AUDIO_PRIME_S, V as AUDIO_CACHE_S, W as DUCK_ATTACK_S, X as LOOKAHEAD_S, Y as INPUT_IDLE_CLOSE_S, Z as MAX_OPEN_INPUTS, _ as fitRect, a as backdropTracks, at as REVERSE_WINDOW_FRAMES, b as multiply, c as combine, ct as SEEK_MODE_SPEED, d as filterString, et as MOUNT_AHEAD_S, f as postEffects, g as cssTransform, h as boundsOf, i as OVERLAY_KINDS, it as QUEUE_TARGET, j as keyframedValue, k as ease, l as effect, lt as SETTLE_MS, m as animatedTransform, n as buildFramePlan, nt as PLAY_PRIME_TIMEOUT_MS, ot as REVERSE_WINDOW_S, p as IDENTITY, q as FRAME_EPS, r as compositionBackground, rt as QUEUE_MAX, s as STILL, st as SCHEDULER_MS, t as blendOf, tt as PICK_EPS, u as motionAt, ut as UNMOUNT_BEHIND_S, v as layoutRect, w as translate, x as rotate, y as matrixToCss, z as captureWarnings } from "./frame-plan-BNwblf1s.mjs";
2
+ import { A as buildAudioPlan, C as ImageFrameSource, D as toResolver, E as fromMap, M as duckIntervals, O as ManualClock, S as ImageFrame, T as defaultCreateCanvas, _ as DecoderPool, a as backgroundFill, b as ErrorFrame, c as setMatrix, d as MediabunnyAudioSource, f as mediabunnyAudioSourceFactory, g as MediabunnyInputs, h as mediabunnyFrameSourceFactory, i as backgroundCss, j as buildGainEnvelope, k as PerformanceClock, l as roundedRect, m as SampleFrame, n as Compositor, o as coversCanvas, p as MediabunnyFrameSource, r as CanvasPainter, s as paintBackground, t as makeNestedResolver, u as traceMask, v as ClipDecoder, w as copyFrame, x as ErrorFrameSource, y as AssetSources } from "./nested-DSXA1q8d.mjs";
3
+ import { A as indexLayerInteriors, C as titleBox, D as layoutStyle, E as dataAttrs, S as stickerSize, a as iconStickerDrawing, b as layerContent, i as graphicLabels, k as overlayStyle, o as shapeDrawing, r as graphicDrawing, t as svgDrawing, w as titleContent, x as stickerPlaceholder, y as captionsContent } from "./svg-element-BUXbz-U5.mjs";
4
+ import { installLayerClock, rampAdvance, rampSpeed } from "@cutcraft/kit";
5
+ import { createContext, forwardRef, memo, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ // src/clock/audio-clock.ts
8
+ /**
9
+ * `AudioContext.currentTime` while the context runs, the performance clock
10
+ * otherwise, with an offset captured at every state change so `now()` never
11
+ * jumps. Audio scheduling converts back with `toContextTime`.
12
+ */
13
+ var AudioContextClock = class {
14
+ ctx;
15
+ offset = 0;
16
+ wasRunning = false;
17
+ lastNow = 0;
18
+ constructor(ctx) {
19
+ this.ctx = ctx;
20
+ }
21
+ get running() {
22
+ return this.ctx.state === "running";
23
+ }
24
+ now() {
25
+ const running = this.running;
26
+ const raw = running ? this.ctx.currentTime : performance.now() / 1e3;
27
+ if (running !== this.wasRunning) {
28
+ this.offset = this.lastNow - raw;
29
+ this.wasRunning = running;
30
+ }
31
+ const now = raw + this.offset;
32
+ this.lastNow = Math.max(now, this.lastNow);
33
+ return this.lastNow;
34
+ }
35
+ /** Audio context seconds for a clock time; exact while the context runs. */
36
+ toContextTime(clockTime) {
37
+ this.now();
38
+ return this.running ? clockTime - this.offset : this.ctx.currentTime + (clockTime - this.lastNow);
39
+ }
40
+ };
41
+ // src/clock/transport.ts
42
+ /**
43
+ * Composition time as an anchor on the clock: `anchorTime + (now − anchorClock) × rate`.
44
+ * Every play, pause, seek and rate change re-anchors, so time is always derived from
45
+ * the clock and never accumulates drift.
46
+ */
47
+ var Transport = class {
48
+ clock;
49
+ duration;
50
+ anchorTime = 0;
51
+ anchorClock = 0;
52
+ _rate = 1;
53
+ _playing = false;
54
+ loop = false;
55
+ constructor(clock, duration) {
56
+ this.clock = clock;
57
+ this.duration = duration;
58
+ }
59
+ get playing() {
60
+ return this._playing;
61
+ }
62
+ get rate() {
63
+ return this._rate;
64
+ }
65
+ time() {
66
+ return this.timeAt(this.clock.now());
67
+ }
68
+ timeAt(clockTime) {
69
+ if (!this._playing) return this.anchorTime;
70
+ return this.clamp(this.anchorTime + (clockTime - this.anchorClock) * this._rate);
71
+ }
72
+ /** Clock time at which composition time `t` plays; the inverse of `timeAt` while playing. */
73
+ clockTimeFor(t) {
74
+ if (!this._playing) return this.clock.now();
75
+ return this.anchorClock + (t - this.anchorTime) / this._rate;
76
+ }
77
+ play() {
78
+ if (this._playing) return;
79
+ if (this.anchorTime >= this.duration && this.duration > 0) this.anchorTime = 0;
80
+ this.anchorClock = this.clock.now();
81
+ this._playing = true;
82
+ }
83
+ pause() {
84
+ if (!this._playing) return;
85
+ this.anchorTime = this.time();
86
+ this._playing = false;
87
+ }
88
+ seek(t) {
89
+ this.anchorTime = this.clamp(t);
90
+ this.anchorClock = this.clock.now();
91
+ }
92
+ setRate(rate) {
93
+ const r = Math.min(4, Math.max(MIN_RATE, rate));
94
+ this.anchorTime = this.time();
95
+ this.anchorClock = this.clock.now();
96
+ this._rate = r;
97
+ }
98
+ /** Advance bookkeeping at the end: loops wrap, otherwise playback stops on the last time. */
99
+ check() {
100
+ if (!this._playing || this.duration <= 0) return null;
101
+ const raw = this.anchorTime + (this.clock.now() - this.anchorClock) * this._rate;
102
+ if (raw < this.duration) return null;
103
+ if (this.loop) {
104
+ this.seek(raw - this.duration >= this.duration ? 0 : raw - this.duration);
105
+ return "looped";
106
+ }
107
+ this.anchorTime = this.duration;
108
+ this._playing = false;
109
+ return "ended";
110
+ }
111
+ clamp(t) {
112
+ return t < 0 ? 0 : t > this.duration ? this.duration : t;
113
+ }
114
+ };
115
+ // src/audio/audio-engine.ts
116
+ /**
117
+ * Schedules audio chunks on the output ahead of the transport. Every play
118
+ * starts a session; a scheduler tick plans the window since the last tick
119
+ * up to the lookahead and schedules each new chunk once, with offset,
120
+ * duration and playback rate derived from the transport. Pause cancels
121
+ * everything; seek and rate changes restart the session.
122
+ */
123
+ var AudioEngine = class {
124
+ deps;
125
+ session = 0;
126
+ timer = null;
127
+ scheduled = /* @__PURE__ */ new Set();
128
+ scheduledUpTo = 0;
129
+ running = false;
130
+ cache = /* @__PURE__ */ new Map();
131
+ cacheSeconds = 0;
132
+ _muted = false;
133
+ _volume = 1;
134
+ constructor(deps) {
135
+ this.deps = deps;
136
+ }
137
+ get muted() {
138
+ return this._muted;
139
+ }
140
+ get volume() {
141
+ return this._volume;
142
+ }
143
+ get active() {
144
+ return this.running;
145
+ }
146
+ setMaster(volume, muted) {
147
+ this._volume = volume;
148
+ this._muted = muted;
149
+ this.deps.output.setMaster(muted ? 0 : volume);
150
+ }
151
+ /** Decode what plays in the first moments after `t`, bounded by a timeout, so play starts on time. */
152
+ prime(t) {
153
+ const plan = buildAudioPlan(this.deps.state(), t, t + AUDIO_PRIME_S, this.deps.resolveNested);
154
+ const all = Promise.all(plan.map((occ) => this.buffer(occ).catch(() => void 0))).then(() => void 0);
155
+ const timeout = new Promise((resolve) => setTimeout(resolve, 500));
156
+ return Promise.race([all, timeout]);
157
+ }
158
+ start() {
159
+ this.session++;
160
+ this.scheduled.clear();
161
+ this.scheduledUpTo = this.deps.transport.time();
162
+ this.running = true;
163
+ this.deps.output.resume();
164
+ this.tick();
165
+ this.timer = this.deps.setInterval(() => this.tick(), 100);
166
+ }
167
+ stop() {
168
+ if (this.timer !== null) this.deps.clearInterval(this.timer);
169
+ this.timer = null;
170
+ this.running = false;
171
+ this.session++;
172
+ this.deps.output.cancelAll();
173
+ }
174
+ /** After a seek, a rate change or a loop: reschedule from the new position. */
175
+ reseek() {
176
+ const wasRunning = this.running;
177
+ this.stop();
178
+ if (wasRunning) this.start();
179
+ }
180
+ tick() {
181
+ if (!this.running) return;
182
+ const now = this.deps.transport.time();
183
+ const horizon = Math.max(now, this.scheduledUpTo) + LOOKAHEAD_S;
184
+ const from = Math.min(now, this.scheduledUpTo);
185
+ const plan = buildAudioPlan(this.deps.state(), from, horizon, this.deps.resolveNested);
186
+ const session = this.session;
187
+ for (const occ of plan) {
188
+ const key = `${occ.id}#${occ.chunk}`;
189
+ if (this.scheduled.has(key)) continue;
190
+ this.scheduled.add(key);
191
+ this.schedule(occ, session, key);
192
+ }
193
+ this.scheduledUpTo = horizon;
194
+ }
195
+ dispose() {
196
+ this.stop();
197
+ this.cache.clear();
198
+ }
199
+ async schedule(occ, session, key) {
200
+ let buffer;
201
+ try {
202
+ buffer = await this.buffer(occ);
203
+ } catch (error) {
204
+ this.deps.onError?.({
205
+ id: occ.id,
206
+ asset: occ.asset,
207
+ error: error instanceof Error ? error : new Error(String(error)),
208
+ fatal: false
209
+ });
210
+ return;
211
+ }
212
+ if (session !== this.session) return;
213
+ const transport = this.deps.transport;
214
+ const now = transport.time();
215
+ const startComp = Math.max(occ.compStart, now);
216
+ if (startComp >= occ.compEnd) return;
217
+ const ctxTime = (t) => this.deps.toContextTime(transport.clockTimeFor(t));
218
+ const envelope = occ.gain.map(([t, value]) => ({
219
+ at: ctxTime(t),
220
+ value
221
+ }));
222
+ const pan = occ.pan?.map(([t, value]) => ({
223
+ at: ctxTime(t),
224
+ value
225
+ }));
226
+ const keys = occ.rate?.map(([t, speed]) => ({
227
+ at: t - occ.compStart,
228
+ speed
229
+ }));
230
+ const played = (t) => keys ? rampAdvance(keys, t - occ.compStart) : (t - occ.compStart) * occ.speed;
231
+ const offset = played(startComp);
232
+ this.deps.output.schedule({
233
+ key: `${key}#${session}`,
234
+ buffer,
235
+ when: ctxTime(startComp),
236
+ offset,
237
+ duration: played(occ.compEnd) - offset,
238
+ playbackRate: (keys ? rampSpeed(keys, startComp - occ.compStart) : occ.speed) * transport.rate,
239
+ ...occ.rate ? { rate: occ.rate.map(([t, value]) => ({
240
+ at: ctxTime(t),
241
+ value: value * transport.rate
242
+ })) } : {},
243
+ envelope,
244
+ ...pan ? { pan } : {}
245
+ });
246
+ }
247
+ buffer(occ) {
248
+ const key = `${occ.asset}|${occ.bufferFrom}|${occ.bufferTo}${occ.reverse ? "|r" : ""}`;
249
+ const hit = this.cache.get(key);
250
+ if (hit) {
251
+ this.cache.delete(key);
252
+ this.cache.set(key, hit);
253
+ return hit.buffer;
254
+ }
255
+ const seconds = occ.bufferTo - occ.bufferFrom;
256
+ const buffer = this.deps.audioSource(occ.asset).then((s) => s.range(occ.bufferFrom, occ.bufferTo)).then((b) => {
257
+ if (occ.reverse) for (let ch = 0; ch < b.numberOfChannels; ch++) b.getChannelData(ch).reverse();
258
+ return b;
259
+ });
260
+ buffer.catch(() => {
261
+ this.cache.delete(key);
262
+ this.cacheSeconds -= seconds;
263
+ });
264
+ this.cache.set(key, {
265
+ buffer,
266
+ seconds
267
+ });
268
+ this.cacheSeconds += seconds;
269
+ while (this.cacheSeconds > 600 && this.cache.size > 1) {
270
+ const [oldest, entry] = this.cache.entries().next().value;
271
+ this.cache.delete(oldest);
272
+ this.cacheSeconds -= entry.seconds;
273
+ }
274
+ return buffer;
275
+ }
276
+ };
277
+ // src/audio/web-audio-output.ts
278
+ /** Web Audio graph: per scheduled chunk `source → gain(envelope) → [pan] → master → destination`. */
279
+ var WebAudioOutput = class {
280
+ ctx;
281
+ master;
282
+ live = /* @__PURE__ */ new Map();
283
+ constructor(ctx) {
284
+ this.ctx = ctx;
285
+ this.master = ctx.createGain();
286
+ this.master.connect(ctx.destination);
287
+ }
288
+ get contextTime() {
289
+ return this.ctx.currentTime;
290
+ }
291
+ get running() {
292
+ return this.ctx.state === "running";
293
+ }
294
+ schedule(src) {
295
+ this.cancel(src.key);
296
+ const source = this.ctx.createBufferSource();
297
+ source.buffer = src.buffer;
298
+ automate(source.playbackRate, src.rate ?? [], src.when, src.playbackRate);
299
+ const gain = this.ctx.createGain();
300
+ automate(gain.gain, src.envelope, src.when, 1);
301
+ const nodes = [gain];
302
+ let tail = gain;
303
+ if (src.pan && src.pan.length > 0 && typeof this.ctx.createStereoPanner === "function") {
304
+ const panner = this.ctx.createStereoPanner();
305
+ automate(panner.pan, src.pan, src.when, 0);
306
+ tail.connect(panner);
307
+ tail = panner;
308
+ nodes.push(panner);
309
+ }
310
+ source.connect(gain);
311
+ tail.connect(this.master);
312
+ const when = Math.max(src.when, this.ctx.currentTime);
313
+ const late = when - src.when;
314
+ const offset = src.offset + late * src.playbackRate;
315
+ const duration = src.duration - late * src.playbackRate;
316
+ if (duration <= 0) return;
317
+ source.onended = () => this.drop(src.key, source);
318
+ source.start(when, offset, duration);
319
+ this.live.set(src.key, {
320
+ source,
321
+ nodes
322
+ });
323
+ }
324
+ cancel(keyPrefix) {
325
+ for (const [key, entry] of this.live) {
326
+ if (!key.startsWith(keyPrefix)) continue;
327
+ this.stop(entry);
328
+ this.live.delete(key);
329
+ }
330
+ }
331
+ cancelAll() {
332
+ for (const entry of this.live.values()) this.stop(entry);
333
+ this.live.clear();
334
+ }
335
+ setMaster(gain) {
336
+ this.master.gain.cancelScheduledValues(this.ctx.currentTime);
337
+ this.master.gain.setTargetAtTime(gain, this.ctx.currentTime, .01);
338
+ }
339
+ async resume() {
340
+ if (this.ctx.state === "suspended") try {
341
+ await this.ctx.resume();
342
+ } catch {}
343
+ }
344
+ drop(key, source) {
345
+ const entry = this.live.get(key);
346
+ if (entry && entry.source === source) {
347
+ this.disconnect(entry);
348
+ this.live.delete(key);
349
+ }
350
+ }
351
+ stop(entry) {
352
+ entry.source.onended = null;
353
+ try {
354
+ entry.source.stop();
355
+ } catch {}
356
+ this.disconnect(entry);
357
+ }
358
+ disconnect(entry) {
359
+ entry.source.disconnect();
360
+ for (const n of entry.nodes) n.disconnect();
361
+ }
362
+ };
363
+ /** Set an AudioParam from envelope points: the value at `when` first, then linear ramps for later points. */
364
+ function automate(param, points, when, fallback) {
365
+ if (points.length === 0) {
366
+ param.value = fallback;
367
+ return;
368
+ }
369
+ const sorted = [...points].sort((a, b) => a.at - b.at);
370
+ param.setValueAtTime(valueAt(sorted, when, fallback), Math.max(0, when));
371
+ for (const p of sorted) if (p.at > when) param.linearRampToValueAtTime(p.value, p.at);
372
+ }
373
+ function valueAt(points, at, fallback) {
374
+ const first = points[0], last = points[points.length - 1];
375
+ if (!first || !last) return fallback;
376
+ if (at <= first.at) return first.value;
377
+ if (at >= last.at) return last.value;
378
+ for (let i = 0; i < points.length - 1; i++) {
379
+ const a = points[i], b = points[i + 1];
380
+ if (at >= a.at && at < b.at) return b.at === a.at ? b.value : a.value + (b.value - a.value) * (at - a.at) / (b.at - a.at);
381
+ }
382
+ return last.value;
383
+ }
384
+ // src/player.ts
385
+ /**
386
+ * The framework-free player: owns the transport, the compositor (canvas),
387
+ * the audio engine and the media sources. React's `<Player>` is a thin
388
+ * wrapper over it; a headless exporter can drive `renderFrame` directly.
389
+ */
390
+ var PlayerCore = class {
391
+ _state;
392
+ media;
393
+ clock;
394
+ transport;
395
+ compositor;
396
+ engine;
397
+ sources;
398
+ inputs;
399
+ audioContext;
400
+ audioSources = /* @__PURE__ */ new Map();
401
+ listeners = /* @__PURE__ */ new Map();
402
+ timeListeners = /* @__PURE__ */ new Set();
403
+ createCanvas;
404
+ raf;
405
+ cancelRaf;
406
+ rafId = null;
407
+ setTimeoutFn;
408
+ clearTimeoutFn;
409
+ settleTimer = null;
410
+ seekGeneration = 0;
411
+ snapshot = {
412
+ time: 0,
413
+ frame: 0
414
+ };
415
+ _muted;
416
+ _volume = 1;
417
+ disposed = false;
418
+ constructor(options) {
419
+ this._state = options.state;
420
+ this.media = toResolver(options.media);
421
+ const comp = options.state.composition;
422
+ const wantsBrowserAudio = !options.audioOutput || !options.clock || !options.audioSourceFactory;
423
+ this.audioContext = wantsBrowserAudio && typeof AudioContext !== "undefined" ? options.audioContext ?? new AudioContext({ sampleRate: Number(options.state.items.get(comp.id)?.props.sampleRate ?? 48e3) }) : options.audioContext ?? null;
424
+ const audioClock = this.audioContext && !options.clock ? new AudioContextClock(this.audioContext) : null;
425
+ this.clock = options.clock ?? audioClock ?? new PerformanceClock();
426
+ const toContextTime = options.toContextTime ?? (audioClock ? (t) => audioClock.toContextTime(t) : (t) => t);
427
+ this.transport = new Transport(this.clock, comp.duration);
428
+ this.transport.loop = options.loop ?? false;
429
+ if (options.playbackRate !== void 0) this.transport.setRate(options.playbackRate);
430
+ this.raf = options.raf ?? ((cb) => requestAnimationFrame(cb));
431
+ this.cancelRaf = options.cancelRaf ?? ((id) => cancelAnimationFrame(id));
432
+ this.setTimeoutFn = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
433
+ this.clearTimeoutFn = options.clearTimeout ?? ((id) => clearTimeout(id));
434
+ const createCanvas = options.createCanvas ?? defaultCreateCanvas;
435
+ this.createCanvas = createCanvas;
436
+ this.inputs = options.frameSourceFactory && options.audioSourceFactory ? null : new MediabunnyInputs();
437
+ const frameSourceFactory = options.frameSourceFactory ?? mediabunnyFrameSourceFactory(this.inputs, createCanvas);
438
+ const audioSourceFactory = options.audioSourceFactory ?? mediabunnyAudioSourceFactory(this.inputs);
439
+ const resolveNested = options.resolveNested ?? makeNestedResolver(() => this._state);
440
+ this.sources = new AssetSources({
441
+ media: this.media,
442
+ manifest: options.state.manifest,
443
+ frameSourceFactory,
444
+ clock: this.clock,
445
+ onError: (e) => this.emit("error", e)
446
+ });
447
+ this.compositor = new Compositor(options.state, {
448
+ sources: this.sources,
449
+ createCanvas,
450
+ resolveNested,
451
+ onFrame: (time, frame) => this.publish(time, frame)
452
+ });
453
+ this.compositor.setRate(this.transport.rate);
454
+ const output = options.audioOutput ?? (this.audioContext ? new WebAudioOutput(this.audioContext) : null);
455
+ if (!output) throw new Error("PlayerCore needs an AudioOutput: no AudioContext in this environment");
456
+ this.engine = new AudioEngine({
457
+ output,
458
+ transport: this.transport,
459
+ toContextTime,
460
+ state: () => this._state,
461
+ resolveNested,
462
+ audioSource: (assetId) => this.audioSource(assetId, audioSourceFactory),
463
+ setInterval: options.setInterval ?? ((fn, ms) => setInterval(fn, ms)),
464
+ clearInterval: options.clearInterval ?? ((id) => clearInterval(id)),
465
+ onError: (e) => this.emit("error", e)
466
+ });
467
+ this._muted = options.initiallyMuted ?? false;
468
+ this.engine.setMaster(this._volume, this._muted);
469
+ }
470
+ get state() {
471
+ return this._state;
472
+ }
473
+ get duration() {
474
+ return this.transport.duration;
475
+ }
476
+ get playbackRate() {
477
+ return this.transport.rate;
478
+ }
479
+ set playbackRate(rate) {
480
+ const r = Math.min(4, Math.max(MIN_RATE, rate));
481
+ if (r === this.transport.rate) return;
482
+ this.transport.setRate(r);
483
+ this.compositor.setRate(r);
484
+ if (this.transport.playing) this.engine.reseek();
485
+ this.emit("ratechange", { rate: r });
486
+ }
487
+ get loop() {
488
+ return this.transport.loop;
489
+ }
490
+ set loop(v) {
491
+ this.transport.loop = v;
492
+ }
493
+ get muted() {
494
+ return this._muted;
495
+ }
496
+ set muted(v) {
497
+ if (v === this._muted) return;
498
+ this._muted = v;
499
+ this.engine.setMaster(this._volume, v);
500
+ this.emit("volumechange", {
501
+ volume: this._volume,
502
+ muted: v
503
+ });
504
+ }
505
+ get volume() {
506
+ return this._volume;
507
+ }
508
+ set volume(v) {
509
+ const vol = Math.min(1, Math.max(0, v));
510
+ if (vol === this._volume) return;
511
+ this._volume = vol;
512
+ this.engine.setMaster(vol, this._muted);
513
+ this.emit("volumechange", {
514
+ volume: vol,
515
+ muted: this._muted
516
+ });
517
+ }
518
+ /** The media handle for an asset id, for DOM elements that show images. */
519
+ resolveMedia(assetId) {
520
+ return this.media.resolve(assetId);
521
+ }
522
+ getCurrentTime() {
523
+ return this.transport.time();
524
+ }
525
+ getCurrentFrame() {
526
+ return frameIndex(this.transport.time(), this._state.composition.fps);
527
+ }
528
+ isPlaying() {
529
+ return this.transport.playing;
530
+ }
531
+ getTime() {
532
+ return this.snapshot;
533
+ }
534
+ async play() {
535
+ if (this.disposed || this.transport.playing) return;
536
+ const t = this.transport.time() >= this.duration ? 0 : this.transport.time();
537
+ await this.engine.prime(t);
538
+ if (this.disposed || this.transport.playing) return;
539
+ if (this.transport.time() >= this.duration) {
540
+ this.transport.seek(0);
541
+ await this.compositor.seek(0);
542
+ }
543
+ this.transport.play();
544
+ this.engine.start();
545
+ this.startLoop();
546
+ this.emit("play", { time: this.transport.time() });
547
+ }
548
+ pause() {
549
+ if (!this.transport.playing) return;
550
+ this.transport.pause();
551
+ this.engine.stop();
552
+ this.stopLoop();
553
+ this.emit("pause", { time: this.transport.time() });
554
+ }
555
+ toggle() {
556
+ if (this.transport.playing) {
557
+ this.pause();
558
+ return Promise.resolve();
559
+ }
560
+ return this.play();
561
+ }
562
+ /** Jump to `time` and draw its exact frame before resolving, playing or not. */
563
+ async seekTo(time) {
564
+ if (this.disposed) return;
565
+ const generation = ++this.seekGeneration;
566
+ this.transport.seek(time);
567
+ const t = this.transport.time();
568
+ if (this.transport.playing) this.engine.reseek();
569
+ await this.compositor.seek(t);
570
+ if (this.disposed || generation !== this.seekGeneration) return;
571
+ await this.compositor.tick(t, { wait: true });
572
+ if (this.disposed || generation !== this.seekGeneration) return;
573
+ this.publish(t, frameIndex(t, this._state.composition.fps));
574
+ this.emit("seeked", { time: t });
575
+ }
576
+ seekToFrame(frame) {
577
+ return this.seekTo(frameTime(frame, this._state.composition.fps));
578
+ }
579
+ /** Draw `time` without moving the transport; decoders return to the play head afterwards. */
580
+ async renderFrame(time) {
581
+ if (this.disposed) return;
582
+ const t = Math.min(this.duration, Math.max(0, time));
583
+ await this.compositor.seek(t);
584
+ await this.compositor.tick(t, { wait: true });
585
+ if (this.transport.playing) await this.compositor.seek(this.transport.time());
586
+ }
587
+ /**
588
+ * A contact sheet of the canvas plane at `times`: each cell is `renderFrame`
589
+ * of that time with a timecode label, laid out `columns` wide. The way an
590
+ * agent or a test looks at the result without exporting. DOM overlays
591
+ * (titles, shapes, Layer interiors) are not on the canvas and do not appear.
592
+ */
593
+ async captureSheet(times, options = {}) {
594
+ const comp = this._state.composition;
595
+ const count = times.length;
596
+ const columns = Math.max(1, Math.min(Math.floor(options.columns ?? 4), Math.max(1, count)));
597
+ const cellWidth = Math.max(1, Math.round(options.cellWidth ?? Math.min(comp.width, DEFAULT_SHEET_CELL_WIDTH)));
598
+ const cellHeight = Math.max(1, Math.round(cellWidth * comp.height / comp.width));
599
+ const rows = Math.max(1, Math.ceil(count / columns));
600
+ const sheet = this.createCanvas(columns * cellWidth, rows * cellHeight);
601
+ const ctx = sheet.getContext("2d");
602
+ const scratch = this.createCanvas(comp.width, comp.height);
603
+ const scratchCtx = scratch.getContext("2d");
604
+ if (!ctx || !scratchCtx) throw new Error("captureSheet: no 2d context");
605
+ const previous = this.compositor.attachedContext;
606
+ this.compositor.attach(scratchCtx);
607
+ try {
608
+ for (let i = 0; i < count; i++) {
609
+ const t = times[i];
610
+ await this.renderFrame(t);
611
+ const x = i % columns * cellWidth;
612
+ const y = Math.floor(i / columns) * cellHeight;
613
+ ctx.drawImage(scratch, x, y, cellWidth, cellHeight);
614
+ if (options.labels !== false) drawLabel(ctx, timecode(Math.min(this.duration, Math.max(0, t)), comp.fps), x, y, cellWidth, cellHeight);
615
+ }
616
+ } finally {
617
+ this.compositor.attach(previous);
618
+ if (previous && !this.disposed && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
619
+ }
620
+ return sheet;
621
+ }
622
+ /**
623
+ * The editor pushes a new resolve on every drag step. Decoders of the same
624
+ * asset keep their frames and take the new trim; a paused preview paints at
625
+ * once with what is decoded and fetches the exact frame after the gesture
626
+ * has been quiet for `SETTLE_MS`.
627
+ */
628
+ setState(state) {
629
+ this._state = state;
630
+ this.transport.duration = state.composition.duration;
631
+ this.compositor.setState(state);
632
+ if (this.transport.playing) return;
633
+ this.compositor.tick(this.transport.time(), { wait: false });
634
+ if (this.settleTimer !== null) this.clearTimeoutFn(this.settleTimer);
635
+ this.settleTimer = this.setTimeoutFn(() => {
636
+ this.settleTimer = null;
637
+ if (!this.disposed && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
638
+ }, 120);
639
+ }
640
+ attachCanvas(canvas) {
641
+ const ctx = canvas ? canvas.getContext("2d") : null;
642
+ if (canvas) {
643
+ canvas.width = this._state.composition.width;
644
+ canvas.height = this._state.composition.height;
645
+ }
646
+ this.compositor.attach(ctx);
647
+ if (ctx && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
648
+ }
649
+ addEventListener(type, fn) {
650
+ let set = this.listeners.get(type);
651
+ if (!set) {
652
+ set = /* @__PURE__ */ new Set();
653
+ this.listeners.set(type, set);
654
+ }
655
+ set.add(fn);
656
+ return () => this.removeEventListener(type, fn);
657
+ }
658
+ removeEventListener(type, fn) {
659
+ this.listeners.get(type)?.delete(fn);
660
+ }
661
+ /** For `useSyncExternalStore`: notified once per painted frame and on every seek. */
662
+ subscribeTime(fn) {
663
+ this.timeListeners.add(fn);
664
+ return () => {
665
+ this.timeListeners.delete(fn);
666
+ };
667
+ }
668
+ dispose() {
669
+ if (this.disposed) return;
670
+ this.pause();
671
+ this.disposed = true;
672
+ if (this.settleTimer !== null) this.clearTimeoutFn(this.settleTimer);
673
+ this.settleTimer = null;
674
+ this.engine.dispose();
675
+ this.compositor.dispose();
676
+ this.sources.dispose();
677
+ for (const p of this.audioSources.values()) p.then((s) => s.close()).catch(() => void 0);
678
+ this.audioSources.clear();
679
+ this.inputs?.dispose();
680
+ this.listeners.clear();
681
+ this.timeListeners.clear();
682
+ }
683
+ startLoop() {
684
+ if (this.rafId !== null) return;
685
+ const tick = () => {
686
+ this.rafId = null;
687
+ if (this.disposed || !this.transport.playing) return;
688
+ const result = this.transport.check();
689
+ const t = this.transport.time();
690
+ if (result === "ended") {
691
+ this.engine.stop();
692
+ this.compositor.tick(t, { wait: true }).then(() => this.emit("ended", { time: t }));
693
+ return;
694
+ }
695
+ if (result === "looped") {
696
+ this.compositor.seek(t);
697
+ this.engine.reseek();
698
+ }
699
+ this.compositor.tick(t, { wait: false });
700
+ this.rafId = this.raf(tick);
701
+ };
702
+ this.rafId = this.raf(tick);
703
+ }
704
+ stopLoop() {
705
+ if (this.rafId !== null) this.cancelRaf(this.rafId);
706
+ this.rafId = null;
707
+ }
708
+ publish(time, frame) {
709
+ if (this.snapshot.time === time && this.snapshot.frame === frame) return;
710
+ this.snapshot = {
711
+ time,
712
+ frame
713
+ };
714
+ for (const fn of this.timeListeners) fn();
715
+ this.emit("frameupdate", {
716
+ time,
717
+ frame
718
+ });
719
+ }
720
+ emit(type, event) {
721
+ const set = this.listeners.get(type);
722
+ if (!set) return;
723
+ for (const fn of [...set]) fn(event);
724
+ }
725
+ audioSource(assetId, factory) {
726
+ let p = this.audioSources.get(assetId);
727
+ if (!p) {
728
+ p = this.media.resolve(assetId).then(async (handle) => {
729
+ const source = factory(assetId, handle, this.audioContext ?? { createBuffer: () => {
730
+ throw new Error("no AudioContext");
731
+ } });
732
+ if (!(await source.open()).decodable) throw new Error(`audio of "${assetId}" cannot be decoded`);
733
+ return source;
734
+ });
735
+ this.audioSources.set(assetId, p);
736
+ p.catch(() => void 0);
737
+ }
738
+ return p;
739
+ }
740
+ };
741
+ const DEFAULT_SHEET_CELL_WIDTH = 320;
742
+ function drawLabel(ctx, text, x, y, w, h) {
743
+ const band = Math.max(12, Math.round(h * .1));
744
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
745
+ ctx.globalAlpha = 1;
746
+ ctx.globalCompositeOperation = "source-over";
747
+ ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
748
+ ctx.fillRect(x, y + h - band, w, band);
749
+ ctx.fillStyle = "#ffffff";
750
+ ctx.font = `${Math.round(band * .7)}px monospace`;
751
+ ctx.textBaseline = "middle";
752
+ ctx.fillText(text, x + Math.round(band / 3), y + h - band / 2);
753
+ }
754
+ // src/react/context.ts
755
+ const PlayerContext = createContext(null);
756
+ /** The layer's clock inside a `<Layer>`; null outside one. */
757
+ const LayerContext = createContext(null);
758
+ // src/react/hooks.ts
759
+ function usePlayer() {
760
+ const core = useContext(PlayerContext);
761
+ if (!core) throw new Error("usePlayer() must be used inside <Player>");
762
+ return core;
763
+ }
764
+ function useCompositionTime() {
765
+ const core = usePlayer();
766
+ return useSyncExternalStore((fn) => core.subscribeTime(fn), () => core.getTime().time, () => core.getTime().time);
767
+ }
768
+ function useCompositionFrame() {
769
+ const core = usePlayer();
770
+ return useSyncExternalStore((fn) => core.subscribeTime(fn), () => core.getTime().frame, () => core.getTime().frame);
771
+ }
772
+ /** What the kit's `useLayer()` and `useT()` delegate to while a Player is mounted. */
773
+ function useLayerClock() {
774
+ const clock = useContext(LayerContext);
775
+ if (!clock) throw new Error("useLayer() and useT() are only valid inside a <Layer>");
776
+ return clock;
777
+ }
778
+ /** 0..1 progress through the enclosing Layer. */
779
+ function useLayerT() {
780
+ return useLayerClock().t;
781
+ }
782
+ let mounted = 0;
783
+ /** Installs the kit's layer hooks for the first mounted Player and removes them after the last unmounts. */
784
+ function retainUseT() {
785
+ if (mounted++ === 0) installLayerClock(useLayerClock);
786
+ return () => {
787
+ if (--mounted === 0) installLayerClock(null);
788
+ };
789
+ }
790
+ // src/react/elements/Adjustment.tsx
791
+ /**
792
+ * The painter's adjustment op filters the footage; overlays stay unfiltered, as in the export.
793
+ * A backdrop filter here would filter the canvas a second time.
794
+ */
795
+ function Adjustment(p) {
796
+ return /* @__PURE__ */ jsx("div", {
797
+ ...dataAttrs(p.placement),
798
+ style: {
799
+ position: "absolute",
800
+ inset: 0,
801
+ pointerEvents: "none"
802
+ }
803
+ });
804
+ }
805
+ // src/react/elements/Captions.tsx
806
+ /** The active caption lines, positioned by the captions style; karaoke colours words by their timing. */
807
+ function Captions(p) {
808
+ return captionsContent(p);
809
+ }
810
+ // src/react/elements/Layer.tsx
811
+ /** Arbitrary React inside the composition, driven by `useLayer()` and `useT()` through LayerContext. */
812
+ function Layer(p) {
813
+ const comp = p.state.composition;
814
+ const style = overlayStyle(p, {
815
+ width: comp.width,
816
+ height: comp.height
817
+ });
818
+ return /* @__PURE__ */ jsx("div", {
819
+ ...dataAttrs(p.placement),
820
+ style,
821
+ children: /* @__PURE__ */ jsx(LayerContext.Provider, {
822
+ value: layerClock(p.placement, p.time, comp.fps),
823
+ children: layerContent(p.interior, p.placement.interior?.props)
824
+ })
825
+ });
826
+ }
827
+ // src/react/elements/Shape.tsx
828
+ /** A vector shape, drawn from the same markup as the export, with room around it for its stroke and shadows. */
829
+ function Shape(p) {
830
+ const d = shapeDrawing(p);
831
+ for (const w of d.prepared.warnings) devWarn(`shape:${p.placement.id}:${w}`, `shape "${p.placement.id}": ${w}`);
832
+ return /* @__PURE__ */ jsx("div", {
833
+ ...dataAttrs(p.placement),
834
+ style: layoutStyle(d.layout),
835
+ children: /* @__PURE__ */ jsx("div", {
836
+ style: {
837
+ position: "absolute",
838
+ left: -d.bleed,
839
+ top: -d.bleed,
840
+ lineHeight: 0
841
+ },
842
+ dangerouslySetInnerHTML: { __html: d.prepared.markup }
843
+ })
844
+ });
845
+ }
846
+ // src/react/elements/Graphic.tsx
847
+ /** A built-in graphic: the kind's art from the same markup as the export, its labels as text above it. */
848
+ function Graphic(p) {
849
+ const d = graphicDrawing(p);
850
+ for (const w of d.prepared.warnings) devWarn(`graphic:${p.placement.id}:${w}`, `graphic "${p.placement.id}": ${w}`);
851
+ return /* @__PURE__ */ jsxs("div", {
852
+ ...dataAttrs(p.placement),
853
+ style: layoutStyle(d.layout),
854
+ children: [/* @__PURE__ */ jsx("div", {
855
+ style: {
856
+ position: "absolute",
857
+ left: 0,
858
+ top: 0,
859
+ lineHeight: 0
860
+ },
861
+ dangerouslySetInnerHTML: { __html: d.prepared.markup }
862
+ }), graphicLabels(d.labels)]
863
+ });
864
+ }
865
+ // src/react/elements/Svg.tsx
866
+ /** Inline SVG markup, or an SVG asset read as text, prepared the way the export prepares it. */
867
+ function Svg(p) {
868
+ const props = p.placement.props;
869
+ const content = typeof props.content === "string" ? props.content : null;
870
+ const text = useMediaText(content === null && typeof props.src === "string" ? props.src : null);
871
+ const markup = content ?? text;
872
+ if (markup === null) return /* @__PURE__ */ jsx("div", {
873
+ ...dataAttrs(p.placement),
874
+ style: overlayStyle(p, {
875
+ width: 0,
876
+ height: 0
877
+ })
878
+ });
879
+ const drawing = svgDrawing(p, markup);
880
+ for (const w of drawing.prepared.warnings) devWarn(`svg:${p.placement.id}:${w}`, `svg "${p.placement.id}": ${w}`);
881
+ return /* @__PURE__ */ jsx("div", {
882
+ ...dataAttrs(p.placement),
883
+ style: {
884
+ ...layoutStyle(drawing.layout),
885
+ lineHeight: 0
886
+ },
887
+ dangerouslySetInnerHTML: { __html: drawing.prepared.markup }
888
+ });
889
+ }
890
+ /** Resolves an `asset:` reference to an object URL or a plain URL. */
891
+ function useMediaUrl(ref) {
892
+ const core = usePlayer();
893
+ const [url, setUrl] = useState(null);
894
+ useEffect(() => {
895
+ if (!ref) {
896
+ setUrl(null);
897
+ return;
898
+ }
899
+ const id = ref.startsWith("asset:") ? ref.slice(6) : ref;
900
+ let revoke = null;
901
+ let cancelled = false;
902
+ core.resolveMedia(id).then((handle) => {
903
+ if (cancelled) return;
904
+ if (handle.kind === "url") setUrl(handle.url);
905
+ else if (handle.kind === "blob") {
906
+ revoke = URL.createObjectURL(handle.blob);
907
+ setUrl(revoke);
908
+ } else setUrl(null);
909
+ }).catch(() => {
910
+ if (!cancelled) setUrl(null);
911
+ });
912
+ return () => {
913
+ cancelled = true;
914
+ if (revoke) URL.revokeObjectURL(revoke);
915
+ };
916
+ }, [core, ref]);
917
+ return url;
918
+ }
919
+ /** Reads an `asset:` reference as text, for markup that is inlined rather than loaded as an image. */
920
+ function useMediaText(ref) {
921
+ const core = usePlayer();
922
+ const [text, setText] = useState(null);
923
+ useEffect(() => {
924
+ if (!ref) {
925
+ setText(null);
926
+ return;
927
+ }
928
+ const id = ref.startsWith("asset:") ? ref.slice(6) : ref;
929
+ let cancelled = false;
930
+ core.resolveMedia(id).then((handle) => handle.kind === "url" ? fetch(handle.url).then((r) => r.text()) : handle.kind === "blob" ? handle.blob.text() : null).then((t) => {
931
+ if (!cancelled) setText(t);
932
+ }).catch(() => {
933
+ if (!cancelled) setText(null);
934
+ });
935
+ return () => {
936
+ cancelled = true;
937
+ };
938
+ }, [core, ref]);
939
+ return text;
940
+ }
941
+ // src/react/elements/Sticker.tsx
942
+ /** An image asset, or a Lucide icon for `icon:name`; other `src` schemes (sticker packs) show a labelled placeholder. */
943
+ function Sticker(p) {
944
+ const props = p.placement.props;
945
+ const src = String(props.src ?? "");
946
+ const isAsset = src.startsWith("asset:");
947
+ const url = useMediaUrl(isAsset ? src : null);
948
+ const icon = iconStickerDrawing(p, stickerSize(p));
949
+ if (icon) return /* @__PURE__ */ jsx("div", {
950
+ ...dataAttrs(p.placement),
951
+ style: {
952
+ ...layoutStyle(icon.layout),
953
+ lineHeight: 0
954
+ },
955
+ dangerouslySetInnerHTML: { __html: icon.prepared.markup }
956
+ });
957
+ if (!isAsset) devWarn(`sticker:${p.placement.id}`, `sticker "${src}" on "${p.placement.id}" has no media; showing a placeholder`);
958
+ const size = stickerSize(p);
959
+ const style = overlayStyle(p, {
960
+ width: size,
961
+ height: size
962
+ });
963
+ return /* @__PURE__ */ jsx("div", {
964
+ ...dataAttrs(p.placement),
965
+ style: {
966
+ ...style,
967
+ display: "flex",
968
+ alignItems: "center",
969
+ justifyContent: "center"
970
+ },
971
+ children: isAsset && url ? /* @__PURE__ */ jsx("img", {
972
+ src: url,
973
+ alt: "",
974
+ style: {
975
+ width: "100%",
976
+ height: "100%",
977
+ objectFit: "contain"
978
+ }
979
+ }) : stickerPlaceholder(String(props.name ?? src))
980
+ });
981
+ }
982
+ // src/react/elements/Title.tsx
983
+ /** Text title: the file's text content with font props; entrance/exit presets as CSS transforms. */
984
+ function Title(p) {
985
+ const comp = p.state.composition;
986
+ return /* @__PURE__ */ jsx("div", {
987
+ ...dataAttrs(p.placement),
988
+ style: overlayStyle(p, titleBox(p.placement.props, comp)),
989
+ children: titleContent(p)
990
+ });
991
+ }
992
+ // src/react/DomLayer.tsx
993
+ /** Overlay elements as DOM, one absolutely positioned group per track in compositing order, above the canvas (or, for the backdrop, below it). */
994
+ function DomLayer({ state, interiors, layer = "front" }) {
995
+ const time = useCompositionTime();
996
+ const items = visiblePlacements(state, time).filter((v) => OVERLAY_KINDS.has(v.placement.kind));
997
+ const byTrack = /* @__PURE__ */ new Map();
998
+ for (const item of items) {
999
+ const list = byTrack.get(item.track.id) ?? [];
1000
+ list.push(item);
1001
+ byTrack.set(item.track.id, list);
1002
+ }
1003
+ const backdrop = backdropTracks(state);
1004
+ const tracks = state.tracks.filter((t) => backdrop.has(t.id) === (layer === "backdrop"));
1005
+ const captions = layer === "front" ? [...state.items.values()].filter((p) => p.kind === "captions") : [];
1006
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [tracks.map((track) => {
1007
+ const list = byTrack.get(track.id);
1008
+ if (!list) return null;
1009
+ return /* @__PURE__ */ jsx("div", {
1010
+ "data-track": track.id,
1011
+ style: {
1012
+ position: "absolute",
1013
+ inset: 0,
1014
+ zIndex: track.order + 1,
1015
+ pointerEvents: "none"
1016
+ },
1017
+ children: list.map((item) => /* @__PURE__ */ jsx(Element, {
1018
+ item,
1019
+ state,
1020
+ time,
1021
+ interior: interiors.get(item.placement.id)
1022
+ }, item.placement.id))
1023
+ }, track.id);
1024
+ }), captions.map((c) => /* @__PURE__ */ jsx("div", {
1025
+ style: {
1026
+ position: "absolute",
1027
+ inset: 0,
1028
+ zIndex: state.tracks.length + 1,
1029
+ pointerEvents: "none"
1030
+ },
1031
+ children: /* @__PURE__ */ jsx(Captions, {
1032
+ placement: c,
1033
+ state,
1034
+ time
1035
+ })
1036
+ }, c.id))] });
1037
+ }
1038
+ /** Re-renders only when the element's own relative frame or placement changes. */
1039
+ const Element = memo(function Element({ item, state, time, interior }) {
1040
+ const props = {
1041
+ placement: item.placement,
1042
+ children: item.children,
1043
+ state,
1044
+ time,
1045
+ transition: item.transition
1046
+ };
1047
+ switch (item.placement.kind) {
1048
+ case "title": return /* @__PURE__ */ jsx(Title, {
1049
+ ...props,
1050
+ text: state.tree.byId.get(item.placement.id)?.text ?? ""
1051
+ });
1052
+ case "shape": return /* @__PURE__ */ jsx(Shape, { ...props });
1053
+ case "svg": return /* @__PURE__ */ jsx(Svg, { ...props });
1054
+ case "sticker": return /* @__PURE__ */ jsx(Sticker, { ...props });
1055
+ case "graphic": return /* @__PURE__ */ jsx(Graphic, { ...props });
1056
+ case "layer": return /* @__PURE__ */ jsx(Layer, {
1057
+ ...props,
1058
+ interior
1059
+ });
1060
+ case "adjustment": return /* @__PURE__ */ jsx(Adjustment, { ...props });
1061
+ default: return null;
1062
+ }
1063
+ }, (a, b) => sameElement(a, b));
1064
+ /**
1065
+ * Whether two renders of an overlay would produce the same DOM. Placements
1066
+ * are new objects on every resolve, so compare what matters: timing, the
1067
+ * parsed props (referentially stable while untouched), children, the
1068
+ * element-relative frame, the transition progress, the text and the
1069
+ * composition box.
1070
+ */
1071
+ function sameElement(a, b) {
1072
+ const p = a.item.placement, q = b.item.placement;
1073
+ if (p !== q && (p.id !== q.id || p.kind !== q.kind || p.start !== q.start || p.end !== q.end || p.props !== q.props || p.interior !== q.interior)) return false;
1074
+ if (a.interior !== b.interior) return false;
1075
+ if (a.item.children.length !== b.item.children.length || a.item.children.some((c, i) => c !== b.item.children[i] && (c.props !== b.item.children[i].props || c.start !== b.item.children[i].start))) return false;
1076
+ const ca = a.state.composition, cb = b.state.composition;
1077
+ if (ca.width !== cb.width || ca.height !== cb.height || ca.fps !== cb.fps) return false;
1078
+ if (p.kind === "title" && a.state.tree.byId.get(p.id)?.text !== b.state.tree.byId.get(p.id)?.text) return false;
1079
+ if (frameIndex(a.time - p.start, ca.fps) !== frameIndex(b.time - q.start, cb.fps)) return false;
1080
+ return (a.item.transition?.progress ?? -1) === (b.item.transition?.progress ?? -1);
1081
+ }
1082
+ // src/react/Controls.tsx
1083
+ const bar = {
1084
+ display: "flex",
1085
+ alignItems: "center",
1086
+ gap: 8,
1087
+ padding: "6px 8px",
1088
+ background: "rgba(0,0,0,0.75)",
1089
+ color: "#eee",
1090
+ font: "12px system-ui, sans-serif",
1091
+ position: "absolute",
1092
+ left: 0,
1093
+ right: 0,
1094
+ bottom: 0,
1095
+ zIndex: 1e3
1096
+ };
1097
+ const button = {
1098
+ background: "none",
1099
+ border: "1px solid #777",
1100
+ color: "inherit",
1101
+ borderRadius: 4,
1102
+ padding: "2px 8px",
1103
+ cursor: "pointer",
1104
+ font: "inherit"
1105
+ };
1106
+ /** Play/pause, scrubber, time readout, mute and rate. */
1107
+ function Controls() {
1108
+ const core = usePlayer();
1109
+ const time = useCompositionTime();
1110
+ const [playing, setPlaying] = useState(core.isPlaying());
1111
+ const [muted, setMuted] = useState(core.muted);
1112
+ const [rate, setRate] = useState(core.playbackRate);
1113
+ const resumeAfterScrub = useRef(false);
1114
+ useEffect(() => {
1115
+ const offs = [
1116
+ core.addEventListener("play", () => setPlaying(true)),
1117
+ core.addEventListener("pause", () => setPlaying(false)),
1118
+ core.addEventListener("ended", () => setPlaying(false)),
1119
+ core.addEventListener("volumechange", (e) => setMuted(e.muted)),
1120
+ core.addEventListener("ratechange", (e) => setRate(e.rate))
1121
+ ];
1122
+ return () => offs.forEach((off) => off());
1123
+ }, [core]);
1124
+ const fps = core.state.composition.fps;
1125
+ return /* @__PURE__ */ jsxs("div", {
1126
+ style: bar,
1127
+ "data-controls": "",
1128
+ children: [
1129
+ /* @__PURE__ */ jsx("button", {
1130
+ type: "button",
1131
+ style: button,
1132
+ onClick: () => void core.toggle(),
1133
+ "aria-label": playing ? "Pause" : "Play",
1134
+ children: playing ? "❚❚" : "▶"
1135
+ }),
1136
+ /* @__PURE__ */ jsx("input", {
1137
+ type: "range",
1138
+ min: 0,
1139
+ max: core.duration,
1140
+ step: 1 / fps,
1141
+ value: time,
1142
+ "aria-label": "Scrub",
1143
+ style: { flex: 1 },
1144
+ onPointerDown: () => {
1145
+ resumeAfterScrub.current = core.isPlaying();
1146
+ core.pause();
1147
+ },
1148
+ onPointerUp: () => {
1149
+ if (resumeAfterScrub.current) core.play();
1150
+ resumeAfterScrub.current = false;
1151
+ },
1152
+ onChange: (e) => void core.seekTo(Number(e.target.value))
1153
+ }),
1154
+ /* @__PURE__ */ jsxs("span", {
1155
+ style: { fontVariantNumeric: "tabular-nums" },
1156
+ children: [
1157
+ timecode(time, fps),
1158
+ " / ",
1159
+ timecode(core.duration, fps)
1160
+ ]
1161
+ }),
1162
+ /* @__PURE__ */ jsx("button", {
1163
+ type: "button",
1164
+ style: button,
1165
+ onClick: () => {
1166
+ core.muted = !core.muted;
1167
+ },
1168
+ "aria-label": muted ? "Unmute" : "Mute",
1169
+ children: muted ? "🔇" : "🔊"
1170
+ }),
1171
+ /* @__PURE__ */ jsx("select", {
1172
+ value: rate,
1173
+ onChange: (e) => {
1174
+ core.playbackRate = Number(e.target.value);
1175
+ },
1176
+ "aria-label": "Speed",
1177
+ style: {
1178
+ ...button,
1179
+ padding: 2
1180
+ },
1181
+ children: [
1182
+ .5,
1183
+ 1,
1184
+ 1.5,
1185
+ 2
1186
+ ].map((r) => /* @__PURE__ */ jsxs("option", {
1187
+ value: r,
1188
+ children: [r, "×"]
1189
+ }, r))
1190
+ })
1191
+ ]
1192
+ });
1193
+ }
1194
+ // src/react/Player.tsx
1195
+ /**
1196
+ * Canvas for media, DOM for overlays, controls below. The root carries
1197
+ * `data-time` and `data-frame`; every element carries `data-el`/`data-kind`.
1198
+ */
1199
+ const Player = forwardRef(function Player(props, ref) {
1200
+ const { state, media, element, controls, loop, autoPlay, initiallyMuted, playbackRate, fit = "contain", style, className, audioContext, onError, onFrame, coreOptions } = props;
1201
+ const canvasRef = useRef(null);
1202
+ const rootRef = useRef(null);
1203
+ const [core, setCore] = useState(null);
1204
+ const interiors = useMemo(() => indexLayerInteriors(element ?? null), [element]);
1205
+ const [scale, setScale] = useState(1);
1206
+ useLayoutEffect(() => {
1207
+ const c = new PlayerCore({
1208
+ state,
1209
+ media,
1210
+ ...audioContext ? { audioContext } : {},
1211
+ loop: loop ?? false,
1212
+ initiallyMuted: initiallyMuted ?? false,
1213
+ ...playbackRate !== void 0 ? { playbackRate } : {},
1214
+ ...coreOptions ?? {}
1215
+ });
1216
+ const release = retainUseT();
1217
+ setCore(c);
1218
+ return () => {
1219
+ release();
1220
+ c.dispose();
1221
+ };
1222
+ }, [media, audioContext]);
1223
+ useLayoutEffect(() => {
1224
+ if (core && core.state !== state) core.setState(state);
1225
+ }, [core, state]);
1226
+ useLayoutEffect(() => {
1227
+ core?.attachCanvas(canvasRef.current);
1228
+ return () => core?.attachCanvas(null);
1229
+ }, [core]);
1230
+ useEffect(() => {
1231
+ if (core && loop !== void 0) core.loop = loop;
1232
+ }, [core, loop]);
1233
+ useEffect(() => {
1234
+ if (core && playbackRate !== void 0) core.playbackRate = playbackRate;
1235
+ }, [core, playbackRate]);
1236
+ useEffect(() => core && onError ? core.addEventListener("error", onError) : void 0, [core, onError]);
1237
+ useEffect(() => core && onFrame ? core.addEventListener("frameupdate", onFrame) : void 0, [core, onFrame]);
1238
+ useEffect(() => {
1239
+ if (core && autoPlay) core.play();
1240
+ }, [core, autoPlay]);
1241
+ useEffect(() => {
1242
+ const root = rootRef.current;
1243
+ if (!root || fit === "none" || typeof ResizeObserver === "undefined") return;
1244
+ const update = () => setScale(root.clientWidth / state.composition.width || 1);
1245
+ update();
1246
+ const ro = new ResizeObserver(update);
1247
+ ro.observe(root);
1248
+ return () => ro.disconnect();
1249
+ }, [fit, state.composition.width]);
1250
+ useImperativeHandle(ref, () => core ? refOf(core, canvasRef.current) : null, [core]);
1251
+ const { width, height } = state.composition;
1252
+ const snapshot = core?.getTime() ?? {
1253
+ time: 0,
1254
+ frame: 0
1255
+ };
1256
+ const backdrop = backdropTracks(state).size > 0;
1257
+ const layerStyle = {
1258
+ position: "absolute",
1259
+ left: 0,
1260
+ top: 0,
1261
+ width,
1262
+ height,
1263
+ transformOrigin: "0 0",
1264
+ transform: fit === "none" ? void 0 : `scale(${scale})`,
1265
+ pointerEvents: "none"
1266
+ };
1267
+ const rootStyle = fit === "none" ? {
1268
+ position: "relative",
1269
+ width,
1270
+ height,
1271
+ overflow: "hidden",
1272
+ background: "#000",
1273
+ ...style
1274
+ } : {
1275
+ position: "relative",
1276
+ width: "100%",
1277
+ aspectRatio: `${width} / ${height}`,
1278
+ overflow: "hidden",
1279
+ background: "#000",
1280
+ ...style
1281
+ };
1282
+ return /* @__PURE__ */ jsxs("div", {
1283
+ ref: rootRef,
1284
+ className,
1285
+ style: rootStyle,
1286
+ "data-time": snapshot.time.toFixed(4),
1287
+ "data-frame": snapshot.frame,
1288
+ "data-player": "",
1289
+ children: [
1290
+ backdrop && /* @__PURE__ */ jsx("div", {
1291
+ "data-ground": "",
1292
+ style: {
1293
+ position: "absolute",
1294
+ inset: 0,
1295
+ zIndex: 0,
1296
+ background: backgroundCss(compositionBackground(state))
1297
+ }
1298
+ }),
1299
+ backdrop && core && /* @__PURE__ */ jsx(PlayerContext.Provider, {
1300
+ value: core,
1301
+ children: /* @__PURE__ */ jsx("div", {
1302
+ "data-dom-layer": "backdrop",
1303
+ style: {
1304
+ ...layerStyle,
1305
+ zIndex: 0
1306
+ },
1307
+ children: /* @__PURE__ */ jsx(DomLayer, {
1308
+ state,
1309
+ interiors,
1310
+ layer: "backdrop"
1311
+ })
1312
+ })
1313
+ }),
1314
+ /* @__PURE__ */ jsx("canvas", {
1315
+ ref: canvasRef,
1316
+ "data-el": state.composition.id,
1317
+ "data-kind": "composition",
1318
+ width,
1319
+ height,
1320
+ style: {
1321
+ position: "absolute",
1322
+ inset: 0,
1323
+ width: "100%",
1324
+ height: "100%",
1325
+ display: "block",
1326
+ zIndex: 1
1327
+ }
1328
+ }),
1329
+ core && /* @__PURE__ */ jsxs(PlayerContext.Provider, {
1330
+ value: core,
1331
+ children: [/* @__PURE__ */ jsxs("div", {
1332
+ "data-dom-layer": "",
1333
+ style: {
1334
+ ...layerStyle,
1335
+ zIndex: 2
1336
+ },
1337
+ children: [/* @__PURE__ */ jsx(TimeAttrs, {}), /* @__PURE__ */ jsx(DomLayer, {
1338
+ state,
1339
+ interiors
1340
+ })]
1341
+ }), controls && /* @__PURE__ */ jsx(Controls, {})]
1342
+ })
1343
+ ]
1344
+ });
1345
+ });
1346
+ /** Keeps `data-time`/`data-frame` on the root current without re-rendering the whole player. */
1347
+ function TimeAttrs() {
1348
+ const core = usePlayer();
1349
+ const ref = useRef(null);
1350
+ useEffect(() => core.subscribeTime(() => {
1351
+ const root = ref.current?.closest("[data-player]");
1352
+ if (!root) return;
1353
+ const { time, frame } = core.getTime();
1354
+ root.dataset.time = time.toFixed(4);
1355
+ root.dataset.frame = String(frame);
1356
+ }), [core]);
1357
+ return /* @__PURE__ */ jsx("span", {
1358
+ ref,
1359
+ hidden: true
1360
+ });
1361
+ }
1362
+ function refOf(core, canvas) {
1363
+ return {
1364
+ core,
1365
+ canvas,
1366
+ play: () => core.play(),
1367
+ pause: () => core.pause(),
1368
+ toggle: () => core.toggle(),
1369
+ seekTo: (t) => core.seekTo(t),
1370
+ seekToFrame: (f) => core.seekToFrame(f),
1371
+ getCurrentTime: () => core.getCurrentTime(),
1372
+ getCurrentFrame: () => core.getCurrentFrame(),
1373
+ isPlaying: () => core.isPlaying(),
1374
+ addEventListener: (type, fn) => core.addEventListener(type, fn),
1375
+ removeEventListener: (type, fn) => core.removeEventListener(type, fn),
1376
+ renderFrame: (t) => core.renderFrame(t),
1377
+ captureSheet: (times, o) => core.captureSheet(times, o),
1378
+ get duration() {
1379
+ return core.duration;
1380
+ },
1381
+ get state() {
1382
+ return core.state;
1383
+ },
1384
+ get playbackRate() {
1385
+ return core.playbackRate;
1386
+ },
1387
+ set playbackRate(v) {
1388
+ core.playbackRate = v;
1389
+ },
1390
+ get loop() {
1391
+ return core.loop;
1392
+ },
1393
+ set loop(v) {
1394
+ core.loop = v;
1395
+ },
1396
+ get muted() {
1397
+ return core.muted;
1398
+ },
1399
+ set muted(v) {
1400
+ core.muted = v;
1401
+ },
1402
+ get volume() {
1403
+ return core.volume;
1404
+ },
1405
+ set volume(v) {
1406
+ core.volume = v;
1407
+ }
1408
+ };
1409
+ }
1410
+ export { AUDIO_CACHE_S, AUDIO_CHUNK_S, AUDIO_PRIME_S, AssetSources, AudioContextClock, AudioEngine, CanvasPainter, ClipDecoder, Compositor, Controls, DUCK_ATTACK_S, DUCK_RELEASE_S, DecoderPool, DomLayer, EASING_FN, ENVELOPE_STEP_S, ErrorFrame, ErrorFrameSource, FRAME_EPS, FRAME_WAIT_MS, IDENTITY, INPUT_IDLE_CLOSE_S, ImageFrame, ImageFrameSource, LOOKAHEAD_S, LayerContext, MAX_OPEN_INPUTS, MAX_RATE, MIN_RATE, MOUNT_AHEAD_S, ManualClock, MediabunnyAudioSource, MediabunnyFrameSource, MediabunnyInputs, OVERLAY_KINDS, PICK_EPS, PLAY_PRIME_TIMEOUT_MS, PerformanceClock, Player, PlayerContext, PlayerCore, QUEUE_MAX, QUEUE_TARGET, REVERSE_WINDOW_FRAMES, REVERSE_WINDOW_S, SCHEDULER_MS, SEEK_MODE_SPEED, SETTLE_MS, STILL, SampleFrame, Transport, UNMOUNT_BEHIND_S, WebAudioOutput, animatedTransform, backdropTracks, backgroundCss, backgroundFill, blendOf, boundsOf, buildAudioPlan, buildFramePlan, buildGainEnvelope, captureWarnings, combine, compositionBackground, copyFrame, coversCanvas, cssTransform, defaultCreateCanvas, devWarn, duckIntervals, ease, effect, filterString, fitRect, frameIndex, frameTime, fromMap, hasKeyframes, indexLayerInteriors, isVisibleAt, keyframedValue, layerClock, layoutRect, localT, makeNestedResolver, matrixToCss, mediabunnyAudioSourceFactory, mediabunnyFrameSourceFactory, motionAt, multiply, nestedSourceTime, paintBackground, placementSourceTime, postEffects, retainUseT, rotate, roundedRect, sameElement, scaleM, setMatrix, timecode, toResolver, traceMask, trackItems, transformMatrix, translate, useCompositionFrame, useCompositionTime, useLayerClock, useLayerT, usePlayer, visiblePlacements };