@cutcraft/player 0.0.0-stage → 0.2.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,3301 @@
1
+ import { $ as ClipDecoder, A as transformMatrix, At as ENVELOPE_STEP_S, B as toResolver, Bt as PLAY_PRIME_TIMEOUT_MS, C as cssTransform, Ct as captureWarnings, D as multiply, Dt as AUDIO_PRIME_S, E as matrixToCss, Et as AUDIO_CHUNK_S, F as paramsAt, Ft as MAX_OPEN_INPUTS, G as MediabunnyAudioSource, Gt as SCHEDULER_MS, H as MediabunnyFrameSource, Ht as QUEUE_TARGET, I as postEffects, It as MAX_RATE, J as loadFileFonts, Jt as UNMOUNT_BEHIND_S, K as mediabunnyAudioSourceFactory, Kt as SEEK_MODE_SPEED, L as OVERLAY_KINDS, Lt as MIN_RATE, M as declaredEffects, Mt as FRAME_WAIT_MS, N as effectPasses, Nt as INPUT_IDLE_CLOSE_S, O as rotate, Ot as DUCK_ATTACK_S, P as filterString, Pt as LOOKAHEAD_S, Q as visiblePlacements, R as backdropTracks, Rt as MOUNT_AHEAD_S, S as boundsOf, St as keyframedValue, T as layoutRect, Tt as AUDIO_CACHE_S, U as SampleFrame, Ut as REVERSE_WINDOW_FRAMES, V as MediabunnyInputs, Vt as QUEUE_MAX, W as mediabunnyFrameSourceFactory, Wt as REVERSE_WINDOW_S, X as isVisibleAt, Y as DecoderPool, Z as trackItems, _ as combine, _t as buildGainEnvelope, a as coversCanvas, at as placementSourceTime, b as IDENTITY, bt as ease, c as ShaderRunner, ct as ErrorFrame, d as makeNestedResolver, dt as ImageFrameSource, et as frameIndex, f as blendOf, ft as copyFrame, g as STILL, gt as buildAudioPlan, h as transitionLook, ht as PerformanceClock, i as backgroundFill, it as nestedSourceTime, j as translate, jt as FRAME_EPS, k as scaleM, kt as DUCK_RELEASE_S, l as roundedRect, lt as ErrorFrameSource, m as compositionBackground, mt as ManualClock, n as CanvasPainter, nt as layerClock, o as paintBackground, ot as timecode, p as buildFramePlan, pt as defaultCreateCanvas, q as addFontFaces, qt as SETTLE_MS, r as backgroundCss, rt as localT, s as setMatrix, st as AssetSources, t as Compositor, tt as frameTime, u as traceMask, ut as ImageFrame, v as effect, vt as duckIntervals, w as fitRect, wt as devWarn, x as animatedTransform, xt as hasKeyframes, y as motionAt, yt as EASING_FN, z as fromMap, zt as PICK_EPS } from "./render-DA1KFyei.mjs";
2
+ import { TEXT_ANIMATION_PRESETS, holdLayerFrame, installLayerClock, isVocabulary, layerEngine, rampAdvance, rampSpeed } from "@cutcraft/kit";
3
+ import { Children, Fragment, cloneElement, createContext, forwardRef, isValidElement, memo, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ import { z } from "zod";
6
+ import { GRAPHIC_KINDS, drawGraphic, graphicSize, isIcon } from "@cutcraft/graphics";
7
+ import { SaxesParser } from "saxes";
8
+ // src/audio/audio-engine.ts
9
+ /**
10
+ * Schedules audio chunks on the output ahead of the transport. Every play
11
+ * starts a session; a scheduler tick plans the window since the last tick
12
+ * up to the lookahead and schedules each new chunk once, with offset,
13
+ * duration and playback rate derived from the transport. Pause cancels
14
+ * everything; seek and rate changes restart the session.
15
+ */
16
+ var AudioEngine = class {
17
+ deps;
18
+ session = 0;
19
+ timer = null;
20
+ scheduled = /* @__PURE__ */ new Set();
21
+ scheduledUpTo = 0;
22
+ running = false;
23
+ cache = /* @__PURE__ */ new Map();
24
+ cacheSeconds = 0;
25
+ _muted = false;
26
+ _volume = 1;
27
+ constructor(deps) {
28
+ this.deps = deps;
29
+ }
30
+ get muted() {
31
+ return this._muted;
32
+ }
33
+ get volume() {
34
+ return this._volume;
35
+ }
36
+ get active() {
37
+ return this.running;
38
+ }
39
+ setMaster(volume, muted) {
40
+ this._volume = volume;
41
+ this._muted = muted;
42
+ this.deps.output.setMaster(muted ? 0 : volume);
43
+ }
44
+ /** Decode what plays in the first moments after `t`, bounded by a timeout, so play starts on time. */
45
+ prime(t) {
46
+ const plan = buildAudioPlan(this.deps.state(), t, t + AUDIO_PRIME_S, this.deps.resolveNested);
47
+ const all = Promise.all(plan.map((occ) => this.buffer(occ).catch(() => void 0))).then(() => void 0);
48
+ const timeout = new Promise((resolve) => setTimeout(resolve, 500));
49
+ return Promise.race([all, timeout]);
50
+ }
51
+ start() {
52
+ this.session++;
53
+ this.scheduled.clear();
54
+ this.scheduledUpTo = this.deps.transport.time();
55
+ this.running = true;
56
+ this.deps.output.resume();
57
+ this.tick();
58
+ this.timer = this.deps.setInterval(() => this.tick(), 100);
59
+ }
60
+ stop() {
61
+ if (this.timer !== null) this.deps.clearInterval(this.timer);
62
+ this.timer = null;
63
+ this.running = false;
64
+ this.session++;
65
+ this.deps.output.cancelAll();
66
+ }
67
+ /** After a seek, a rate change or a loop: reschedule from the new position. */
68
+ reseek() {
69
+ const wasRunning = this.running;
70
+ this.stop();
71
+ if (wasRunning) this.start();
72
+ }
73
+ tick() {
74
+ if (!this.running) return;
75
+ const now = this.deps.transport.time();
76
+ const horizon = Math.max(now, this.scheduledUpTo) + LOOKAHEAD_S;
77
+ const from = Math.min(now, this.scheduledUpTo);
78
+ const plan = buildAudioPlan(this.deps.state(), from, horizon, this.deps.resolveNested);
79
+ const session = this.session;
80
+ for (const occ of plan) {
81
+ const key = `${occ.id}#${occ.chunk}`;
82
+ if (this.scheduled.has(key)) continue;
83
+ this.scheduled.add(key);
84
+ this.schedule(occ, session, key);
85
+ }
86
+ this.scheduledUpTo = horizon;
87
+ }
88
+ dispose() {
89
+ this.stop();
90
+ this.cache.clear();
91
+ }
92
+ async schedule(occ, session, key) {
93
+ let buffer;
94
+ try {
95
+ buffer = await this.buffer(occ);
96
+ } catch (error) {
97
+ this.deps.onError?.({
98
+ id: occ.id,
99
+ asset: occ.asset,
100
+ error: error instanceof Error ? error : new Error(String(error)),
101
+ fatal: false
102
+ });
103
+ return;
104
+ }
105
+ if (session !== this.session) return;
106
+ const transport = this.deps.transport;
107
+ const now = transport.time();
108
+ const startComp = Math.max(occ.compStart, now);
109
+ if (startComp >= occ.compEnd) return;
110
+ const ctxTime = (t) => this.deps.toContextTime(transport.clockTimeFor(t));
111
+ const envelope = occ.gain.map(([t, value]) => ({
112
+ at: ctxTime(t),
113
+ value
114
+ }));
115
+ const pan = occ.pan?.map(([t, value]) => ({
116
+ at: ctxTime(t),
117
+ value
118
+ }));
119
+ const keys = occ.rate?.map(([t, speed]) => ({
120
+ at: t - occ.compStart,
121
+ speed
122
+ }));
123
+ const played = (t) => keys ? rampAdvance(keys, t - occ.compStart) : (t - occ.compStart) * occ.speed;
124
+ const offset = played(startComp);
125
+ this.deps.output.schedule({
126
+ key: `${key}#${session}`,
127
+ buffer,
128
+ when: ctxTime(startComp),
129
+ offset,
130
+ duration: played(occ.compEnd) - offset,
131
+ playbackRate: (keys ? rampSpeed(keys, startComp - occ.compStart) : occ.speed) * transport.rate,
132
+ ...occ.rate ? { rate: occ.rate.map(([t, value]) => ({
133
+ at: ctxTime(t),
134
+ value: value * transport.rate
135
+ })) } : {},
136
+ envelope,
137
+ ...pan ? { pan } : {}
138
+ });
139
+ }
140
+ buffer(occ) {
141
+ const key = `${occ.asset}|${occ.bufferFrom}|${occ.bufferTo}${occ.reverse ? "|r" : ""}`;
142
+ const hit = this.cache.get(key);
143
+ if (hit) {
144
+ this.cache.delete(key);
145
+ this.cache.set(key, hit);
146
+ return hit.buffer;
147
+ }
148
+ const seconds = occ.bufferTo - occ.bufferFrom;
149
+ const buffer = this.deps.audioSource(occ.asset).then((s) => s.range(occ.bufferFrom, occ.bufferTo)).then((b) => {
150
+ if (occ.reverse) for (let ch = 0; ch < b.numberOfChannels; ch++) b.getChannelData(ch).reverse();
151
+ return b;
152
+ });
153
+ buffer.catch(() => {
154
+ this.cache.delete(key);
155
+ this.cacheSeconds -= seconds;
156
+ });
157
+ this.cache.set(key, {
158
+ buffer,
159
+ seconds
160
+ });
161
+ this.cacheSeconds += seconds;
162
+ while (this.cacheSeconds > 600 && this.cache.size > 1) {
163
+ const [oldest, entry] = this.cache.entries().next().value;
164
+ this.cache.delete(oldest);
165
+ this.cacheSeconds -= entry.seconds;
166
+ }
167
+ return buffer;
168
+ }
169
+ };
170
+ // src/audio/web-audio-output.ts
171
+ /** Web Audio graph: per scheduled chunk `source → gain(envelope) → [pan] → master → destination`. */
172
+ var WebAudioOutput = class {
173
+ ctx;
174
+ master;
175
+ live = /* @__PURE__ */ new Map();
176
+ constructor(ctx) {
177
+ this.ctx = ctx;
178
+ this.master = ctx.createGain();
179
+ this.master.connect(ctx.destination);
180
+ }
181
+ get contextTime() {
182
+ return this.ctx.currentTime;
183
+ }
184
+ get running() {
185
+ return this.ctx.state === "running";
186
+ }
187
+ schedule(src) {
188
+ this.cancel(src.key);
189
+ const source = this.ctx.createBufferSource();
190
+ source.buffer = src.buffer;
191
+ automate(source.playbackRate, src.rate ?? [], src.when, src.playbackRate);
192
+ const gain = this.ctx.createGain();
193
+ automate(gain.gain, src.envelope, src.when, 1);
194
+ const nodes = [gain];
195
+ let tail = gain;
196
+ if (src.pan && src.pan.length > 0 && typeof this.ctx.createStereoPanner === "function") {
197
+ const panner = this.ctx.createStereoPanner();
198
+ automate(panner.pan, src.pan, src.when, 0);
199
+ tail.connect(panner);
200
+ tail = panner;
201
+ nodes.push(panner);
202
+ }
203
+ source.connect(gain);
204
+ tail.connect(this.master);
205
+ const when = Math.max(src.when, this.ctx.currentTime);
206
+ const late = when - src.when;
207
+ const offset = src.offset + late * src.playbackRate;
208
+ const duration = src.duration - late * src.playbackRate;
209
+ if (duration <= 0) return;
210
+ source.onended = () => this.drop(src.key, source);
211
+ source.start(when, offset, duration);
212
+ this.live.set(src.key, {
213
+ source,
214
+ nodes
215
+ });
216
+ }
217
+ cancel(keyPrefix) {
218
+ for (const [key, entry] of this.live) {
219
+ if (!key.startsWith(keyPrefix)) continue;
220
+ this.stop(entry);
221
+ this.live.delete(key);
222
+ }
223
+ }
224
+ cancelAll() {
225
+ for (const entry of this.live.values()) this.stop(entry);
226
+ this.live.clear();
227
+ }
228
+ setMaster(gain) {
229
+ this.master.gain.cancelScheduledValues(this.ctx.currentTime);
230
+ this.master.gain.setTargetAtTime(gain, this.ctx.currentTime, .01);
231
+ }
232
+ async resume() {
233
+ if (this.ctx.state === "suspended") try {
234
+ await this.ctx.resume();
235
+ } catch {}
236
+ }
237
+ drop(key, source) {
238
+ const entry = this.live.get(key);
239
+ if (entry && entry.source === source) {
240
+ this.disconnect(entry);
241
+ this.live.delete(key);
242
+ }
243
+ }
244
+ stop(entry) {
245
+ entry.source.onended = null;
246
+ try {
247
+ entry.source.stop();
248
+ } catch {}
249
+ this.disconnect(entry);
250
+ }
251
+ disconnect(entry) {
252
+ entry.source.disconnect();
253
+ for (const n of entry.nodes) n.disconnect();
254
+ }
255
+ };
256
+ /** Set an AudioParam from envelope points: the value at `when` first, then linear ramps for later points. */
257
+ function automate(param, points, when, fallback) {
258
+ if (points.length === 0) {
259
+ param.value = fallback;
260
+ return;
261
+ }
262
+ const sorted = [...points].sort((a, b) => a.at - b.at);
263
+ param.setValueAtTime(valueAt$1(sorted, when, fallback), Math.max(0, when));
264
+ for (const p of sorted) if (p.at > when) param.linearRampToValueAtTime(p.value, p.at);
265
+ }
266
+ function valueAt$1(points, at, fallback) {
267
+ const first = points[0], last = points[points.length - 1];
268
+ if (!first || !last) return fallback;
269
+ if (at <= first.at) return first.value;
270
+ if (at >= last.at) return last.value;
271
+ for (let i = 0; i < points.length - 1; i++) {
272
+ const a = points[i], b = points[i + 1];
273
+ 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);
274
+ }
275
+ return last.value;
276
+ }
277
+ // src/clock/audio-clock.ts
278
+ /**
279
+ * `AudioContext.currentTime` while the context runs, the performance clock
280
+ * otherwise, with an offset captured at every state change so `now()` never
281
+ * jumps. Audio scheduling converts back with `toContextTime`.
282
+ */
283
+ var AudioContextClock = class {
284
+ ctx;
285
+ offset = 0;
286
+ wasRunning = false;
287
+ lastNow = 0;
288
+ constructor(ctx) {
289
+ this.ctx = ctx;
290
+ }
291
+ get running() {
292
+ return this.ctx.state === "running";
293
+ }
294
+ now() {
295
+ const running = this.running;
296
+ const raw = running ? this.ctx.currentTime : performance.now() / 1e3;
297
+ if (running !== this.wasRunning) {
298
+ this.offset = this.lastNow - raw;
299
+ this.wasRunning = running;
300
+ }
301
+ const now = raw + this.offset;
302
+ this.lastNow = Math.max(now, this.lastNow);
303
+ return this.lastNow;
304
+ }
305
+ /** Audio context seconds for a clock time; exact while the context runs. */
306
+ toContextTime(clockTime) {
307
+ this.now();
308
+ return this.running ? clockTime - this.offset : this.ctx.currentTime + (clockTime - this.lastNow);
309
+ }
310
+ };
311
+ // src/clock/transport.ts
312
+ /**
313
+ * Composition time as an anchor on the clock: `anchorTime + (now − anchorClock) × rate`.
314
+ * Every play, pause, seek and rate change re-anchors, so time is always derived from
315
+ * the clock and never accumulates drift.
316
+ */
317
+ var Transport = class {
318
+ clock;
319
+ duration;
320
+ anchorTime = 0;
321
+ anchorClock = 0;
322
+ _rate = 1;
323
+ _playing = false;
324
+ loop = false;
325
+ constructor(clock, duration) {
326
+ this.clock = clock;
327
+ this.duration = duration;
328
+ }
329
+ get playing() {
330
+ return this._playing;
331
+ }
332
+ get rate() {
333
+ return this._rate;
334
+ }
335
+ time() {
336
+ return this.timeAt(this.clock.now());
337
+ }
338
+ timeAt(clockTime) {
339
+ if (!this._playing) return this.anchorTime;
340
+ return this.clamp(this.anchorTime + (clockTime - this.anchorClock) * this._rate);
341
+ }
342
+ /** Clock time at which composition time `t` plays; the inverse of `timeAt` while playing. */
343
+ clockTimeFor(t) {
344
+ if (!this._playing) return this.clock.now();
345
+ return this.anchorClock + (t - this.anchorTime) / this._rate;
346
+ }
347
+ play() {
348
+ if (this._playing) return;
349
+ if (this.anchorTime >= this.duration && this.duration > 0) this.anchorTime = 0;
350
+ this.anchorClock = this.clock.now();
351
+ this._playing = true;
352
+ }
353
+ pause() {
354
+ if (!this._playing) return;
355
+ this.anchorTime = this.time();
356
+ this._playing = false;
357
+ }
358
+ seek(t) {
359
+ this.anchorTime = this.clamp(t);
360
+ this.anchorClock = this.clock.now();
361
+ }
362
+ setRate(rate) {
363
+ const r = Math.min(4, Math.max(MIN_RATE, rate));
364
+ this.anchorTime = this.time();
365
+ this.anchorClock = this.clock.now();
366
+ this._rate = r;
367
+ }
368
+ /** Advance bookkeeping at the end: loops wrap, otherwise playback stops on the last time. */
369
+ check() {
370
+ if (!this._playing || this.duration <= 0) return null;
371
+ const raw = this.anchorTime + (this.clock.now() - this.anchorClock) * this._rate;
372
+ if (raw < this.duration) return null;
373
+ if (this.loop) {
374
+ this.seek(raw - this.duration >= this.duration ? 0 : raw - this.duration);
375
+ return "looped";
376
+ }
377
+ this.anchorTime = this.duration;
378
+ this._playing = false;
379
+ return "ended";
380
+ }
381
+ clamp(t) {
382
+ return t < 0 ? 0 : t > this.duration ? this.duration : t;
383
+ }
384
+ };
385
+ // src/player.ts
386
+ /**
387
+ * The framework-free player: owns the transport, the compositor (canvas),
388
+ * the audio engine and the media sources. React's `<Player>` is a thin
389
+ * wrapper over it; a headless exporter can drive `renderFrame` directly.
390
+ */
391
+ var PlayerCore = class {
392
+ _state;
393
+ media;
394
+ clock;
395
+ transport;
396
+ compositor;
397
+ engine;
398
+ sources;
399
+ inputs;
400
+ audioContext;
401
+ audioSources = /* @__PURE__ */ new Map();
402
+ listeners = /* @__PURE__ */ new Map();
403
+ timeListeners = /* @__PURE__ */ new Set();
404
+ createCanvas;
405
+ raf;
406
+ cancelRaf;
407
+ rafId = null;
408
+ setTimeoutFn;
409
+ clearTimeoutFn;
410
+ settleTimer = null;
411
+ seekGeneration = 0;
412
+ snapshot = {
413
+ time: 0,
414
+ frame: 0
415
+ };
416
+ _muted;
417
+ _volume = 1;
418
+ disposed = false;
419
+ fonts = Promise.resolve();
420
+ fontsOf = null;
421
+ fontsWanted;
422
+ constructor(options) {
423
+ this._state = options.state;
424
+ this.media = toResolver(options.media);
425
+ const comp = options.state.composition;
426
+ const wantsBrowserAudio = !options.audioOutput || !options.clock || !options.audioSourceFactory;
427
+ this.audioContext = wantsBrowserAudio && typeof AudioContext !== "undefined" ? options.audioContext ?? new AudioContext({ sampleRate: Number(options.state.items.get(comp.id)?.props.sampleRate ?? 48e3) }) : options.audioContext ?? null;
428
+ const audioClock = this.audioContext && !options.clock ? new AudioContextClock(this.audioContext) : null;
429
+ this.clock = options.clock ?? audioClock ?? new PerformanceClock();
430
+ const toContextTime = options.toContextTime ?? (audioClock ? (t) => audioClock.toContextTime(t) : (t) => t);
431
+ this.transport = new Transport(this.clock, comp.duration);
432
+ this.transport.loop = options.loop ?? false;
433
+ if (options.playbackRate !== void 0) this.transport.setRate(options.playbackRate);
434
+ this.raf = options.raf ?? ((cb) => requestAnimationFrame(cb));
435
+ this.cancelRaf = options.cancelRaf ?? ((id) => cancelAnimationFrame(id));
436
+ this.setTimeoutFn = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
437
+ this.clearTimeoutFn = options.clearTimeout ?? ((id) => clearTimeout(id));
438
+ const createCanvas = options.createCanvas ?? defaultCreateCanvas;
439
+ this.createCanvas = createCanvas;
440
+ this.inputs = options.frameSourceFactory && options.audioSourceFactory ? null : new MediabunnyInputs();
441
+ const frameSourceFactory = options.frameSourceFactory ?? mediabunnyFrameSourceFactory(this.inputs, createCanvas);
442
+ const audioSourceFactory = options.audioSourceFactory ?? mediabunnyAudioSourceFactory(this.inputs);
443
+ const resolveNested = options.resolveNested ?? makeNestedResolver(() => this._state);
444
+ this.sources = new AssetSources({
445
+ media: this.media,
446
+ manifest: options.state.manifest,
447
+ frameSourceFactory,
448
+ clock: this.clock,
449
+ onError: (e) => this.emit("error", e)
450
+ });
451
+ this.compositor = new Compositor(options.state, {
452
+ sources: this.sources,
453
+ createCanvas,
454
+ resolveNested,
455
+ onFrame: (time, frame) => this.publish(time, frame)
456
+ });
457
+ this.compositor.setRate(this.transport.rate);
458
+ const output = options.audioOutput ?? (this.audioContext ? new WebAudioOutput(this.audioContext) : null);
459
+ if (!output) throw new Error("PlayerCore needs an AudioOutput: no AudioContext in this environment");
460
+ this.engine = new AudioEngine({
461
+ output,
462
+ transport: this.transport,
463
+ toContextTime,
464
+ state: () => this._state,
465
+ resolveNested,
466
+ audioSource: (assetId) => this.audioSource(assetId, audioSourceFactory),
467
+ setInterval: options.setInterval ?? ((fn, ms) => setInterval(fn, ms)),
468
+ clearInterval: options.clearInterval ?? ((id) => clearInterval(id)),
469
+ onError: (e) => this.emit("error", e)
470
+ });
471
+ this._muted = options.initiallyMuted ?? false;
472
+ this.engine.setMaster(this._volume, this._muted);
473
+ this.fontsWanted = options.loadFonts ?? true;
474
+ this.exactTime = options.exactTime ?? false;
475
+ this.loadFonts(options.state);
476
+ }
477
+ /** Resolves once the file's fonts (`state.fonts`) have loaded; a face that fails is reported as an error event. */
478
+ /** Overlays follow every change of time, not each frame (`exactTime`). */
479
+ exactTime;
480
+ get fontsReady() {
481
+ return this.fonts;
482
+ }
483
+ loadFonts(state) {
484
+ if (!this.fontsWanted || state.fonts === this.fontsOf) return;
485
+ this.fontsOf = state.fonts;
486
+ this.fonts = loadFileFonts(state.fonts, this.media, (family, error) => this.emit("error", {
487
+ error: /* @__PURE__ */ new Error(`font "${family}": ${error.message}`),
488
+ fatal: false
489
+ }));
490
+ }
491
+ get state() {
492
+ return this._state;
493
+ }
494
+ get duration() {
495
+ return this.transport.duration;
496
+ }
497
+ get playbackRate() {
498
+ return this.transport.rate;
499
+ }
500
+ set playbackRate(rate) {
501
+ const r = Math.min(4, Math.max(MIN_RATE, rate));
502
+ if (r === this.transport.rate) return;
503
+ this.transport.setRate(r);
504
+ this.compositor.setRate(r);
505
+ if (this.transport.playing) this.engine.reseek();
506
+ this.emit("ratechange", { rate: r });
507
+ }
508
+ get loop() {
509
+ return this.transport.loop;
510
+ }
511
+ set loop(v) {
512
+ this.transport.loop = v;
513
+ }
514
+ get muted() {
515
+ return this._muted;
516
+ }
517
+ set muted(v) {
518
+ if (v === this._muted) return;
519
+ this._muted = v;
520
+ this.engine.setMaster(this._volume, v);
521
+ this.emit("volumechange", {
522
+ volume: this._volume,
523
+ muted: v
524
+ });
525
+ }
526
+ get volume() {
527
+ return this._volume;
528
+ }
529
+ set volume(v) {
530
+ const vol = Math.min(1, Math.max(0, v));
531
+ if (vol === this._volume) return;
532
+ this._volume = vol;
533
+ this.engine.setMaster(vol, this._muted);
534
+ this.emit("volumechange", {
535
+ volume: vol,
536
+ muted: this._muted
537
+ });
538
+ }
539
+ /** The media handle for an asset id, for DOM elements that show images. */
540
+ resolveMedia(assetId) {
541
+ return this.media.resolve(assetId);
542
+ }
543
+ getCurrentTime() {
544
+ return this.transport.time();
545
+ }
546
+ getCurrentFrame() {
547
+ return frameIndex(this.transport.time(), this._state.composition.fps);
548
+ }
549
+ isPlaying() {
550
+ return this.transport.playing;
551
+ }
552
+ getTime() {
553
+ return this.snapshot;
554
+ }
555
+ async play() {
556
+ if (this.disposed || this.transport.playing) return;
557
+ const t = this.transport.time() >= this.duration ? 0 : this.transport.time();
558
+ await this.engine.prime(t);
559
+ if (this.disposed || this.transport.playing) return;
560
+ if (this.transport.time() >= this.duration) {
561
+ this.transport.seek(0);
562
+ await this.compositor.seek(0);
563
+ }
564
+ this.transport.play();
565
+ this.engine.start();
566
+ this.startLoop();
567
+ this.emit("play", { time: this.transport.time() });
568
+ }
569
+ pause() {
570
+ if (!this.transport.playing) return;
571
+ this.transport.pause();
572
+ this.engine.stop();
573
+ this.stopLoop();
574
+ this.emit("pause", { time: this.transport.time() });
575
+ }
576
+ toggle() {
577
+ if (this.transport.playing) {
578
+ this.pause();
579
+ return Promise.resolve();
580
+ }
581
+ return this.play();
582
+ }
583
+ /** Jump to `time` and draw its exact frame before resolving, playing or not. */
584
+ async seekTo(time) {
585
+ if (this.disposed) return;
586
+ const generation = ++this.seekGeneration;
587
+ this.transport.seek(time);
588
+ const t = this.transport.time();
589
+ if (this.transport.playing) this.engine.reseek();
590
+ await this.compositor.seek(t);
591
+ if (this.disposed || generation !== this.seekGeneration) return;
592
+ await this.compositor.tick(t, { wait: true });
593
+ if (this.disposed || generation !== this.seekGeneration) return;
594
+ this.publish(t, frameIndex(t, this._state.composition.fps));
595
+ this.emit("seeked", { time: t });
596
+ }
597
+ seekToFrame(frame) {
598
+ return this.seekTo(frameTime(frame, this._state.composition.fps));
599
+ }
600
+ /** Draw `time` without moving the transport; decoders return to the play head afterwards. */
601
+ async renderFrame(time) {
602
+ if (this.disposed) return;
603
+ const t = Math.min(this.duration, Math.max(0, time));
604
+ await this.compositor.seek(t);
605
+ await this.compositor.tick(t, { wait: true });
606
+ if (this.transport.playing) await this.compositor.seek(this.transport.time());
607
+ }
608
+ /**
609
+ * A contact sheet of the canvas plane at `times`: each cell is `renderFrame`
610
+ * of that time with a timecode label, laid out `columns` wide. The way an
611
+ * agent or a test looks at the result without exporting. DOM overlays
612
+ * (titles, shapes, Layer interiors) are not on the canvas and do not appear.
613
+ */
614
+ async captureSheet(times, options = {}) {
615
+ const comp = this._state.composition;
616
+ const count = times.length;
617
+ const columns = Math.max(1, Math.min(Math.floor(options.columns ?? 4), Math.max(1, count)));
618
+ const cellWidth = Math.max(1, Math.round(options.cellWidth ?? Math.min(comp.width, DEFAULT_SHEET_CELL_WIDTH)));
619
+ const cellHeight = Math.max(1, Math.round(cellWidth * comp.height / comp.width));
620
+ const rows = Math.max(1, Math.ceil(count / columns));
621
+ const sheet = this.createCanvas(columns * cellWidth, rows * cellHeight);
622
+ const ctx = sheet.getContext("2d");
623
+ const scratch = this.createCanvas(comp.width, comp.height);
624
+ const scratchCtx = scratch.getContext("2d");
625
+ if (!ctx || !scratchCtx) throw new Error("captureSheet: no 2d context");
626
+ const previous = this.compositor.attachedContext;
627
+ this.compositor.attach(scratchCtx);
628
+ try {
629
+ for (let i = 0; i < count; i++) {
630
+ const t = times[i];
631
+ await this.renderFrame(t);
632
+ const x = i % columns * cellWidth;
633
+ const y = Math.floor(i / columns) * cellHeight;
634
+ ctx.drawImage(scratch, x, y, cellWidth, cellHeight);
635
+ if (options.labels !== false) drawLabel(ctx, timecode(Math.min(this.duration, Math.max(0, t)), comp.fps), x, y, cellWidth, cellHeight);
636
+ }
637
+ } finally {
638
+ this.compositor.attach(previous);
639
+ if (previous && !this.disposed && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
640
+ }
641
+ return sheet;
642
+ }
643
+ /**
644
+ * The editor pushes a new resolve on every drag step. Decoders of the same
645
+ * asset keep their frames and take the new trim; a paused preview paints at
646
+ * once with what is decoded and fetches the exact frame after the gesture
647
+ * has been quiet for `SETTLE_MS`.
648
+ */
649
+ setState(state) {
650
+ this._state = state;
651
+ this.loadFonts(state);
652
+ this.transport.duration = state.composition.duration;
653
+ this.compositor.setState(state);
654
+ if (this.transport.playing) return;
655
+ this.compositor.tick(this.transport.time(), { wait: false });
656
+ if (this.settleTimer !== null) this.clearTimeoutFn(this.settleTimer);
657
+ this.settleTimer = this.setTimeoutFn(() => {
658
+ this.settleTimer = null;
659
+ if (!this.disposed && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
660
+ }, 120);
661
+ }
662
+ attachCanvas(canvas) {
663
+ const ctx = canvas ? canvas.getContext("2d") : null;
664
+ if (canvas) {
665
+ canvas.width = this._state.composition.width;
666
+ canvas.height = this._state.composition.height;
667
+ }
668
+ this.compositor.attach(ctx);
669
+ if (ctx && !this.transport.playing) this.compositor.tick(this.transport.time(), { wait: true });
670
+ }
671
+ addEventListener(type, fn) {
672
+ let set = this.listeners.get(type);
673
+ if (!set) {
674
+ set = /* @__PURE__ */ new Set();
675
+ this.listeners.set(type, set);
676
+ }
677
+ set.add(fn);
678
+ return () => this.removeEventListener(type, fn);
679
+ }
680
+ removeEventListener(type, fn) {
681
+ this.listeners.get(type)?.delete(fn);
682
+ }
683
+ /** For `useSyncExternalStore`: notified once per painted frame and on every seek. */
684
+ subscribeTime(fn) {
685
+ this.timeListeners.add(fn);
686
+ return () => {
687
+ this.timeListeners.delete(fn);
688
+ };
689
+ }
690
+ dispose() {
691
+ if (this.disposed) return;
692
+ this.pause();
693
+ this.disposed = true;
694
+ if (this.settleTimer !== null) this.clearTimeoutFn(this.settleTimer);
695
+ this.settleTimer = null;
696
+ this.engine.dispose();
697
+ this.compositor.dispose();
698
+ this.sources.dispose();
699
+ for (const p of this.audioSources.values()) p.then((s) => s.close()).catch(() => void 0);
700
+ this.audioSources.clear();
701
+ this.inputs?.dispose();
702
+ this.listeners.clear();
703
+ this.timeListeners.clear();
704
+ }
705
+ startLoop() {
706
+ if (this.rafId !== null) return;
707
+ const tick = () => {
708
+ this.rafId = null;
709
+ if (this.disposed || !this.transport.playing) return;
710
+ const result = this.transport.check();
711
+ const t = this.transport.time();
712
+ if (result === "ended") {
713
+ this.engine.stop();
714
+ this.compositor.tick(t, { wait: true }).then(() => this.emit("ended", { time: t }));
715
+ return;
716
+ }
717
+ if (result === "looped") {
718
+ this.compositor.seek(t);
719
+ this.engine.reseek();
720
+ }
721
+ this.compositor.tick(t, { wait: false });
722
+ this.rafId = this.raf(tick);
723
+ };
724
+ this.rafId = this.raf(tick);
725
+ }
726
+ stopLoop() {
727
+ if (this.rafId !== null) this.cancelRaf(this.rafId);
728
+ this.rafId = null;
729
+ }
730
+ publish(time, frame) {
731
+ if (this.snapshot.time === time && this.snapshot.frame === frame) return;
732
+ this.snapshot = {
733
+ time,
734
+ frame
735
+ };
736
+ for (const fn of this.timeListeners) fn();
737
+ this.emit("frameupdate", {
738
+ time,
739
+ frame
740
+ });
741
+ }
742
+ emit(type, event) {
743
+ const set = this.listeners.get(type);
744
+ if (!set) return;
745
+ for (const fn of [...set]) fn(event);
746
+ }
747
+ audioSource(assetId, factory) {
748
+ let p = this.audioSources.get(assetId);
749
+ if (!p) {
750
+ p = this.media.resolve(assetId).then(async (handle) => {
751
+ const source = factory(assetId, handle, this.audioContext ?? { createBuffer: () => {
752
+ throw new Error("no AudioContext");
753
+ } });
754
+ if (!(await source.open()).decodable) throw new Error(`audio of "${assetId}" cannot be decoded`);
755
+ return source;
756
+ });
757
+ this.audioSources.set(assetId, p);
758
+ p.catch(() => void 0);
759
+ }
760
+ return p;
761
+ }
762
+ };
763
+ const DEFAULT_SHEET_CELL_WIDTH = 320;
764
+ function drawLabel(ctx, text, x, y, w, h) {
765
+ const band = Math.max(12, Math.round(h * .1));
766
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
767
+ ctx.globalAlpha = 1;
768
+ ctx.globalCompositeOperation = "source-over";
769
+ ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
770
+ ctx.fillRect(x, y + h - band, w, band);
771
+ ctx.fillStyle = "#ffffff";
772
+ ctx.font = `${Math.round(band * .7)}px monospace`;
773
+ ctx.textBaseline = "middle";
774
+ ctx.fillText(text, x + Math.round(band / 3), y + h - band / 2);
775
+ }
776
+ // src/overlays/layer-interiors.ts
777
+ /**
778
+ * Walk the file's element tree the way the kit's `collect` does (fragments,
779
+ * vocabulary elements, pure helpers expanded by calling them) and record the
780
+ * children of every `<Layer id>`: the resolver drops them, the Player renders them.
781
+ */
782
+ function indexLayerInteriors(element) {
783
+ const out = /* @__PURE__ */ new Map();
784
+ walk(element, out);
785
+ return out;
786
+ }
787
+ function walk(node, out) {
788
+ Children.forEach(node, (child) => {
789
+ if (!isValidElement(child)) return;
790
+ const element = child;
791
+ const type = element.type;
792
+ if (type === Fragment) {
793
+ walk(element.props.children, out);
794
+ return;
795
+ }
796
+ if (isVocabulary(type)) {
797
+ if (type.kind === "layer") {
798
+ if (typeof element.props.id === "string") out.set(element.props.id, element.props.children);
799
+ return;
800
+ }
801
+ walk(element.props.children, out);
802
+ return;
803
+ }
804
+ if (typeof type === "function") {
805
+ try {
806
+ walk(type(element.props), out);
807
+ } catch {}
808
+ return;
809
+ }
810
+ walk(element.props.children, out);
811
+ });
812
+ }
813
+ // src/react/context.ts
814
+ const PlayerContext = createContext(null);
815
+ /** The layer's clock inside a `<Layer>`; null outside one. */
816
+ const LayerContext = createContext(null);
817
+ // src/react/hooks.ts
818
+ function usePlayer() {
819
+ const core = useContext(PlayerContext);
820
+ if (!core) throw new Error("usePlayer() must be used inside <Player>");
821
+ return core;
822
+ }
823
+ function useCompositionTime() {
824
+ const core = usePlayer();
825
+ return useSyncExternalStore((fn) => core.subscribeTime(fn), () => core.getTime().time, () => core.getTime().time);
826
+ }
827
+ function useCompositionFrame() {
828
+ const core = usePlayer();
829
+ return useSyncExternalStore((fn) => core.subscribeTime(fn), () => core.getTime().frame, () => core.getTime().frame);
830
+ }
831
+ /** What the kit's `useLayer()` and `useT()` delegate to while a Player is mounted. */
832
+ function useLayerClock() {
833
+ const clock = useContext(LayerContext);
834
+ if (!clock) throw new Error("useLayer() and useT() are only valid inside a <Layer>");
835
+ return clock;
836
+ }
837
+ /** 0..1 progress through the enclosing Layer. */
838
+ function useLayerT() {
839
+ return useLayerClock().t;
840
+ }
841
+ let mounted = 0;
842
+ /** Installs the kit's layer hooks for the first mounted Player and removes them after the last unmounts. */
843
+ function retainUseT() {
844
+ if (mounted++ === 0) installLayerClock(useLayerClock);
845
+ return () => {
846
+ if (--mounted === 0) installLayerClock(null);
847
+ };
848
+ }
849
+ // src/react/Controls.tsx
850
+ const bar = {
851
+ display: "flex",
852
+ alignItems: "center",
853
+ gap: 8,
854
+ padding: "6px 8px",
855
+ background: "rgba(0,0,0,0.75)",
856
+ color: "#eee",
857
+ font: "12px system-ui, sans-serif",
858
+ position: "absolute",
859
+ left: 0,
860
+ right: 0,
861
+ bottom: 0,
862
+ zIndex: 1e3
863
+ };
864
+ const button = {
865
+ background: "none",
866
+ border: "1px solid #777",
867
+ color: "inherit",
868
+ borderRadius: 4,
869
+ padding: "2px 8px",
870
+ cursor: "pointer",
871
+ font: "inherit"
872
+ };
873
+ /** Play/pause, scrubber, time readout, mute and rate. */
874
+ function Controls() {
875
+ const core = usePlayer();
876
+ const time = useCompositionTime();
877
+ const [playing, setPlaying] = useState(core.isPlaying());
878
+ const [muted, setMuted] = useState(core.muted);
879
+ const [rate, setRate] = useState(core.playbackRate);
880
+ const resumeAfterScrub = useRef(false);
881
+ useEffect(() => {
882
+ const offs = [
883
+ core.addEventListener("play", () => setPlaying(true)),
884
+ core.addEventListener("pause", () => setPlaying(false)),
885
+ core.addEventListener("ended", () => setPlaying(false)),
886
+ core.addEventListener("volumechange", (e) => setMuted(e.muted)),
887
+ core.addEventListener("ratechange", (e) => setRate(e.rate))
888
+ ];
889
+ return () => offs.forEach((off) => off());
890
+ }, [core]);
891
+ const fps = core.state.composition.fps;
892
+ return /* @__PURE__ */ jsxs("div", {
893
+ style: bar,
894
+ "data-controls": "",
895
+ children: [
896
+ /* @__PURE__ */ jsx("button", {
897
+ type: "button",
898
+ style: button,
899
+ onClick: () => void core.toggle(),
900
+ "aria-label": playing ? "Pause" : "Play",
901
+ children: playing ? "❚❚" : "▶"
902
+ }),
903
+ /* @__PURE__ */ jsx("input", {
904
+ type: "range",
905
+ min: 0,
906
+ max: core.duration,
907
+ step: 1 / fps,
908
+ value: time,
909
+ "aria-label": "Scrub",
910
+ style: { flex: 1 },
911
+ onPointerDown: () => {
912
+ resumeAfterScrub.current = core.isPlaying();
913
+ core.pause();
914
+ },
915
+ onPointerUp: () => {
916
+ if (resumeAfterScrub.current) core.play();
917
+ resumeAfterScrub.current = false;
918
+ },
919
+ onChange: (e) => void core.seekTo(Number(e.target.value))
920
+ }),
921
+ /* @__PURE__ */ jsxs("span", {
922
+ style: { fontVariantNumeric: "tabular-nums" },
923
+ children: [
924
+ timecode(time, fps),
925
+ " / ",
926
+ timecode(core.duration, fps)
927
+ ]
928
+ }),
929
+ /* @__PURE__ */ jsx("button", {
930
+ type: "button",
931
+ style: button,
932
+ onClick: () => {
933
+ core.muted = !core.muted;
934
+ },
935
+ "aria-label": muted ? "Unmute" : "Mute",
936
+ children: muted ? "🔇" : "🔊"
937
+ }),
938
+ /* @__PURE__ */ jsx("select", {
939
+ value: rate,
940
+ onChange: (e) => {
941
+ core.playbackRate = Number(e.target.value);
942
+ },
943
+ "aria-label": "Speed",
944
+ style: {
945
+ ...button,
946
+ padding: 2
947
+ },
948
+ children: [
949
+ .5,
950
+ 1,
951
+ 1.5,
952
+ 2
953
+ ].map((r) => /* @__PURE__ */ jsxs("option", {
954
+ value: r,
955
+ children: [r, "×"]
956
+ }, r))
957
+ })
958
+ ]
959
+ });
960
+ }
961
+ // src/overlays/layout.ts
962
+ /**
963
+ * `drawable` elements (shapes, SVG) draw their strokes progressively for a `draw` entrance;
964
+ * others fade instead.
965
+ */
966
+ function overlayLayout(p, size, fit = "none", drawable = false) {
967
+ const rel = p.time - p.placement.start;
968
+ const transform = animatedTransform(p.placement.props.transform, p.children, rel);
969
+ const layout = layoutRect(p.state.composition, size, fit, transform);
970
+ const { filter } = filterString(p.children, p.placement.id, rel, declaredEffects(p.state));
971
+ const blend = p.placement.props.blend;
972
+ const props = p.placement.props;
973
+ const motion = combine(motionAt(props.enter, props.exit, rel, p.placement.duration, layout.rect.w, layout.rect.h), transitionMotion(p.transition, layout.rect.w, layout.rect.h));
974
+ const fadeForDraw = !drawable && motion.draw !== null ? motion.draw : 1;
975
+ return {
976
+ rect: {
977
+ w: layout.rect.w,
978
+ h: layout.rect.h
979
+ },
980
+ matrix: multiply(layout.matrix, motion.matrix),
981
+ opacity: (transform.opacity ?? 1) * motion.opacity * fadeForDraw,
982
+ filter: filter || null,
983
+ blend: blend && blend !== "normal" ? blendOf(blend) : null,
984
+ borderRadius: transform.borderRadius ?? 0,
985
+ clip: motion.clip,
986
+ draw: drawable ? motion.draw : null
987
+ };
988
+ }
989
+ /**
990
+ * A transition as an entrance or exit: the footage's look, played across the element's box.
991
+ * shortcut: an overlay slides and wipes across its own box, not the frame, and a dip shows as a fade; fine for full-frame titles, measure against the frame if small overlays need it.
992
+ */
993
+ function transitionMotion(t, w, h) {
994
+ if (!t) return STILL;
995
+ const look = transitionLook(t, w, h);
996
+ return {
997
+ opacity: look.opacity * (1 - (look.dip ?? 0)),
998
+ matrix: translate(look.offset?.x ?? 0, look.offset?.y ?? 0),
999
+ clip: look.clip ?? null,
1000
+ draw: null
1001
+ };
1002
+ }
1003
+ /** A clip as a CSS `path()`: rectangles in the element's own pixels, an empty one when nothing shows. */
1004
+ function clipPathCss(clip) {
1005
+ const n = (v) => String(Math.round(v * 100) / 100);
1006
+ return `path("${clip.map((r) => `M${n(r.x)} ${n(r.y)}h${n(r.w)}v${n(r.h)}h${n(-r.w)}Z`).join("") || "M0 0Z"}")`;
1007
+ }
1008
+ /** Position an overlay of `size` composition pixels with its transform, filter and blend as CSS. */
1009
+ function overlayStyle(p, size, fit = "none", drawable = false) {
1010
+ return layoutStyle(overlayLayout(p, size, fit, drawable));
1011
+ }
1012
+ /** The CSS that puts an element where its layout says. */
1013
+ function layoutStyle(l) {
1014
+ return {
1015
+ position: "absolute",
1016
+ left: 0,
1017
+ top: 0,
1018
+ width: l.rect.w,
1019
+ height: l.rect.h,
1020
+ transformOrigin: "0 0",
1021
+ transform: matrixToCss(l.matrix),
1022
+ opacity: l.opacity,
1023
+ ...l.filter ? { filter: l.filter } : {},
1024
+ ...l.blend ? { mixBlendMode: cssBlend(l.blend) } : {},
1025
+ ...l.borderRadius ? {
1026
+ borderRadius: l.borderRadius,
1027
+ overflow: "hidden"
1028
+ } : {},
1029
+ ...l.clip ? { clipPath: clipPathCss(l.clip) } : {},
1030
+ boxSizing: "border-box"
1031
+ };
1032
+ }
1033
+ function cssBlend(op) {
1034
+ return op === "source-over" ? "normal" : op === "lighter" ? "plus-lighter" : op;
1035
+ }
1036
+ function dataAttrs(p) {
1037
+ return {
1038
+ "data-el": p.id,
1039
+ "data-kind": p.kind
1040
+ };
1041
+ }
1042
+ // src/react/elements/Adjustment.tsx
1043
+ /**
1044
+ * The painter's adjustment op filters the footage; overlays stay unfiltered, as in the export.
1045
+ * A backdrop filter here would filter the canvas a second time.
1046
+ */
1047
+ function Adjustment(p) {
1048
+ return /* @__PURE__ */ jsx("div", {
1049
+ ...dataAttrs(p.placement),
1050
+ style: {
1051
+ position: "absolute",
1052
+ inset: 0,
1053
+ pointerEvents: "none"
1054
+ }
1055
+ });
1056
+ }
1057
+ // src/overlays/content.tsx
1058
+ /** @jsxRuntime automatic */
1059
+ /** @jsxImportSource react */
1060
+ /**
1061
+ * What each overlay element draws, as a React tree with no hooks and no DOM
1062
+ * calls. The player mounts these inside positioned divs.
1063
+ */
1064
+ const SUPPORTED = new Set(TEXT_ANIMATION_PRESETS);
1065
+ /** A title's box: its width and height props, the whole frame when left out. */
1066
+ function titleBox(props, comp) {
1067
+ return {
1068
+ width: typeof props.width === "number" ? props.width : comp.width,
1069
+ height: typeof props.height === "number" ? props.height : comp.height
1070
+ };
1071
+ }
1072
+ /** Text title: the file's text content with font props; entrance/exit presets as CSS transforms. */
1073
+ function titleContent(p) {
1074
+ const props = p.placement.props;
1075
+ const text = p.text;
1076
+ const rel = p.time - p.placement.start;
1077
+ const anim = props.animation;
1078
+ const { effect, visibleChars } = animation(anim, rel, p.placement.duration, text.length, p.placement.id);
1079
+ const weight = props.weight;
1080
+ const shadow = props.shadow;
1081
+ const stroke = props.stroke;
1082
+ const inner = {
1083
+ width: "100%",
1084
+ height: "100%",
1085
+ display: "flex",
1086
+ alignItems: props.valign === "top" ? "flex-start" : props.valign === "bottom" ? "flex-end" : "center",
1087
+ justifyContent: props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center",
1088
+ textAlign: props.align ?? "center",
1089
+ fontFamily: String(props.font ?? "Inter"),
1090
+ fontSize: Number(props.size ?? 48),
1091
+ fontWeight: typeof weight === "number" ? weight : weight === "normal" ? 400 : 700,
1092
+ fontStyle: props.italic ? "italic" : "normal",
1093
+ color: String(props.color ?? "#ffffff"),
1094
+ lineHeight: Number(props.lineHeight ?? 1.2),
1095
+ letterSpacing: Number(props.letterSpacing ?? 0),
1096
+ textDecoration: typeof props.decoration === "string" ? props.decoration : "none",
1097
+ whiteSpace: "pre-wrap",
1098
+ ...props.background ? { background: String(props.background) } : {},
1099
+ ...shadow ? { textShadow: `${shadow.x ?? 0}px ${shadow.y ?? 0}px ${shadow.blur ?? 0}px ${shadow.color}` } : {},
1100
+ ...stroke ? { WebkitTextStroke: `${stroke.width}px ${stroke.color}` } : {},
1101
+ ...effect
1102
+ };
1103
+ const shown = visibleChars === null ? text : text.slice(0, visibleChars);
1104
+ return /* @__PURE__ */ jsx("div", {
1105
+ style: inner,
1106
+ children: anim?.preset === "word-by-word" ? words(text, rel, anim, p.placement.duration) : shown
1107
+ });
1108
+ }
1109
+ function animation(anim, rel, duration, chars, id) {
1110
+ if (!anim || anim.preset === "none") return {
1111
+ effect: {},
1112
+ visibleChars: null
1113
+ };
1114
+ let preset = anim.preset;
1115
+ if (!SUPPORTED.has(preset)) {
1116
+ devWarn(`title-anim:${id}:${preset}`, `text animation "${preset}" on "${id}" is shown as a fade`);
1117
+ preset = "fade";
1118
+ }
1119
+ const inP = anim.in > 0 ? Math.min(1, rel / anim.in) : 1;
1120
+ const outP = anim.out > 0 ? Math.min(1, Math.max(0, (duration - rel) / anim.out)) : 1;
1121
+ const k = Math.min(inP, outP);
1122
+ if (preset === "typewriter") return {
1123
+ effect: {},
1124
+ visibleChars: Math.round(chars * inP)
1125
+ };
1126
+ if (preset === "word-by-word") return {
1127
+ effect: {},
1128
+ visibleChars: null
1129
+ };
1130
+ const slide = {
1131
+ "slide-up": [0, 40],
1132
+ "slide-down": [0, -40],
1133
+ "slide-left": [40, 0],
1134
+ "slide-right": [-40, 0]
1135
+ }[preset];
1136
+ if (slide) return {
1137
+ effect: {
1138
+ opacity: k,
1139
+ transform: `translate(${slide[0] * (1 - k)}px, ${slide[1] * (1 - k)}px)`
1140
+ },
1141
+ visibleChars: null
1142
+ };
1143
+ if (preset === "scale") return {
1144
+ effect: {
1145
+ opacity: k,
1146
+ transform: `scale(${.6 + .4 * k})`
1147
+ },
1148
+ visibleChars: null
1149
+ };
1150
+ return {
1151
+ effect: { opacity: k },
1152
+ visibleChars: null
1153
+ };
1154
+ }
1155
+ function words(text, rel, anim, duration) {
1156
+ const parts = text.split(/(\s+)/);
1157
+ const wordCount = parts.filter((w) => w.trim().length > 0).length;
1158
+ const stagger = anim.stagger ?? (wordCount > 0 ? anim.in / wordCount : 0);
1159
+ let i = 0;
1160
+ return parts.map((part, idx) => {
1161
+ if (part.trim().length === 0) return /* @__PURE__ */ jsx("span", { children: part }, idx);
1162
+ const shown = rel >= i++ * stagger && rel <= duration;
1163
+ return /* @__PURE__ */ jsx("span", {
1164
+ style: { opacity: shown ? 1 : 0 },
1165
+ children: part
1166
+ }, idx);
1167
+ });
1168
+ }
1169
+ /** The active caption lines, positioned by the captions style; karaoke colours words by their timing. */
1170
+ function captionsContent(p) {
1171
+ const props = p.placement.props;
1172
+ const style = props.style ?? {};
1173
+ const animation = String(props.animation ?? "none");
1174
+ const active = [...p.state.items.values()].filter((c) => c.parent === p.placement.id && c.kind === "caption" && isVisibleAt(c, p.time, p.state.composition.duration)).sort((a, b) => a.start - b.start);
1175
+ const comp = p.state.composition;
1176
+ const portrait = comp.height > comp.width;
1177
+ const shadow = style.shadow ? `${style.shadow.x ?? 0}px ${style.shadow.y ?? 0}px ${style.shadow.blur ?? 0}px ${style.shadow.color}` : "0 0 3px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.55), 0 2px 14px rgba(0,0,0,0.75)";
1178
+ const container = {
1179
+ position: "absolute",
1180
+ left: 0,
1181
+ right: 0,
1182
+ padding: portrait ? "0 12%" : "0 8%",
1183
+ ...style.position === "top" ? { top: comp.height * .08 } : style.position === "center" ? {
1184
+ top: "50%",
1185
+ transform: "translateY(-50%)"
1186
+ } : { bottom: comp.height * (portrait ? .2 : .08) },
1187
+ display: "flex",
1188
+ flexDirection: "column",
1189
+ alignItems: "center",
1190
+ gap: 8,
1191
+ pointerEvents: "none",
1192
+ fontFamily: style.font ?? "Inter",
1193
+ fontSize: style.size ?? Math.round(comp.height / 24),
1194
+ color: style.color ?? "#ffffff",
1195
+ fontWeight: style.weight === "normal" ? 400 : style.weight === "bold" ? 700 : Number(style.weight ?? 800),
1196
+ textShadow: shadow,
1197
+ ...style.stroke ? {
1198
+ WebkitTextStroke: `${style.stroke.width}px ${style.stroke.color}`,
1199
+ paintOrder: "stroke fill"
1200
+ } : {},
1201
+ textAlign: "center",
1202
+ lineHeight: 1.15
1203
+ };
1204
+ return /* @__PURE__ */ jsx("div", {
1205
+ "data-el": p.placement.id,
1206
+ "data-kind": "captions",
1207
+ style: container,
1208
+ children: active.map((c) => /* @__PURE__ */ jsx("div", {
1209
+ "data-el": c.id,
1210
+ "data-kind": "caption",
1211
+ style: {
1212
+ padding: "0.2em 0.5em",
1213
+ borderRadius: 6,
1214
+ textWrap: "balance",
1215
+ ...style.background ? { background: style.background } : {}
1216
+ },
1217
+ children: line(c, p.time, animation, style)
1218
+ }, c.id))
1219
+ });
1220
+ }
1221
+ function line(c, time, animation, style) {
1222
+ const text = String(c.props.text ?? "");
1223
+ const words = c.props.words;
1224
+ if (!words || words.length === 0 || animation === "none") {
1225
+ if (animation === "typewriter" && c.duration > 0) return text.slice(0, Math.round(text.length * (time - c.start) / c.duration));
1226
+ return text;
1227
+ }
1228
+ return words.map((w, i) => {
1229
+ const done = time >= w.end;
1230
+ const color = time >= w.start && time < w.end ? style.highlight ?? "#ffd400" : done || animation === "word-highlight" ? void 0 : style.upcoming ?? "rgba(255,255,255,0.5)";
1231
+ const hidden = animation === "word-by-word" && time < w.start;
1232
+ return /* @__PURE__ */ jsxs("span", {
1233
+ style: {
1234
+ ...color ? { color } : {},
1235
+ ...hidden ? { opacity: 0 } : {}
1236
+ },
1237
+ children: [w.text, i < words.length - 1 ? " " : ""]
1238
+ }, i);
1239
+ });
1240
+ }
1241
+ /** The interior as written, with the Layer's handles (edited props) applied to its one component. */
1242
+ function layerContent(interior, props) {
1243
+ if (!props) return interior;
1244
+ const only = Children.toArray(interior).filter((c) => c !== null && typeof c !== "boolean" && c !== "");
1245
+ const child = only.length === 1 ? only[0] : null;
1246
+ return isValidElement(child) ? cloneElement(child, props) : interior;
1247
+ }
1248
+ /** The labelled box shown for a sticker with no media. */
1249
+ function stickerPlaceholder(label) {
1250
+ return /* @__PURE__ */ jsx("div", {
1251
+ style: {
1252
+ width: "100%",
1253
+ height: "100%",
1254
+ background: "#333",
1255
+ color: "#ccc",
1256
+ font: "16px monospace",
1257
+ display: "flex",
1258
+ alignItems: "center",
1259
+ justifyContent: "center",
1260
+ borderRadius: 8
1261
+ },
1262
+ children: label
1263
+ });
1264
+ }
1265
+ /** Side length of the box a sticker is drawn in. */
1266
+ function stickerSize(p) {
1267
+ return Math.round(Math.min(p.state.composition.width, p.state.composition.height) / 4);
1268
+ }
1269
+ // src/react/elements/Captions.tsx
1270
+ /** The active caption lines, positioned by the captions style; karaoke colours words by their timing. */
1271
+ function Captions(p) {
1272
+ return captionsContent(p);
1273
+ }
1274
+ // src/overlays/smil.ts
1275
+ /**
1276
+ * SVG animation (SMIL) evaluated at one moment of the element's own time and
1277
+ * written into plain attributes, so the frame follows the video's clock rather
1278
+ * than the browser's, which would otherwise play SMIL on its own.
1279
+ *
1280
+ * Covered: `<animate>`, `<set>`, `<animateTransform>` and `<animateColor>`;
1281
+ * `values`, `from`/`to`/`by`, `keyTimes`, `keySplines`; linear, discrete,
1282
+ * spline and paced (as linear) timing; `begin` and `end` as offsets or
1283
+ * `id.begin`/`id.end` with an offset; `dur`, `repeatCount`, `repeatDur`,
1284
+ * `fill="freeze"`, `additive` and `accumulate="sum"`; a target by `href`.
1285
+ * Not covered, and dropped with a warning: `<animateMotion>`, and begins on
1286
+ * events (`click`, `mouseover`) or `indefinite`.
1287
+ */
1288
+ const ANIMATIONS = /* @__PURE__ */ new Set([
1289
+ "animate",
1290
+ "set",
1291
+ "animateTransform",
1292
+ "animateColor",
1293
+ "animateMotion"
1294
+ ]);
1295
+ /** Whether a tree has SMIL animations in it. */
1296
+ function hasSmil(node) {
1297
+ return ANIMATIONS.has(node.name) || node.children.some((c) => typeof c !== "string" && hasSmil(c));
1298
+ }
1299
+ /** The tree with every animation applied at `time` seconds and removed. Returns a copy; the input is not changed. */
1300
+ function freezeSmil(root, time, warnings) {
1301
+ const copy = clone(root);
1302
+ const ids = /* @__PURE__ */ new Map();
1303
+ const anims = [];
1304
+ const collect = (node, parent) => {
1305
+ if (node.attrs.id) ids.set(node.attrs.id, node);
1306
+ if (ANIMATIONS.has(node.name) && parent) anims.push({
1307
+ node,
1308
+ target: parent,
1309
+ order: anims.length
1310
+ });
1311
+ for (const c of node.children) if (typeof c !== "string") collect(c, node);
1312
+ };
1313
+ collect(copy, null);
1314
+ if (anims.length === 0) return copy;
1315
+ const byId = /* @__PURE__ */ new Map();
1316
+ for (const a of anims) {
1317
+ const href = a.node.attrs.href ?? a.node.attrs["xlink:href"];
1318
+ if (href?.startsWith("#")) {
1319
+ const t = ids.get(href.slice(1));
1320
+ if (t) a.target = t;
1321
+ }
1322
+ if (a.node.attrs.id) byId.set(a.node.attrs.id, a);
1323
+ }
1324
+ const warned = /* @__PURE__ */ new Set();
1325
+ const warn = (key, message) => {
1326
+ if (!warned.has(key)) {
1327
+ warned.add(key);
1328
+ warnings.push(message);
1329
+ }
1330
+ };
1331
+ const intervals = /* @__PURE__ */ new Map();
1332
+ const resolving = /* @__PURE__ */ new Set();
1333
+ const intervalOf = (a) => {
1334
+ if (intervals.has(a)) return intervals.get(a);
1335
+ if (resolving.has(a)) {
1336
+ warn("cycle", "SMIL animations that begin on each other in a loop are left out");
1337
+ return null;
1338
+ }
1339
+ resolving.add(a);
1340
+ const result = activeInterval(a, time, (id, edge) => {
1341
+ const other = byId.get(id);
1342
+ const iv = other ? intervalOf(other) : null;
1343
+ return iv ? edge === "begin" ? iv.begin : iv.end : null;
1344
+ }, warn);
1345
+ resolving.delete(a);
1346
+ intervals.set(a, result);
1347
+ return result;
1348
+ };
1349
+ const base = /* @__PURE__ */ new Map();
1350
+ const sandwich = /* @__PURE__ */ new Map();
1351
+ for (const a of anims) {
1352
+ if (a.node.name === "animateMotion") {
1353
+ warn("motion", "<animateMotion> is not played; the element stays where it is");
1354
+ continue;
1355
+ }
1356
+ const name = a.node.name === "animateTransform" ? "transform" : a.node.attrs.attributeName;
1357
+ if (!name) continue;
1358
+ const iv = intervalOf(a);
1359
+ if (!iv) continue;
1360
+ const baseAttrs = base.get(a.target) ?? {
1361
+ ...a.target.attrs,
1362
+ ...styleProps(a.target.attrs.style)
1363
+ };
1364
+ base.set(a.target, baseAttrs);
1365
+ const current = sandwich.get(a.target) ?? /* @__PURE__ */ new Map();
1366
+ sandwich.set(a.target, current);
1367
+ const value = valueAt(a, iv, time, current.get(name) ?? baseAttrs[name] ?? "", baseAttrs[name] ?? "");
1368
+ if (value !== null) current.set(name, value);
1369
+ }
1370
+ for (const [target, values] of sandwich) for (const [name, value] of values) {
1371
+ target.attrs[name] = value;
1372
+ if (target.attrs.style) target.attrs.style = withoutProp(target.attrs.style, name);
1373
+ }
1374
+ return strip(copy);
1375
+ }
1376
+ /** The first interval of an animation that has begun by `time` (the latest such), or null when it never applies. */
1377
+ function activeInterval(a, time, sync, warn) {
1378
+ const attrs = a.node.attrs;
1379
+ const begins = timeList(attrs.begin ?? "0", sync, (spec) => warn(`begin:${spec}`, `SMIL begin "${spec}" waits for an event or a script; that animation is left out`));
1380
+ const started = begins.filter((b) => b <= time + 1e-9);
1381
+ if (begins.length === 0) return null;
1382
+ const begin = started.length ? Math.max(...started) : Math.min(...begins);
1383
+ const simple = durationOf(a);
1384
+ const repeatCount = attrs.repeatCount === "indefinite" ? Infinity : attrs.repeatCount !== void 0 ? Number(attrs.repeatCount) : null;
1385
+ const repeatDur = attrs.repeatDur === "indefinite" ? Infinity : attrs.repeatDur !== void 0 ? clock(attrs.repeatDur) : null;
1386
+ let active = simple;
1387
+ if (repeatCount !== null || repeatDur !== null) active = Math.min(repeatCount === null ? Infinity : simple * repeatCount, repeatDur ?? Infinity);
1388
+ if (attrs.end !== void 0) {
1389
+ const ends = timeList(attrs.end, sync, () => void 0).filter((e) => e >= begin);
1390
+ if (ends.length) active = Math.min(active, Math.min(...ends) - begin);
1391
+ }
1392
+ return {
1393
+ begin,
1394
+ end: begin + Math.max(0, active)
1395
+ };
1396
+ }
1397
+ function durationOf(a) {
1398
+ const dur = a.node.attrs.dur;
1399
+ if (dur === void 0 || dur === "indefinite" || dur === "media") return Infinity;
1400
+ const d = clock(dur);
1401
+ return d !== null && d > 0 ? d : Infinity;
1402
+ }
1403
+ /** The animation's value at `time`, over `under` (what lower animations or the base give), or null when it does not apply. */
1404
+ function valueAt(a, iv, time, under, baseValue) {
1405
+ const attrs = a.node.attrs;
1406
+ if (time < iv.begin) return null;
1407
+ const frozen = time >= iv.end;
1408
+ if (frozen && attrs.fill !== "freeze") return null;
1409
+ if (a.node.name === "set") return attrs.to ?? null;
1410
+ const dur = durationOf(a);
1411
+ const t = (frozen ? iv.end : time) - iv.begin;
1412
+ let iteration = 0, k;
1413
+ if (!Number.isFinite(dur)) k = 0;
1414
+ else {
1415
+ iteration = Math.floor(t / dur);
1416
+ k = (t - iteration * dur) / dur;
1417
+ if (frozen && iteration > 0 && k < 1e-9) {
1418
+ iteration -= 1;
1419
+ k = 1;
1420
+ }
1421
+ }
1422
+ const list = valuesOf(a, baseValue);
1423
+ if (list.length === 0) return null;
1424
+ let value = sample(list, k, attrs);
1425
+ if (attrs.accumulate === "sum" && iteration > 0) value = addValues(value, scaleValue(list[list.length - 1], iteration));
1426
+ const transform = a.node.name === "animateTransform";
1427
+ if (transform) value = `${attrs.type ?? "translate"}(${value})`;
1428
+ if (attrs.additive === "sum" || !attrs.values && !attrs.from && attrs.by !== void 0) return transform ? `${under} ${value}`.trim() : addValues(under, value);
1429
+ return value;
1430
+ }
1431
+ function valuesOf(a, baseValue) {
1432
+ const attrs = a.node.attrs;
1433
+ if (attrs.values) return attrs.values.split(";").map((v) => v.trim()).filter((v) => v.length > 0);
1434
+ if (attrs.from !== void 0 && attrs.to !== void 0) return [attrs.from, attrs.to];
1435
+ if (attrs.from !== void 0 && attrs.by !== void 0) return [attrs.from, addValues(attrs.from, attrs.by)];
1436
+ if (attrs.to !== void 0) return [a.node.name === "animateTransform" ? "0" : baseValue || attrs.to, attrs.to];
1437
+ if (attrs.by !== void 0) return [scaleValue(attrs.by, 0), attrs.by];
1438
+ return [];
1439
+ }
1440
+ /** The value `k` (0..1) of the way through a list, by the animation's calcMode, keyTimes and keySplines. */
1441
+ function sample(values, k, attrs) {
1442
+ const mode = attrs.calcMode ?? "linear";
1443
+ const n = values.length;
1444
+ if (n === 1) return values[0];
1445
+ const times = attrs.keyTimes?.split(";").map(Number);
1446
+ const keys = times && times.length === n && times.every(Number.isFinite) ? times : null;
1447
+ if (mode === "discrete") {
1448
+ if (keys) {
1449
+ let i = 0;
1450
+ while (i + 1 < n && keys[i + 1] <= k + 1e-9) i++;
1451
+ return values[i];
1452
+ }
1453
+ return values[Math.min(n - 1, Math.floor(k * n))];
1454
+ }
1455
+ let i, local;
1456
+ if (keys) {
1457
+ i = 0;
1458
+ while (i + 2 < n && keys[i + 1] <= k) i++;
1459
+ const a = keys[i], b = keys[i + 1];
1460
+ local = b > a ? (k - a) / (b - a) : 1;
1461
+ } else {
1462
+ const x = k * (n - 1);
1463
+ i = Math.min(n - 2, Math.floor(x));
1464
+ local = x - i;
1465
+ }
1466
+ local = Math.max(0, Math.min(1, local));
1467
+ if (mode === "spline") {
1468
+ const spline = attrs.keySplines?.split(";")[i]?.trim().split(/[\s,]+/).map(Number);
1469
+ if (spline && spline.length === 4 && spline.every(Number.isFinite)) local = bezier(local, spline);
1470
+ }
1471
+ return interpolate(values[i], values[i + 1], local);
1472
+ }
1473
+ /** Two values `k` of the way from `a` to `b`: numbers in the same places interpolate, colours mix, anything else switches halfway. */
1474
+ function interpolate(a, b, k) {
1475
+ const ca = colour(a), cb = colour(b);
1476
+ if (ca && cb) {
1477
+ const ch = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * k);
1478
+ return `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`;
1479
+ }
1480
+ const pa = split(a), pb = split(b);
1481
+ if (pa.text.length === pb.text.length && pa.text.every((t, i) => t === pb.text[i])) {
1482
+ let out = pa.text[0];
1483
+ for (let i = 0; i < pa.nums.length; i++) out += fmt(pa.nums[i] + (pb.nums[i] - pa.nums[i]) * k) + pa.text[i + 1];
1484
+ return out;
1485
+ }
1486
+ return k < .5 ? a : b;
1487
+ }
1488
+ const NUMBER = /[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?/g;
1489
+ /** A value as the text between its numbers, and the numbers. */
1490
+ function split(v) {
1491
+ const text = [], nums = [];
1492
+ let last = 0;
1493
+ for (const m of v.matchAll(NUMBER)) {
1494
+ text.push(v.slice(last, m.index));
1495
+ nums.push(Number(m[0]));
1496
+ last = (m.index ?? 0) + m[0].length;
1497
+ }
1498
+ text.push(v.slice(last));
1499
+ return {
1500
+ text,
1501
+ nums
1502
+ };
1503
+ }
1504
+ function addValues(a, b) {
1505
+ if (!a) return b;
1506
+ const ca = colour(a), cb = colour(b);
1507
+ if (ca && cb) return `rgb(${ca.map((x, i) => Math.min(255, Math.round(x + cb[i]))).join(", ")})`;
1508
+ const pa = split(a), pb = split(b);
1509
+ if (pa.nums.length !== pb.nums.length) return b;
1510
+ let out = pb.text[0];
1511
+ for (let i = 0; i < pb.nums.length; i++) out += fmt(pa.nums[i] + pb.nums[i]) + pb.text[i + 1];
1512
+ return out;
1513
+ }
1514
+ function scaleValue(v, k) {
1515
+ const p = split(v);
1516
+ let out = p.text[0];
1517
+ for (let i = 0; i < p.nums.length; i++) out += fmt(p.nums[i] * k) + p.text[i + 1];
1518
+ return out;
1519
+ }
1520
+ const NAMED = {
1521
+ black: [
1522
+ 0,
1523
+ 0,
1524
+ 0
1525
+ ],
1526
+ white: [
1527
+ 255,
1528
+ 255,
1529
+ 255
1530
+ ],
1531
+ red: [
1532
+ 255,
1533
+ 0,
1534
+ 0
1535
+ ],
1536
+ green: [
1537
+ 0,
1538
+ 128,
1539
+ 0
1540
+ ],
1541
+ blue: [
1542
+ 0,
1543
+ 0,
1544
+ 255
1545
+ ],
1546
+ yellow: [
1547
+ 255,
1548
+ 255,
1549
+ 0
1550
+ ],
1551
+ orange: [
1552
+ 255,
1553
+ 165,
1554
+ 0
1555
+ ],
1556
+ purple: [
1557
+ 128,
1558
+ 0,
1559
+ 128
1560
+ ],
1561
+ gray: [
1562
+ 128,
1563
+ 128,
1564
+ 128
1565
+ ],
1566
+ grey: [
1567
+ 128,
1568
+ 128,
1569
+ 128
1570
+ ],
1571
+ cyan: [
1572
+ 0,
1573
+ 255,
1574
+ 255
1575
+ ],
1576
+ magenta: [
1577
+ 255,
1578
+ 0,
1579
+ 255
1580
+ ]
1581
+ };
1582
+ function colour(v) {
1583
+ const c = v.trim().toLowerCase();
1584
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
1585
+ if (hex) {
1586
+ const h = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
1587
+ return [
1588
+ parseInt(h.slice(0, 2), 16),
1589
+ parseInt(h.slice(2, 4), 16),
1590
+ parseInt(h.slice(4, 6), 16)
1591
+ ];
1592
+ }
1593
+ const fn = /^rgb\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*\)$/.exec(c);
1594
+ if (fn) return [
1595
+ Number(fn[1]),
1596
+ Number(fn[2]),
1597
+ Number(fn[3])
1598
+ ];
1599
+ return NAMED[c] ?? null;
1600
+ }
1601
+ /** A cubic-bezier easing (x1 y1 x2 y2) at progress x. */
1602
+ function bezier(x, [x1, y1, x2, y2]) {
1603
+ const at = (t, a, b) => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t;
1604
+ let lo = 0, hi = 1, t = x;
1605
+ for (let i = 0; i < 30; i++) {
1606
+ t = (lo + hi) / 2;
1607
+ if (at(t, x1, x2) < x) lo = t;
1608
+ else hi = t;
1609
+ }
1610
+ return at(t, y1, y2);
1611
+ }
1612
+ /** A begin or end list: offsets and `id.begin`/`id.end` with an offset. Event and `indefinite` entries are left out (and reported). */
1613
+ function timeList(spec, sync, unsupported) {
1614
+ const out = [];
1615
+ for (const raw of spec.split(";")) {
1616
+ const s = raw.trim();
1617
+ if (!s) continue;
1618
+ const offset = clock(s);
1619
+ if (offset !== null) {
1620
+ out.push(offset);
1621
+ continue;
1622
+ }
1623
+ const m = /^([A-Za-z_][\w-]*)\.(begin|end)\s*(?:([+-])\s*(.+))?$/.exec(s);
1624
+ if (m) {
1625
+ const at = sync(m[1], m[2]);
1626
+ const extra = m[4] ? clock(m[4]) : 0;
1627
+ if (at !== null && extra !== null && Number.isFinite(at)) out.push(at + (m[3] === "-" ? -extra : extra));
1628
+ continue;
1629
+ }
1630
+ unsupported(s);
1631
+ }
1632
+ return out;
1633
+ }
1634
+ /** A SMIL clock value in seconds: `2s`, `500ms`, `1.5`, `1min`, `0.1h`, `01:02.5`, `00:01:02`. */
1635
+ function clock(v) {
1636
+ const s = v.trim();
1637
+ const unit = /^([-+]?[\d.]+)\s*(h|min|s|ms)?$/.exec(s);
1638
+ if (unit) {
1639
+ const n = Number(unit[1]);
1640
+ if (!Number.isFinite(n)) return null;
1641
+ return n * {
1642
+ h: 3600,
1643
+ min: 60,
1644
+ s: 1,
1645
+ ms: .001
1646
+ }[unit[2] ?? "s"];
1647
+ }
1648
+ const parts = /^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(s);
1649
+ if (parts) return Number(parts[1] ?? 0) * 3600 + Number(parts[2]) * 60 + Number(parts[3]);
1650
+ return null;
1651
+ }
1652
+ function styleProps(style) {
1653
+ const out = {};
1654
+ for (const decl of (style ?? "").split(";")) {
1655
+ const i = decl.indexOf(":");
1656
+ if (i > 0) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
1657
+ }
1658
+ return out;
1659
+ }
1660
+ function withoutProp(style, name) {
1661
+ return style.split(";").filter((d) => d.split(":")[0]?.trim() !== name).join(";");
1662
+ }
1663
+ function clone(node) {
1664
+ return {
1665
+ name: node.name,
1666
+ attrs: { ...node.attrs },
1667
+ children: node.children.map((c) => typeof c === "string" ? c : clone(c))
1668
+ };
1669
+ }
1670
+ function strip(node) {
1671
+ return {
1672
+ ...node,
1673
+ children: node.children.filter((c) => typeof c === "string" || !ANIMATIONS.has(c.name)).map((c) => typeof c === "string" ? c : strip(c))
1674
+ };
1675
+ }
1676
+ function fmt(v) {
1677
+ return String(Math.round(v * 1e3) / 1e3);
1678
+ }
1679
+ // src/overlays/svg-length.ts
1680
+ /**
1681
+ * Stroke lengths of SVG shapes in their own user units, for drawing strokes
1682
+ * progressively with dashes. Skia ignores `pathLength`, so the lengths are
1683
+ * measured here and the dashes written in absolute units. Curves and arcs are
1684
+ * flattened into short segments; the result is close, not exact, which is
1685
+ * enough to hide and reveal a stroke.
1686
+ */
1687
+ const SEGMENTS = 24;
1688
+ /** The length of the outline a shape element strokes, or null for elements with no outline of their own. */
1689
+ function shapeLength(name, a) {
1690
+ const n = (k, d = 0) => {
1691
+ const v = parseFloat(a[k] ?? "");
1692
+ return Number.isFinite(v) ? v : d;
1693
+ };
1694
+ switch (name) {
1695
+ case "path": return a.d ? pathLength(a.d) : 0;
1696
+ case "line": return Math.hypot(n("x2") - n("x1"), n("y2") - n("y1"));
1697
+ case "polyline": return polyLength(points(a.points ?? ""), false);
1698
+ case "polygon": return polyLength(points(a.points ?? ""), true);
1699
+ case "circle": return 2 * Math.PI * n("r");
1700
+ case "ellipse": return ellipseLength(n("rx"), n("ry"));
1701
+ case "rect": {
1702
+ const w = n("width"), h = n("height");
1703
+ let rx = Math.min(n("rx", n("ry")), w / 2), ry = Math.min(n("ry", n("rx")), h / 2);
1704
+ if (rx <= 0 || ry <= 0) rx = ry = 0;
1705
+ return 2 * (w - 2 * rx) + 2 * (h - 2 * ry) + ellipseLength(rx, ry);
1706
+ }
1707
+ default: return null;
1708
+ }
1709
+ }
1710
+ /** Ramanujan's approximation of an ellipse's circumference. */
1711
+ function ellipseLength(rx, ry) {
1712
+ if (rx <= 0 || ry <= 0) return 0;
1713
+ return Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
1714
+ }
1715
+ function points(list) {
1716
+ const v = list.trim().split(/[\s,]+/).map(Number).filter((x) => Number.isFinite(x));
1717
+ const out = [];
1718
+ for (let i = 0; i + 1 < v.length; i += 2) out.push([v[i], v[i + 1]]);
1719
+ return out;
1720
+ }
1721
+ function polyLength(p, closed) {
1722
+ let len = 0;
1723
+ for (let i = 1; i < p.length; i++) len += dist(p[i - 1], p[i]);
1724
+ if (closed && p.length > 2) len += dist(p[p.length - 1], p[0]);
1725
+ return len;
1726
+ }
1727
+ function dist(a, b) {
1728
+ return Math.hypot(b[0] - a[0], b[1] - a[1]);
1729
+ }
1730
+ /** The length of an SVG path's outline, every command included. */
1731
+ function pathLength(d) {
1732
+ const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? [];
1733
+ let i = 0;
1734
+ let cmd = "";
1735
+ let x = 0, y = 0, sx = 0, sy = 0;
1736
+ let cx = 0, cy = 0;
1737
+ let prev = "";
1738
+ let len = 0;
1739
+ const num = () => Number(tokens[i++]);
1740
+ const more = () => i < tokens.length && !/^[a-zA-Z]$/.test(tokens[i]);
1741
+ const line = (nx, ny) => {
1742
+ len += Math.hypot(nx - x, ny - y);
1743
+ x = nx;
1744
+ y = ny;
1745
+ };
1746
+ const cubic = (x1, y1, x2, y2, nx, ny) => {
1747
+ let px = x, py = y;
1748
+ for (let s = 1; s <= SEGMENTS; s++) {
1749
+ const t = s / SEGMENTS, u = 1 - t;
1750
+ const qx = u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * nx;
1751
+ const qy = u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * ny;
1752
+ len += Math.hypot(qx - px, qy - py);
1753
+ px = qx;
1754
+ py = qy;
1755
+ }
1756
+ cx = x2;
1757
+ cy = y2;
1758
+ x = nx;
1759
+ y = ny;
1760
+ };
1761
+ const quad = (x1, y1, nx, ny) => {
1762
+ let px = x, py = y;
1763
+ for (let s = 1; s <= SEGMENTS; s++) {
1764
+ const t = s / SEGMENTS, u = 1 - t;
1765
+ const qx = u * u * x + 2 * u * t * x1 + t * t * nx, qy = u * u * y + 2 * u * t * y1 + t * t * ny;
1766
+ len += Math.hypot(qx - px, qy - py);
1767
+ px = qx;
1768
+ py = qy;
1769
+ }
1770
+ cx = x1;
1771
+ cy = y1;
1772
+ x = nx;
1773
+ y = ny;
1774
+ };
1775
+ while (i < tokens.length) {
1776
+ const t = tokens[i];
1777
+ if (/^[a-zA-Z]$/.test(t)) {
1778
+ cmd = t;
1779
+ i++;
1780
+ } else if (!cmd) {
1781
+ i++;
1782
+ continue;
1783
+ }
1784
+ const rel = cmd === cmd.toLowerCase();
1785
+ const ox = rel ? x : 0, oy = rel ? y : 0;
1786
+ switch (cmd.toUpperCase()) {
1787
+ case "M":
1788
+ if (!more()) break;
1789
+ x = ox + num();
1790
+ y = oy + num();
1791
+ sx = x;
1792
+ sy = y;
1793
+ cmd = rel ? "l" : "L";
1794
+ break;
1795
+ case "L":
1796
+ if (more()) line(ox + num(), oy + num());
1797
+ break;
1798
+ case "H":
1799
+ if (more()) line(ox + num(), y);
1800
+ break;
1801
+ case "V":
1802
+ if (more()) line(x, oy + num());
1803
+ break;
1804
+ case "C":
1805
+ if (more()) cubic(ox + num(), oy + num(), ox + num(), oy + num(), ox + num(), oy + num());
1806
+ break;
1807
+ case "S":
1808
+ if (more()) {
1809
+ const [x1, y1] = /[CS]/i.test(prev) ? [2 * x - cx, 2 * y - cy] : [x, y];
1810
+ cubic(x1, y1, ox + num(), oy + num(), ox + num(), oy + num());
1811
+ }
1812
+ break;
1813
+ case "Q":
1814
+ if (more()) quad(ox + num(), oy + num(), ox + num(), oy + num());
1815
+ break;
1816
+ case "T":
1817
+ if (more()) {
1818
+ const [x1, y1] = /[QT]/i.test(prev) ? [2 * x - cx, 2 * y - cy] : [x, y];
1819
+ quad(x1, y1, ox + num(), oy + num());
1820
+ }
1821
+ break;
1822
+ case "A":
1823
+ if (more()) {
1824
+ const rx = Math.abs(num()), ry = Math.abs(num()), phi = num(), large = num(), sweep = num();
1825
+ const nx = ox + num(), ny = oy + num();
1826
+ len += arcLength(x, y, rx, ry, phi, large !== 0, sweep !== 0, nx, ny);
1827
+ x = nx;
1828
+ y = ny;
1829
+ }
1830
+ break;
1831
+ case "Z":
1832
+ line(sx, sy);
1833
+ break;
1834
+ default: i++;
1835
+ }
1836
+ prev = cmd;
1837
+ if (cmd.toUpperCase() === "Z") cmd = "";
1838
+ }
1839
+ return len;
1840
+ }
1841
+ /** An elliptical arc from the endpoint form (SVG implementation notes, F.6.5), flattened. */
1842
+ function arcLength(x1, y1, rx, ry, phiDeg, large, sweep, x2, y2) {
1843
+ if (rx === 0 || ry === 0) return Math.hypot(x2 - x1, y2 - y1);
1844
+ const phi = phiDeg * Math.PI / 180, cos = Math.cos(phi), sin = Math.sin(phi);
1845
+ const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
1846
+ const x1p = cos * dx + sin * dy, y1p = -sin * dx + cos * dy;
1847
+ const lambda = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
1848
+ if (lambda > 1) {
1849
+ rx *= Math.sqrt(lambda);
1850
+ ry *= Math.sqrt(lambda);
1851
+ }
1852
+ const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
1853
+ const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
1854
+ const coef = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
1855
+ const cxp = coef * rx * y1p / ry, cyp = -coef * ry * x1p / rx;
1856
+ const angle = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
1857
+ const t1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
1858
+ let dt = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
1859
+ if (!sweep && dt > 0) dt -= 2 * Math.PI;
1860
+ if (sweep && dt < 0) dt += 2 * Math.PI;
1861
+ const cx = cos * cxp - sin * cyp + (x1 + x2) / 2, cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
1862
+ let len = 0, px = x1, py = y1;
1863
+ const steps = Math.max(SEGMENTS, Math.ceil(Math.abs(dt) / (2 * Math.PI) * SEGMENTS * 4));
1864
+ for (let s = 1; s <= steps; s++) {
1865
+ const t = t1 + dt * s / steps;
1866
+ const ex = rx * Math.cos(t), ey = ry * Math.sin(t);
1867
+ const qx = cos * ex - sin * ey + cx, qy = sin * ex + cos * ey + cy;
1868
+ len += Math.hypot(qx - px, qy - py);
1869
+ px = qx;
1870
+ py = qy;
1871
+ }
1872
+ return len;
1873
+ }
1874
+ // src/overlays/svg.ts
1875
+ const SVG_NS = "http://www.w3.org/2000/svg";
1876
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
1877
+ /** Elements that run code, embed other documents or reach outside the file. */
1878
+ const DROPPED = /* @__PURE__ */ new Set([
1879
+ "script",
1880
+ "foreignObject",
1881
+ "iframe",
1882
+ "object",
1883
+ "embed",
1884
+ "audio",
1885
+ "video",
1886
+ "canvas",
1887
+ "handler",
1888
+ "listener"
1889
+ ]);
1890
+ const DEFAULT_SIZE = {
1891
+ width: 300,
1892
+ height: 150
1893
+ };
1894
+ const PARSE_CACHE = 64;
1895
+ const parsed = /* @__PURE__ */ new Map();
1896
+ /** Parses SVG markup into a small tree; the result is cached by the markup string. */
1897
+ function parseSvg(markup) {
1898
+ const hit = parsed.get(markup);
1899
+ if (hit) return hit;
1900
+ const result = parse(markup);
1901
+ parsed.set(markup, result);
1902
+ if (parsed.size > PARSE_CACHE) parsed.delete(parsed.keys().next().value);
1903
+ return result;
1904
+ }
1905
+ const animatedRoots = /* @__PURE__ */ new WeakMap();
1906
+ /** Whether markup animates (SMIL or CSS animations): such markup is prepared for each moment, the rest once. */
1907
+ function svgAnimated(markup) {
1908
+ const root = parseSvg(markup).root;
1909
+ if (!root) return false;
1910
+ let hit = animatedRoots.get(root);
1911
+ if (hit === void 0) {
1912
+ hit = hasSmil(root);
1913
+ animatedRoots.set(root, hit);
1914
+ }
1915
+ return hit;
1916
+ }
1917
+ /** The size an SVG asks for: its width and height, else its viewBox, else the HTML default of 300 by 150. */
1918
+ function svgIntrinsicSize(markup, viewBox) {
1919
+ const root = parseSvg(markup).root;
1920
+ const box = viewBox ?? (root ? viewBoxOf(root) : null);
1921
+ const w = root ? length(root.attrs.width) : null, h = root ? length(root.attrs.height) : null;
1922
+ if (w !== null && h !== null) return {
1923
+ width: w,
1924
+ height: h
1925
+ };
1926
+ if (box) {
1927
+ if (w !== null) return {
1928
+ width: w,
1929
+ height: w * box.height / box.width
1930
+ };
1931
+ if (h !== null) return {
1932
+ width: h * box.width / box.height,
1933
+ height: h
1934
+ };
1935
+ return {
1936
+ width: box.width,
1937
+ height: box.height
1938
+ };
1939
+ }
1940
+ return {
1941
+ width: w ?? DEFAULT_SIZE.width,
1942
+ height: h ?? DEFAULT_SIZE.height
1943
+ };
1944
+ }
1945
+ const PREPARED_CACHE = 128;
1946
+ const prepared = /* @__PURE__ */ new Map();
1947
+ /** The markup to draw for an SVG element in a box of `options.box` pixels. Results are cached by markup and options. */
1948
+ function prepareSvg(markup, options) {
1949
+ const key = `${JSON.stringify(options)}\u0000${markup}`;
1950
+ const hit = prepared.get(key);
1951
+ if (hit) return hit;
1952
+ const result = build(markup, options);
1953
+ prepared.set(key, result);
1954
+ if (prepared.size > PREPARED_CACHE) prepared.delete(prepared.keys().next().value);
1955
+ return result;
1956
+ }
1957
+ function build(markup, options) {
1958
+ const { root: parsedRoot, warnings: parseWarnings } = parseSvg(markup);
1959
+ const warnings = [...parseWarnings];
1960
+ if (!parsedRoot) return {
1961
+ markup: "",
1962
+ animated: false,
1963
+ warnings
1964
+ };
1965
+ const animated = svgAnimated(markup);
1966
+ const root = animated ? freezeSmil(parsedRoot, options.time ?? 0, warnings) : parsedRoot;
1967
+ const prefix = options.idPrefix ? `${options.idPrefix.replace(/[^A-Za-z0-9_-]/g, "_")}-` : "";
1968
+ const draw = options.draw === null || options.draw === void 0 || options.draw >= 1 ? null : Math.max(0, options.draw);
1969
+ const ctx = {
1970
+ prefix,
1971
+ tokens: options.tokens ?? {},
1972
+ warnings,
1973
+ unknownTokens: /* @__PURE__ */ new Set(),
1974
+ draw,
1975
+ inDefs: false
1976
+ };
1977
+ const body = root.children.map((c) => clean(c, ctx)).filter((c) => c !== null);
1978
+ const box = options.viewBox ?? viewBoxOf(root) ?? {
1979
+ minX: 0,
1980
+ minY: 0,
1981
+ ...svgIntrinsicSize(markup)
1982
+ };
1983
+ const bleed = Math.max(0, options.bleed ?? 0);
1984
+ const bx = bleed * (box.width / Math.max(1e-9, options.box.width)), by = bleed * (box.height / Math.max(1e-9, options.box.height));
1985
+ const rootAttrs = {};
1986
+ for (const [k, v] of Object.entries(root.attrs)) {
1987
+ if (k === "width" || k === "height" || k === "viewBox" || k === "preserveAspectRatio" || k === "x" || k === "y" || k.startsWith("xmlns")) continue;
1988
+ const kept = cleanAttr(k, v, ctx);
1989
+ if (kept !== null) rootAttrs[k] = kept;
1990
+ }
1991
+ const head = {
1992
+ xmlns: SVG_NS,
1993
+ "xmlns:xlink": XLINK_NS,
1994
+ width: num$1(options.box.width + 2 * bleed),
1995
+ height: num$1(options.box.height + 2 * bleed),
1996
+ viewBox: `${num$1(box.minX - bx)} ${num$1(box.minY - by)} ${num$1(box.width + 2 * bx)} ${num$1(box.height + 2 * by)}`,
1997
+ preserveAspectRatio: options.preserveAspectRatio ?? root.attrs.preserveAspectRatio ?? "xMidYMid meet"
1998
+ };
1999
+ let children = body;
2000
+ const filter = colorFilter(options.color, `${prefix}cc-color`, {
2001
+ minX: box.minX - bx,
2002
+ minY: box.minY - by,
2003
+ width: box.width + 2 * bx,
2004
+ height: box.height + 2 * by
2005
+ });
2006
+ if (filter) children = [{
2007
+ name: "defs",
2008
+ attrs: {},
2009
+ children: [filter]
2010
+ }, {
2011
+ name: "g",
2012
+ attrs: { filter: `url(#${prefix}cc-color)` },
2013
+ children: body
2014
+ }];
2015
+ return {
2016
+ markup: serialize({
2017
+ name: "svg",
2018
+ attrs: {
2019
+ ...head,
2020
+ ...rootAttrs
2021
+ },
2022
+ children
2023
+ }),
2024
+ animated,
2025
+ warnings
2026
+ };
2027
+ }
2028
+ const DEFINITIONS = /* @__PURE__ */ new Set([
2029
+ "defs",
2030
+ "clipPath",
2031
+ "mask",
2032
+ "marker",
2033
+ "pattern",
2034
+ "symbol",
2035
+ "linearGradient",
2036
+ "radialGradient",
2037
+ "filter"
2038
+ ]);
2039
+ const FILLED = /* @__PURE__ */ new Set([
2040
+ "path",
2041
+ "line",
2042
+ "polyline",
2043
+ "polygon",
2044
+ "circle",
2045
+ "ellipse",
2046
+ "rect",
2047
+ "text"
2048
+ ]);
2049
+ function clean(node, ctx) {
2050
+ if (typeof node === "string") return node;
2051
+ if (DROPPED.has(node.name)) {
2052
+ ctx.warnings.push(`<${node.name}> is not drawn and was removed`);
2053
+ return null;
2054
+ }
2055
+ const attrs = {};
2056
+ for (const [k, v] of Object.entries(node.attrs)) {
2057
+ const kept = cleanAttr(k, v, ctx);
2058
+ if (kept !== null) attrs[k] = kept;
2059
+ }
2060
+ const inner = DEFINITIONS.has(node.name) && !ctx.inDefs ? {
2061
+ ...ctx,
2062
+ inDefs: true
2063
+ } : ctx;
2064
+ const children = node.children.map((c) => node.name === "style" && typeof c === "string" ? cleanStyleSheet(c, inner) : clean(c, inner)).filter((c) => c !== null);
2065
+ if (ctx.draw !== null && !ctx.inDefs && FILLED.has(node.name)) drawProgress(node.name, attrs, ctx.draw);
2066
+ if (node.name === "a") return {
2067
+ name: "g",
2068
+ attrs: Object.fromEntries(Object.entries(attrs).filter(([k]) => k !== "href" && k !== "xlink:href" && k !== "target")),
2069
+ children
2070
+ };
2071
+ return {
2072
+ name: node.name,
2073
+ attrs,
2074
+ children
2075
+ };
2076
+ }
2077
+ /** Hides the stroke beyond `draw` of its length with a dash, and fades the fill in over the last 40%. */
2078
+ function drawProgress(name, attrs, draw) {
2079
+ const style = attrs.style ?? "";
2080
+ const stroke = attrs.stroke ?? style.match(/(?:^|;)\s*stroke\s*:\s*([^;]+)/)?.[1]?.trim();
2081
+ if (stroke && stroke !== "none") {
2082
+ const len = shapeLength(name, attrs);
2083
+ if (len !== null && len > 0) {
2084
+ attrs["stroke-dasharray"] = `${num$1(len)} ${num$1(len)}`;
2085
+ attrs["stroke-dashoffset"] = num$1(len * (1 - draw));
2086
+ }
2087
+ }
2088
+ const fade = Math.max(0, Math.min(1, (draw - .6) / .4));
2089
+ const own = parseFloat(attrs["fill-opacity"] ?? "1");
2090
+ attrs["fill-opacity"] = num$1((Number.isFinite(own) ? own : 1) * fade);
2091
+ }
2092
+ function cleanAttr(name, value, ctx) {
2093
+ if (/^on/i.test(name)) return null;
2094
+ if (name.startsWith("xmlns")) return null;
2095
+ if (name === "href" || name === "xlink:href") {
2096
+ const v = value.trim();
2097
+ if (v.startsWith("#")) return `#${ctx.prefix}${v.slice(1)}`;
2098
+ if (/^data:image\/(png|jpe?g|gif|webp);/i.test(v)) return v;
2099
+ ctx.warnings.push(`a link to "${v.slice(0, 40)}" was removed: only #ids and embedded raster images are drawn`);
2100
+ return null;
2101
+ }
2102
+ if (name === "id") return `${ctx.prefix}${value}`;
2103
+ let v = withTokens(value, ctx);
2104
+ if (name === "style") v = cleanStyleSheet(v, ctx);
2105
+ else v = prefixUrls(v, ctx.prefix);
2106
+ return v;
2107
+ }
2108
+ /** Drops imports and outside URLs from CSS, prefixes `url(#id)` and fills in tokens. */
2109
+ function cleanStyleSheet(css, ctx) {
2110
+ let out = withoutCssAnimation(withTokens(css, ctx).replace(/@import[^;]*;?/gi, ""), ctx);
2111
+ out = out.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (_m, _q, target) => {
2112
+ const t = target.trim();
2113
+ if (t.startsWith("#")) return `url(#${ctx.prefix}${t.slice(1)})`;
2114
+ if (/^data:image\/(png|jpe?g|gif|webp);/i.test(t)) return `url(${t})`;
2115
+ return "none";
2116
+ });
2117
+ return ctx.prefix ? out.replace(/#([A-Za-z_][\w-]*)(?=[^{}]*\{)/g, `#${ctx.prefix}$1`) : out;
2118
+ }
2119
+ /**
2120
+ * CSS animations are removed: the browser would play them on its own clock, not the video's, so the element is drawn
2121
+ * as it is styled without them.
2122
+ */
2123
+ function withoutCssAnimation(css, ctx) {
2124
+ if (!/@keyframes|animation/i.test(css)) return css;
2125
+ let out = "", i = 0;
2126
+ const keyframes = /@(-webkit-)?keyframes\b/gi;
2127
+ for (let m = keyframes.exec(css); m; m = keyframes.exec(css)) {
2128
+ out += css.slice(i, m.index);
2129
+ const open = css.indexOf("{", m.index);
2130
+ if (open < 0) {
2131
+ i = css.length;
2132
+ break;
2133
+ }
2134
+ let depth = 0, j = open;
2135
+ for (; j < css.length; j++) if (css[j] === "{") depth++;
2136
+ else if (css[j] === "}" && --depth === 0) break;
2137
+ i = j + 1;
2138
+ keyframes.lastIndex = i;
2139
+ }
2140
+ out += css.slice(i);
2141
+ const stripped = out.replace(/(^|[;{\s])animation(-[a-z-]+)?\s*:[^;}]*;?/gi, "$1");
2142
+ if (stripped !== css && !ctx.warnings.includes(CSS_ANIMATION_WARNING)) ctx.warnings.push(CSS_ANIMATION_WARNING);
2143
+ return stripped;
2144
+ }
2145
+ const CSS_ANIMATION_WARNING = "CSS animations are not played; the SVG is drawn without them (use SMIL <animate>, which is)";
2146
+ function prefixUrls(value, prefix) {
2147
+ if (!prefix || !value.includes("url(")) return value;
2148
+ return value.replace(/url\(\s*(['"]?)#([^'")]+)\1\s*\)/g, (_m, _q, id) => `url(#${prefix}${id})`);
2149
+ }
2150
+ function withTokens(value, ctx) {
2151
+ if (!value.includes("$") && !value.includes("--cc-")) return value;
2152
+ const look = (name, whole) => {
2153
+ const v = ctx.tokens[name];
2154
+ if (v !== void 0) return v;
2155
+ if (!ctx.unknownTokens.has(name)) {
2156
+ ctx.unknownTokens.add(name);
2157
+ ctx.warnings.push(`unknown token "${name}" is left as written`);
2158
+ }
2159
+ return whole;
2160
+ };
2161
+ return value.replace(/var\(\s*--cc-([A-Za-z][\w-]*)\s*(?:,\s*([^)]*))?\)/g, (whole, name, fallback) => ctx.tokens[name] ?? (fallback?.trim() || look(name, whole))).replace(/\$([A-Za-z][\w-]*)/g, (whole, name) => look(name, whole));
2162
+ }
2163
+ /** `replace` paints every drawn pixel in the colour; `tint` keeps the shading and colours it. `opacity` is how much. */
2164
+ function colorFilter(color, id, region) {
2165
+ if (!color || color.mode === "none" || !color.tint) return null;
2166
+ const amount = Math.max(0, Math.min(1, color.opacity ?? 1));
2167
+ const steps = (color.mode === "replace" ? {
2168
+ name: "g",
2169
+ attrs: {},
2170
+ children: [{
2171
+ name: "feFlood",
2172
+ attrs: {
2173
+ "flood-color": color.tint,
2174
+ result: "flood"
2175
+ },
2176
+ children: []
2177
+ }, {
2178
+ name: "feComposite",
2179
+ attrs: {
2180
+ in: "flood",
2181
+ in2: "SourceAlpha",
2182
+ operator: "in",
2183
+ result: "coloured"
2184
+ },
2185
+ children: []
2186
+ }]
2187
+ } : (() => {
2188
+ const [r, g, b] = rgb(color.tint);
2189
+ const row = (k) => `${num$1(.2126 * k)} ${num$1(.7152 * k)} ${num$1(.0722 * k)} 0 0`;
2190
+ return {
2191
+ name: "g",
2192
+ attrs: {},
2193
+ children: [{
2194
+ name: "feColorMatrix",
2195
+ attrs: {
2196
+ in: "SourceGraphic",
2197
+ type: "matrix",
2198
+ values: `${row(r)} ${row(g)} ${row(b)} 0 0 0 1 0`,
2199
+ result: "coloured"
2200
+ },
2201
+ children: []
2202
+ }]
2203
+ };
2204
+ })()).children.slice();
2205
+ if (amount < 1) steps.push({
2206
+ name: "feComposite",
2207
+ attrs: {
2208
+ in: "coloured",
2209
+ in2: "SourceGraphic",
2210
+ operator: "arithmetic",
2211
+ k1: "0",
2212
+ k2: num$1(amount),
2213
+ k3: num$1(1 - amount),
2214
+ k4: "0"
2215
+ },
2216
+ children: []
2217
+ });
2218
+ return {
2219
+ name: "filter",
2220
+ attrs: {
2221
+ id,
2222
+ filterUnits: "userSpaceOnUse",
2223
+ x: num$1(region.minX),
2224
+ y: num$1(region.minY),
2225
+ width: num$1(region.width),
2226
+ height: num$1(region.height),
2227
+ "color-interpolation-filters": "sRGB"
2228
+ },
2229
+ children: steps
2230
+ };
2231
+ }
2232
+ /** A CSS colour as 0..1 channels; named colours other than black and white count as white. */
2233
+ function rgb(color) {
2234
+ const c = color.trim().toLowerCase();
2235
+ const hex = c.match(/^#([0-9a-f]{3,8})$/);
2236
+ if (hex) {
2237
+ const h = hex[1];
2238
+ const full = h.length <= 4 ? h.split("").map((x) => x + x).join("") : h;
2239
+ return [
2240
+ parseInt(full.slice(0, 2), 16) / 255,
2241
+ parseInt(full.slice(2, 4), 16) / 255,
2242
+ parseInt(full.slice(4, 6), 16) / 255
2243
+ ];
2244
+ }
2245
+ const fn = c.match(/^rgba?\(\s*([\d.]+)%?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/);
2246
+ if (fn) return [
2247
+ Number(fn[1]) / 255,
2248
+ Number(fn[2]) / 255,
2249
+ Number(fn[3]) / 255
2250
+ ];
2251
+ return c === "black" ? [
2252
+ 0,
2253
+ 0,
2254
+ 0
2255
+ ] : [
2256
+ 1,
2257
+ 1,
2258
+ 1
2259
+ ];
2260
+ }
2261
+ function viewBoxOf(node) {
2262
+ const raw = node.attrs.viewBox;
2263
+ if (!raw) return null;
2264
+ const n = raw.trim().split(/[\s,]+/).map(Number);
2265
+ if (n.length !== 4 || n.some((x) => !Number.isFinite(x)) || n[2] <= 0 || n[3] <= 0) return null;
2266
+ return {
2267
+ minX: n[0],
2268
+ minY: n[1],
2269
+ width: n[2],
2270
+ height: n[3]
2271
+ };
2272
+ }
2273
+ /** A length attribute in user units; percentages and font-relative units have no size of their own. */
2274
+ function length(v) {
2275
+ if (!v) return null;
2276
+ const m = v.trim().match(/^([\d.]+)(px)?$/);
2277
+ return m ? Number(m[1]) : null;
2278
+ }
2279
+ function parse(markup) {
2280
+ const warnings = [];
2281
+ const parser = new SaxesParser({ xmlns: false });
2282
+ const stack = [];
2283
+ let root = null;
2284
+ parser.on("opentag", (tag) => {
2285
+ const node = {
2286
+ name: tag.name,
2287
+ attrs: { ...tag.attributes },
2288
+ children: []
2289
+ };
2290
+ const parent = stack[stack.length - 1];
2291
+ if (parent) parent.children.push(node);
2292
+ else if (!root) root = node;
2293
+ stack.push(node);
2294
+ });
2295
+ parser.on("closetag", () => {
2296
+ stack.pop();
2297
+ });
2298
+ const text = (t) => {
2299
+ const parent = stack[stack.length - 1];
2300
+ if (parent && t.length > 0) parent.children.push(t);
2301
+ };
2302
+ parser.on("text", text);
2303
+ parser.on("cdata", text);
2304
+ parser.on("error", (e) => {
2305
+ throw e;
2306
+ });
2307
+ try {
2308
+ parser.write(markup).close();
2309
+ } catch (error) {
2310
+ warnings.push(`the SVG could not be read: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
2311
+ return {
2312
+ root: null,
2313
+ warnings
2314
+ };
2315
+ }
2316
+ const found = root;
2317
+ if (found?.name !== "svg") {
2318
+ warnings.push("the markup is not an <svg> element");
2319
+ return {
2320
+ root: null,
2321
+ warnings
2322
+ };
2323
+ }
2324
+ return {
2325
+ root: found,
2326
+ warnings
2327
+ };
2328
+ }
2329
+ function serialize(node) {
2330
+ if (typeof node === "string") return escapeText(node);
2331
+ const attrs = Object.entries(node.attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
2332
+ if (node.children.length === 0) return `<${node.name}${attrs}/>`;
2333
+ return `<${node.name}${attrs}>${node.children.map(serialize).join("")}</${node.name}>`;
2334
+ }
2335
+ function escapeText(t) {
2336
+ return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2337
+ }
2338
+ function escapeAttr(t) {
2339
+ return escapeText(t).replace(/"/g, "&quot;");
2340
+ }
2341
+ function num$1(v) {
2342
+ return String(Math.round(v * 1e3) / 1e3);
2343
+ }
2344
+ // src/overlays/graphic.tsx
2345
+ /**
2346
+ * How a `<Graphic>` draws at the element's time: the kind's art through the
2347
+ * same SVG preparation as `<Svg>` (so `draw` and the theme apply), and its
2348
+ * labels, which both surfaces lay out as text the way they lay out titles.
2349
+ */
2350
+ function graphicDrawing(p) {
2351
+ const props = p.placement.props;
2352
+ return drawingOf(p, props, graphicSize(props, p.state.composition));
2353
+ }
2354
+ const iconShape = z.strictObject(GRAPHIC_KINDS.icon.shape);
2355
+ /** A sticker whose `src` is `icon:name`, drawn as the icon graphic in the sticker's square. Null for other stickers. */
2356
+ function iconStickerDrawing(p, side) {
2357
+ const src = String(p.placement.props.src ?? "");
2358
+ if (!src.startsWith("icon:") || !isIcon(src.slice(5))) return null;
2359
+ return drawingOf(p, {
2360
+ kind: "icon",
2361
+ ...iconShape.parse({ name: src.slice(5) })
2362
+ }, {
2363
+ width: side,
2364
+ height: side
2365
+ });
2366
+ }
2367
+ function drawingOf(p, props, size) {
2368
+ const layout = overlayLayout(p, size, "none", true);
2369
+ const box = {
2370
+ width: layout.rect.w,
2371
+ height: layout.rect.h
2372
+ };
2373
+ const drawn = drawGraphic(props, {
2374
+ ...box,
2375
+ t: p.time - p.placement.start,
2376
+ duration: p.placement.duration,
2377
+ id: p.placement.id,
2378
+ tokens: p.state.theme?.tokens ?? {}
2379
+ });
2380
+ return {
2381
+ layout,
2382
+ prepared: prepareSvg(drawn.svg, {
2383
+ box,
2384
+ idPrefix: p.placement.id,
2385
+ draw: layout.draw
2386
+ }),
2387
+ labels: drawn.labels
2388
+ };
2389
+ }
2390
+ /** A graphic's labels as positioned text, laid out by the DOM. */
2391
+ function graphicLabels(labels) {
2392
+ return labels.map((l, i) => /* @__PURE__ */ jsx("div", {
2393
+ style: {
2394
+ position: "absolute",
2395
+ left: l.x,
2396
+ top: l.y,
2397
+ width: l.w,
2398
+ height: l.h,
2399
+ display: "flex",
2400
+ alignItems: l.valign === "top" ? "flex-start" : l.valign === "bottom" ? "flex-end" : "center",
2401
+ justifyContent: l.align === "left" ? "flex-start" : l.align === "right" ? "flex-end" : "center",
2402
+ textAlign: l.align,
2403
+ fontFamily: l.font,
2404
+ fontSize: l.size,
2405
+ fontWeight: l.weight,
2406
+ fontStyle: l.italic ? "italic" : "normal",
2407
+ color: l.color,
2408
+ lineHeight: l.lineHeight ?? 1.2,
2409
+ letterSpacing: l.letterSpacing ?? 0,
2410
+ whiteSpace: "pre-wrap"
2411
+ },
2412
+ children: l.text
2413
+ }, i));
2414
+ }
2415
+ // src/react/elements/Graphic.tsx
2416
+ /** A built-in graphic: the kind's art from the same markup as the export, its labels as text above it. */
2417
+ function Graphic(p) {
2418
+ const d = graphicDrawing(p);
2419
+ for (const w of d.prepared.warnings) devWarn(`graphic:${p.placement.id}:${w}`, `graphic "${p.placement.id}": ${w}`);
2420
+ return /* @__PURE__ */ jsxs("div", {
2421
+ ...dataAttrs(p.placement),
2422
+ style: layoutStyle(d.layout),
2423
+ children: [/* @__PURE__ */ jsx("div", {
2424
+ style: {
2425
+ position: "absolute",
2426
+ left: 0,
2427
+ top: 0,
2428
+ lineHeight: 0
2429
+ },
2430
+ dangerouslySetInnerHTML: { __html: d.prepared.markup }
2431
+ }), graphicLabels(d.labels)]
2432
+ });
2433
+ }
2434
+ // src/react/hold-animations.ts
2435
+ /**
2436
+ * Holds the CSS animations under `root` at `seconds` past the layer's start: paused, and seeked there. They then
2437
+ * follow the video's clock (play, pause, scrub both ways) and an export captures the same frame twice.
2438
+ *
2439
+ * Only CSS animations: their zero is the moment their element got the style, which inside a Layer is the layer's
2440
+ * start. A Web Animation made with `element.animate()` or by a library keeps its own start and its owner's clock.
2441
+ */
2442
+ function holdCssAnimations(root, seconds) {
2443
+ if (!root || typeof root.getAnimations !== "function" || typeof CSSAnimation === "undefined") return;
2444
+ const ms = seconds * 1e3;
2445
+ for (const animation of root.getAnimations({ subtree: true })) {
2446
+ if (!(animation instanceof CSSAnimation)) continue;
2447
+ if (animation.playState !== "paused") animation.pause();
2448
+ if (animation.currentTime !== ms) animation.currentTime = ms;
2449
+ }
2450
+ }
2451
+ // src/react/elements/Layer.tsx
2452
+ /** Arbitrary React inside the composition, driven by `useLayer()` and `useT()` through LayerContext. */
2453
+ function Layer(p) {
2454
+ const comp = p.state.composition;
2455
+ const style = overlayStyle(p, {
2456
+ width: comp.width,
2457
+ height: comp.height
2458
+ });
2459
+ const clock = layerClock(p.placement, p.time, comp.fps);
2460
+ const ref = useRef(null);
2461
+ useLayoutEffect(() => {
2462
+ holdCssAnimations(ref.current, clock.elapsed);
2463
+ });
2464
+ return /* @__PURE__ */ jsx("div", {
2465
+ ref,
2466
+ ...dataAttrs(p.placement),
2467
+ style,
2468
+ children: /* @__PURE__ */ jsx(LayerContext.Provider, {
2469
+ value: clock,
2470
+ children: typeof p.placement.props.engine === "string" ? /* @__PURE__ */ jsx(Engine, {
2471
+ name: p.placement.props.engine,
2472
+ props: p.placement.props,
2473
+ children: p.interior
2474
+ }) : layerContent(p.interior, p.placement.interior?.props)
2475
+ })
2476
+ });
2477
+ }
2478
+ /** An engine Layer (`<Three>`, `<Motion>`): drawn by the engine the host installed, with the Layer's resolved props. */
2479
+ function Engine({ name, props, children }) {
2480
+ const Draw = layerEngine(name);
2481
+ if (!Draw) {
2482
+ devWarn(`engine:${name}`, `no layer engine "${name}" is installed, so <${name === "three" ? "Three" : name === "motion" ? "Motion" : name}> draws nothing; the host installs it (installFreeCodeLibraries from @cutcraft/graphics/libraries)`);
2483
+ return null;
2484
+ }
2485
+ return /* @__PURE__ */ jsx(Draw, {
2486
+ ...props,
2487
+ children
2488
+ });
2489
+ }
2490
+ // src/overlays/shape.ts
2491
+ /** The size a shape draws at, before layout. */
2492
+ function shapeSize(p) {
2493
+ return {
2494
+ width: Number(p.placement.props.width ?? 200),
2495
+ height: Number(p.placement.props.height ?? 200)
2496
+ };
2497
+ }
2498
+ function shapeMarkup(props, size) {
2499
+ const { width: w, height: h } = size;
2500
+ const warnings = [];
2501
+ const defs = [];
2502
+ const attrs = {};
2503
+ const fill = props.fill;
2504
+ if (typeof fill === "string") attrs.fill = fill;
2505
+ else if (!fill) attrs.fill = "#ffffff";
2506
+ else if (fill.type === "none") attrs.fill = "none";
2507
+ else if (fill.type === "gradient" && fill.gradient) {
2508
+ defs.push(gradient("fill", fill.gradient, warnings));
2509
+ attrs.fill = "url(#fill)";
2510
+ } else attrs.fill = fill.color ?? "#ffffff";
2511
+ if (typeof fill === "object" && fill?.opacity !== void 0) attrs["fill-opacity"] = num(fill.opacity);
2512
+ const stroke = props.stroke;
2513
+ if (stroke) {
2514
+ if (stroke.gradient) {
2515
+ defs.push(gradient("stroke", stroke.gradient, warnings));
2516
+ attrs.stroke = "url(#stroke)";
2517
+ } else attrs.stroke = stroke.color;
2518
+ attrs["stroke-width"] = num(stroke.width);
2519
+ if (stroke.opacity !== void 0) attrs["stroke-opacity"] = num(stroke.opacity);
2520
+ if (stroke.dashArray?.length) attrs["stroke-dasharray"] = stroke.dashArray.map(num).join(" ");
2521
+ if (stroke.dashOffset !== void 0) attrs["stroke-dashoffset"] = num(stroke.dashOffset);
2522
+ if (stroke.lineCap) attrs["stroke-linecap"] = stroke.lineCap;
2523
+ if (stroke.lineJoin) attrs["stroke-linejoin"] = stroke.lineJoin;
2524
+ }
2525
+ const shadows = props.shadows ?? (props.shadow ? [props.shadow] : []);
2526
+ const outer = shadows.filter((s) => !s.inset);
2527
+ if (outer.length < shadows.length) warnings.push("inset shadows are not drawn");
2528
+ const strokeReach = stroke ? stroke.width / 2 + 1 : 0;
2529
+ const shadowReach = outer.reduce((m, s) => Math.max(m, Math.max(Math.abs(s.x ?? 0), Math.abs(s.y ?? 0)) + (s.blur ?? 0) * 1.5 + Math.max(0, s.spread ?? 0)), 0);
2530
+ const bleed = Math.ceil(Math.max(strokeReach, shadowReach + strokeReach));
2531
+ if (outer.length > 0) {
2532
+ defs.push(shadowFilter(outer, {
2533
+ x: -bleed,
2534
+ y: -bleed,
2535
+ w: w + 2 * bleed,
2536
+ h: h + 2 * bleed
2537
+ }));
2538
+ attrs.filter = "url(#shadow)";
2539
+ }
2540
+ const body = geometry(String(props.shape ?? "rectangle"), w, h, props, attrs);
2541
+ return {
2542
+ markup: `<svg xmlns="http://www.w3.org/2000/svg" width="${num(w)}" height="${num(h)}" viewBox="0 0 ${num(w)} ${num(h)}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}${body}</svg>`,
2543
+ size,
2544
+ bleed,
2545
+ warnings
2546
+ };
2547
+ }
2548
+ /** How a `<Shape>` element draws at its time: its layout, and the markup with entrance drawing applied. */
2549
+ function shapeDrawing(p, tokens) {
2550
+ const size = shapeSize(p);
2551
+ const layout = overlayLayout(p, size, "none", true);
2552
+ const shape = shapeMarkup(p.placement.props, size);
2553
+ const prepared = prepareSvg(shape.markup, {
2554
+ box: {
2555
+ width: layout.rect.w,
2556
+ height: layout.rect.h
2557
+ },
2558
+ idPrefix: p.placement.id,
2559
+ draw: layout.draw,
2560
+ bleed: shape.bleed,
2561
+ tokens
2562
+ });
2563
+ return {
2564
+ layout,
2565
+ prepared: shape.warnings.length ? {
2566
+ ...prepared,
2567
+ warnings: [...shape.warnings, ...prepared.warnings]
2568
+ } : prepared,
2569
+ bleed: shape.bleed
2570
+ };
2571
+ }
2572
+ function geometry(shape, w, h, props, a) {
2573
+ const el = (name, own) => {
2574
+ const all = {
2575
+ ...Object.fromEntries(Object.entries(own).map(([k, v]) => [k, typeof v === "number" ? num(v) : v])),
2576
+ ...a
2577
+ };
2578
+ return `<${name}${Object.entries(all).map(([k, v]) => ` ${k}="${escapeXml(v)}"`).join("")}/>`;
2579
+ };
2580
+ const pts = (list) => list.map(([x, y]) => `${num(x)},${num(y)}`).join(" ");
2581
+ switch (shape) {
2582
+ case "circle": return el("circle", {
2583
+ cx: w / 2,
2584
+ cy: h / 2,
2585
+ r: Math.min(w, h) / 2
2586
+ });
2587
+ case "ellipse": return el("ellipse", {
2588
+ cx: w / 2,
2589
+ cy: h / 2,
2590
+ rx: w / 2,
2591
+ ry: h / 2
2592
+ });
2593
+ case "triangle": return el("polygon", { points: pts([
2594
+ [w / 2, 0],
2595
+ [w, h],
2596
+ [0, h]
2597
+ ]) });
2598
+ case "line":
2599
+ if (!a.stroke || a.stroke === "none") {
2600
+ a.stroke = a.fill ?? "#ffffff";
2601
+ a["stroke-width"] ??= "4";
2602
+ }
2603
+ return el("line", {
2604
+ x1: 0,
2605
+ y1: h / 2,
2606
+ x2: w,
2607
+ y2: h / 2
2608
+ });
2609
+ case "arrow": {
2610
+ const head = Math.min(h, w * .3);
2611
+ return el("polygon", { points: pts([
2612
+ [0, h * .35],
2613
+ [w - head, h * .35],
2614
+ [w - head, 0],
2615
+ [w, h / 2],
2616
+ [w - head, h],
2617
+ [w - head, h * .65],
2618
+ [0, h * .65]
2619
+ ]) });
2620
+ }
2621
+ case "polygon": return el("polygon", { points: pts((props.points ?? []).map((q) => [q.x * w, q.y * h])) });
2622
+ case "star": {
2623
+ const n = Number(props.starPoints ?? 5), inner = Number(props.innerRadius ?? .5);
2624
+ const R = Math.min(w, h) / 2;
2625
+ const list = [];
2626
+ for (let i = 0; i < n * 2; i++) {
2627
+ const rad = i % 2 === 0 ? R : R * inner;
2628
+ const angle = -Math.PI / 2 + i * Math.PI / n;
2629
+ list.push([w / 2 + rad * Math.cos(angle), h / 2 + rad * Math.sin(angle)]);
2630
+ }
2631
+ return el("polygon", { points: pts(list) });
2632
+ }
2633
+ case "path": return el("path", { d: (props.points ?? []).map((q, i) => `${i === 0 ? "M" : "L"}${num(q.x * w)} ${num(q.y * h)}`).join(" ") });
2634
+ default: {
2635
+ const radii = props.cornerRadii;
2636
+ if (radii) return el("path", { d: roundedRect$1(w, h, radii) });
2637
+ const r = Number(props.cornerRadius ?? 0);
2638
+ return el("rect", {
2639
+ x: 0,
2640
+ y: 0,
2641
+ width: w,
2642
+ height: h,
2643
+ ...r > 0 ? {
2644
+ rx: r,
2645
+ ry: r
2646
+ } : {}
2647
+ });
2648
+ }
2649
+ }
2650
+ }
2651
+ /** A rectangle with its own radius at each corner, each clamped to half the shorter side. */
2652
+ function roundedRect$1(w, h, r) {
2653
+ const c = (v) => Math.max(0, Math.min(v, w / 2, h / 2));
2654
+ const tl = c(r.topLeft), tr = c(r.topRight), br = c(r.bottomRight), bl = c(r.bottomLeft);
2655
+ return [
2656
+ `M${num(tl)} 0`,
2657
+ `H${num(w - tr)}`,
2658
+ tr ? `A${num(tr)} ${num(tr)} 0 0 1 ${num(w)} ${num(tr)}` : "",
2659
+ `V${num(h - br)}`,
2660
+ br ? `A${num(br)} ${num(br)} 0 0 1 ${num(w - br)} ${num(h)}` : "",
2661
+ `H${num(bl)}`,
2662
+ bl ? `A${num(bl)} ${num(bl)} 0 0 1 0 ${num(h - bl)}` : "",
2663
+ `V${num(tl)}`,
2664
+ tl ? `A${num(tl)} ${num(tl)} 0 0 1 ${num(tl)} 0` : "",
2665
+ "Z"
2666
+ ].filter(Boolean).join(" ");
2667
+ }
2668
+ /** CSS angles: 0 points up, 90 right; the default, 180, runs top to bottom. */
2669
+ function gradient(id, g, warnings) {
2670
+ const stops = [...g.stops].sort((a, b) => a.offset - b.offset).map((s) => `<stop offset="${num(s.offset)}" stop-color="${escapeXml(s.color)}"${s.opacity !== void 0 ? ` stop-opacity="${num(s.opacity)}"` : ""}/>`).join("");
2671
+ if (g.type === "radial") {
2672
+ const c = g.center ?? {
2673
+ x: .5,
2674
+ y: .5
2675
+ };
2676
+ return `<radialGradient id="${id}" cx="${num(c.x)}" cy="${num(c.y)}" r="0.5">${stops}</radialGradient>`;
2677
+ }
2678
+ if (g.type === "conic") warnings.push("conic gradients are drawn as linear ones on shapes");
2679
+ const rad = (g.angle ?? 180) % 360 * Math.PI / 180;
2680
+ const dx = Math.sin(rad), dy = -Math.cos(rad);
2681
+ return `<linearGradient id="${id}" x1="${num(.5 - dx / 2)}" y1="${num(.5 - dy / 2)}" x2="${num(.5 + dx / 2)}" y2="${num(.5 + dy / 2)}">${stops}</linearGradient>`;
2682
+ }
2683
+ /** Drop shadows under the shape, as SVG filter primitives. */
2684
+ function shadowFilter(shadows, region) {
2685
+ const parts = [];
2686
+ const merged = [];
2687
+ shadows.forEach((s, i) => {
2688
+ const src = (s.spread ?? 0) > 0 ? `spread${i}` : "SourceAlpha";
2689
+ if ((s.spread ?? 0) > 0) parts.push(`<feMorphology in="SourceAlpha" operator="dilate" radius="${num(s.spread ?? 0)}" result="spread${i}"/>`);
2690
+ parts.push(`<feGaussianBlur in="${src}" stdDeviation="${num((s.blur ?? 0) / 2)}" result="blur${i}"/>`, `<feOffset in="blur${i}" dx="${num(s.x ?? 0)}" dy="${num(s.y ?? 0)}" result="offset${i}"/>`, `<feFlood flood-color="${escapeXml(s.color)}" result="colour${i}"/>`, `<feComposite in="colour${i}" in2="offset${i}" operator="in" result="shadow${i}"/>`);
2691
+ merged.push(`<feMergeNode in="shadow${i}"/>`);
2692
+ });
2693
+ return `<filter id="shadow" filterUnits="userSpaceOnUse" x="${num(region.x)}" y="${num(region.y)}" width="${num(region.w)}" height="${num(region.h)}" color-interpolation-filters="sRGB">${parts.join("")}<feMerge>${merged.join("")}<feMergeNode in="SourceGraphic"/></feMerge></filter>`;
2694
+ }
2695
+ function escapeXml(v) {
2696
+ return v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
2697
+ }
2698
+ function num(v) {
2699
+ return String(Math.round(v * 1e3) / 1e3);
2700
+ }
2701
+ // src/react/elements/Shape.tsx
2702
+ /** A vector shape, drawn from the same markup as the export, with room around it for its stroke and shadows. */
2703
+ function Shape(p) {
2704
+ const d = shapeDrawing(p);
2705
+ for (const w of d.prepared.warnings) devWarn(`shape:${p.placement.id}:${w}`, `shape "${p.placement.id}": ${w}`);
2706
+ return /* @__PURE__ */ jsx("div", {
2707
+ ...dataAttrs(p.placement),
2708
+ style: layoutStyle(d.layout),
2709
+ children: /* @__PURE__ */ jsx("div", {
2710
+ style: {
2711
+ position: "absolute",
2712
+ left: -d.bleed,
2713
+ top: -d.bleed,
2714
+ lineHeight: 0
2715
+ },
2716
+ dangerouslySetInnerHTML: { __html: d.prepared.markup }
2717
+ })
2718
+ });
2719
+ }
2720
+ // src/overlays/svg-element.ts
2721
+ /**
2722
+ * How an `<Svg>` element draws its markup: at the size the SVG asks for (or fitted to the
2723
+ * composition with `fit`), with its `viewBox`, `preserveAspectRatio` and `color` applied, and
2724
+ * `$name` / `var(--cc-name)` filled from `tokens` (by default the composition's theme).
2725
+ */
2726
+ function svgDrawing(p, markup, tokens = themeTokens(p.state.theme)) {
2727
+ const props = p.placement.props;
2728
+ const viewBox = props.viewBox;
2729
+ const size = svgIntrinsicSize(markup, viewBox);
2730
+ const fit = props.fit ?? "none";
2731
+ const layout = overlayLayout(p, size, fit, true);
2732
+ return {
2733
+ size,
2734
+ fit,
2735
+ layout,
2736
+ prepared: prepareSvg(markup, {
2737
+ box: {
2738
+ width: layout.rect.w,
2739
+ height: layout.rect.h
2740
+ },
2741
+ viewBox,
2742
+ preserveAspectRatio: props.preserveAspectRatio,
2743
+ color: props.color,
2744
+ tokens,
2745
+ idPrefix: p.placement.id,
2746
+ draw: layout.draw,
2747
+ ...svgAnimated(markup) ? { time: p.time - p.placement.start } : {}
2748
+ })
2749
+ };
2750
+ }
2751
+ const tokensByTheme = /* @__PURE__ */ new WeakMap();
2752
+ /** A theme's tokens as SVG attribute values. */
2753
+ function themeTokens(theme) {
2754
+ if (!theme) return {};
2755
+ let tokens = tokensByTheme.get(theme);
2756
+ if (!tokens) {
2757
+ tokens = Object.fromEntries(Object.entries(theme.tokens).map(([k, v]) => [k, String(v)]));
2758
+ tokensByTheme.set(theme, tokens);
2759
+ }
2760
+ return tokens;
2761
+ }
2762
+ // src/react/elements/Svg.tsx
2763
+ /** Inline SVG markup, or an SVG asset read as text, prepared the way the export prepares it. */
2764
+ function Svg(p) {
2765
+ const props = p.placement.props;
2766
+ const content = typeof props.content === "string" ? props.content : null;
2767
+ const text = useMediaText(content === null && typeof props.src === "string" ? props.src : null);
2768
+ const markup = content ?? text;
2769
+ if (markup === null) return /* @__PURE__ */ jsx("div", {
2770
+ ...dataAttrs(p.placement),
2771
+ style: overlayStyle(p, {
2772
+ width: 0,
2773
+ height: 0
2774
+ })
2775
+ });
2776
+ const drawing = svgDrawing(p, markup);
2777
+ for (const w of drawing.prepared.warnings) devWarn(`svg:${p.placement.id}:${w}`, `svg "${p.placement.id}": ${w}`);
2778
+ return /* @__PURE__ */ jsx("div", {
2779
+ ...dataAttrs(p.placement),
2780
+ style: {
2781
+ ...layoutStyle(drawing.layout),
2782
+ lineHeight: 0
2783
+ },
2784
+ dangerouslySetInnerHTML: { __html: drawing.prepared.markup }
2785
+ });
2786
+ }
2787
+ /** Resolves an `asset:` reference to an object URL or a plain URL. */
2788
+ function useMediaUrl(ref) {
2789
+ const core = usePlayer();
2790
+ const [url, setUrl] = useState(null);
2791
+ useEffect(() => {
2792
+ if (!ref) {
2793
+ setUrl(null);
2794
+ return;
2795
+ }
2796
+ const id = ref.startsWith("asset:") ? ref.slice(6) : ref;
2797
+ let revoke = null;
2798
+ let cancelled = false;
2799
+ core.resolveMedia(id).then((handle) => {
2800
+ if (cancelled) return;
2801
+ if (handle.kind === "url") setUrl(handle.url);
2802
+ else if (handle.kind === "blob") {
2803
+ revoke = URL.createObjectURL(handle.blob);
2804
+ setUrl(revoke);
2805
+ } else setUrl(null);
2806
+ }).catch(() => {
2807
+ if (!cancelled) setUrl(null);
2808
+ });
2809
+ return () => {
2810
+ cancelled = true;
2811
+ if (revoke) URL.revokeObjectURL(revoke);
2812
+ };
2813
+ }, [core, ref]);
2814
+ return url;
2815
+ }
2816
+ /** Reads an `asset:` reference as text, for markup that is inlined rather than loaded as an image. */
2817
+ function useMediaText(ref) {
2818
+ const core = usePlayer();
2819
+ const [text, setText] = useState(null);
2820
+ useEffect(() => {
2821
+ if (!ref) {
2822
+ setText(null);
2823
+ return;
2824
+ }
2825
+ const id = ref.startsWith("asset:") ? ref.slice(6) : ref;
2826
+ let cancelled = false;
2827
+ core.resolveMedia(id).then((handle) => handle.kind === "url" ? fetch(handle.url).then((r) => r.text()) : handle.kind === "blob" ? handle.blob.text() : null).then((t) => {
2828
+ if (!cancelled) setText(t);
2829
+ }).catch(() => {
2830
+ if (!cancelled) setText(null);
2831
+ });
2832
+ return () => {
2833
+ cancelled = true;
2834
+ };
2835
+ }, [core, ref]);
2836
+ return text;
2837
+ }
2838
+ // src/react/elements/Sticker.tsx
2839
+ /** An image asset, or a Lucide icon for `icon:name`; other `src` schemes (sticker packs) show a labelled placeholder. */
2840
+ function Sticker(p) {
2841
+ const props = p.placement.props;
2842
+ const src = String(props.src ?? "");
2843
+ const isAsset = src.startsWith("asset:");
2844
+ const url = useMediaUrl(isAsset ? src : null);
2845
+ const icon = iconStickerDrawing(p, stickerSize(p));
2846
+ if (icon) return /* @__PURE__ */ jsx("div", {
2847
+ ...dataAttrs(p.placement),
2848
+ style: {
2849
+ ...layoutStyle(icon.layout),
2850
+ lineHeight: 0
2851
+ },
2852
+ dangerouslySetInnerHTML: { __html: icon.prepared.markup }
2853
+ });
2854
+ if (!isAsset) devWarn(`sticker:${p.placement.id}`, `sticker "${src}" on "${p.placement.id}" has no media; showing a placeholder`);
2855
+ const size = stickerSize(p);
2856
+ const style = overlayStyle(p, {
2857
+ width: size,
2858
+ height: size
2859
+ });
2860
+ return /* @__PURE__ */ jsx("div", {
2861
+ ...dataAttrs(p.placement),
2862
+ style: {
2863
+ ...style,
2864
+ display: "flex",
2865
+ alignItems: "center",
2866
+ justifyContent: "center"
2867
+ },
2868
+ children: isAsset && url ? /* @__PURE__ */ jsx("img", {
2869
+ src: url,
2870
+ alt: "",
2871
+ style: {
2872
+ width: "100%",
2873
+ height: "100%",
2874
+ objectFit: "contain"
2875
+ }
2876
+ }) : stickerPlaceholder(String(props.name ?? src))
2877
+ });
2878
+ }
2879
+ // src/react/elements/Title.tsx
2880
+ /** Text title: the file's text content with font props; entrance/exit presets as CSS transforms. */
2881
+ function Title(p) {
2882
+ const comp = p.state.composition;
2883
+ return /* @__PURE__ */ jsx("div", {
2884
+ ...dataAttrs(p.placement),
2885
+ style: overlayStyle(p, titleBox(p.placement.props, comp)),
2886
+ children: titleContent(p)
2887
+ });
2888
+ }
2889
+ // src/react/ShaderBox.tsx
2890
+ /** Whether this browser draws live DOM into a canvas (HTML-in-Canvas: Chrome with the CanvasDrawElement feature). */
2891
+ function htmlInCanvas() {
2892
+ return typeof CanvasRenderingContext2D !== "undefined" && typeof CanvasRenderingContext2D.prototype.drawElementImage === "function";
2893
+ }
2894
+ /** One WebGL runner per frame size for the whole page: a browser allows only a few WebGL contexts at once. */
2895
+ const runners = /* @__PURE__ */ new Map();
2896
+ function runnerFor(width, height) {
2897
+ const key = `${width}x${height}`;
2898
+ let runner = runners.get(key);
2899
+ if (!runner) {
2900
+ runner = new ShaderRunner(width, height, defaultCreateCanvas);
2901
+ runners.set(key, runner);
2902
+ }
2903
+ return runner;
2904
+ }
2905
+ /**
2906
+ * An overlay drawn through the effects the file declares (`defineEffect()`): its DOM lives inside a canvas
2907
+ * (HTML-in-Canvas), which draws it, runs the shaders over the picture and shows the result. The box is the frame's
2908
+ * size, so the overlay keeps its place in it. A browser without HTML-in-Canvas shows the overlay without the effects.
2909
+ */
2910
+ function ShaderBox(p) {
2911
+ const canvasRef = useRef(null);
2912
+ const innerRef = useRef(null);
2913
+ const supported = htmlInCanvas();
2914
+ useLayoutEffect(() => {
2915
+ const canvas = canvasRef.current;
2916
+ const inner = innerRef.current;
2917
+ if (!supported || !canvas || !inner) return;
2918
+ const release = holdLayerFrame();
2919
+ let done = false;
2920
+ const draw = () => {
2921
+ if (done) return;
2922
+ done = true;
2923
+ try {
2924
+ const ctx = canvas.getContext("2d");
2925
+ if (!ctx) return;
2926
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
2927
+ ctx.clearRect(0, 0, p.width, p.height);
2928
+ ctx.drawElementImage(inner, 0, 0);
2929
+ const result = runnerFor(p.width, p.height).effects(canvas, p.passes);
2930
+ if (result) {
2931
+ ctx.clearRect(0, 0, p.width, p.height);
2932
+ ctx.drawImage(result, 0, 0);
2933
+ }
2934
+ } catch (error) {
2935
+ devWarn(`shader-error:${p.id}`, `"${p.id}": ${error instanceof Error ? error.message : String(error)}`);
2936
+ } finally {
2937
+ release();
2938
+ }
2939
+ };
2940
+ const request = canvas.requestPaint;
2941
+ canvas.addEventListener("paint", draw, { once: true });
2942
+ if (typeof request === "function") request.call(canvas);
2943
+ const frame = requestAnimationFrame(() => requestAnimationFrame(draw));
2944
+ return () => {
2945
+ canvas.removeEventListener("paint", draw);
2946
+ cancelAnimationFrame(frame);
2947
+ done = true;
2948
+ release();
2949
+ };
2950
+ });
2951
+ if (!supported) {
2952
+ devWarn(`html-in-canvas:${p.id}`, `"${p.id}" has an effect the file declares; this browser has no HTML-in-Canvas (Chrome's CanvasDrawElement feature), so it shows without it (the export draws it)`);
2953
+ return /* @__PURE__ */ jsx(Fragment$1, { children: p.children });
2954
+ }
2955
+ const box = {
2956
+ position: "absolute",
2957
+ left: 0,
2958
+ top: 0,
2959
+ width: p.width,
2960
+ height: p.height
2961
+ };
2962
+ return /* @__PURE__ */ jsx("canvas", {
2963
+ ref: canvasRef,
2964
+ "data-shader-box": p.id,
2965
+ width: p.width,
2966
+ height: p.height,
2967
+ style: {
2968
+ ...box,
2969
+ pointerEvents: "none"
2970
+ },
2971
+ layoutsubtree: "",
2972
+ content: "drawable",
2973
+ children: /* @__PURE__ */ jsx("div", {
2974
+ ref: innerRef,
2975
+ style: box,
2976
+ drawable: "",
2977
+ children: p.children
2978
+ })
2979
+ });
2980
+ }
2981
+ // src/react/DomLayer.tsx
2982
+ /** Overlay elements as DOM, one absolutely positioned group per track in compositing order, above the canvas (or, for the backdrop, below it). */
2983
+ function DomLayer({ state, interiors, layer = "front" }) {
2984
+ const time = useCompositionTime();
2985
+ const exact = usePlayer().exactTime;
2986
+ const items = visiblePlacements(state, time).filter((v) => OVERLAY_KINDS.has(v.placement.kind));
2987
+ const byTrack = /* @__PURE__ */ new Map();
2988
+ for (const item of items) {
2989
+ const list = byTrack.get(item.track.id) ?? [];
2990
+ list.push(item);
2991
+ byTrack.set(item.track.id, list);
2992
+ }
2993
+ const backdrop = backdropTracks(state);
2994
+ const tracks = state.tracks.filter((t) => backdrop.has(t.id) === (layer === "backdrop"));
2995
+ const captions = layer === "front" ? [...state.items.values()].filter((p) => p.kind === "captions") : [];
2996
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [tracks.map((track) => {
2997
+ const list = byTrack.get(track.id);
2998
+ if (!list) return null;
2999
+ return /* @__PURE__ */ jsx("div", {
3000
+ "data-track": track.id,
3001
+ style: {
3002
+ position: "absolute",
3003
+ inset: 0,
3004
+ zIndex: track.order + 1,
3005
+ pointerEvents: "none"
3006
+ },
3007
+ children: list.map((item) => /* @__PURE__ */ jsx(Element, {
3008
+ item,
3009
+ state,
3010
+ time,
3011
+ exact,
3012
+ interior: interiors.get(item.placement.id)
3013
+ }, item.placement.id))
3014
+ }, track.id);
3015
+ }), captions.map((c) => /* @__PURE__ */ jsx("div", {
3016
+ style: {
3017
+ position: "absolute",
3018
+ inset: 0,
3019
+ zIndex: state.tracks.length + 1,
3020
+ pointerEvents: "none"
3021
+ },
3022
+ children: /* @__PURE__ */ jsx(Captions, {
3023
+ placement: c,
3024
+ state,
3025
+ time
3026
+ })
3027
+ }, c.id))] });
3028
+ }
3029
+ /** Re-renders only when the element's own relative frame or placement changes. */
3030
+ const Element = memo(function Element({ item, state, time, interior }) {
3031
+ const content = elementContent(item, {
3032
+ placement: item.placement,
3033
+ children: item.children,
3034
+ state,
3035
+ time,
3036
+ transition: item.transition
3037
+ }, state, interior);
3038
+ const passes = item.placement.kind === "adjustment" ? void 0 : effectPasses(state, item.children, time - item.placement.start);
3039
+ if (!passes || !content) return content;
3040
+ const { width, height } = state.composition;
3041
+ return /* @__PURE__ */ jsx(ShaderBox, {
3042
+ id: item.placement.id,
3043
+ width,
3044
+ height,
3045
+ passes,
3046
+ children: content
3047
+ });
3048
+ }, (a, b) => sameElement(a, b));
3049
+ function elementContent(item, props, state, interior) {
3050
+ switch (item.placement.kind) {
3051
+ case "title": return /* @__PURE__ */ jsx(Title, {
3052
+ ...props,
3053
+ text: state.tree.byId.get(item.placement.id)?.text ?? ""
3054
+ });
3055
+ case "shape": return /* @__PURE__ */ jsx(Shape, { ...props });
3056
+ case "svg": return /* @__PURE__ */ jsx(Svg, { ...props });
3057
+ case "sticker": return /* @__PURE__ */ jsx(Sticker, { ...props });
3058
+ case "graphic": return /* @__PURE__ */ jsx(Graphic, { ...props });
3059
+ case "layer": return /* @__PURE__ */ jsx(Layer, {
3060
+ ...props,
3061
+ interior
3062
+ });
3063
+ case "adjustment": return /* @__PURE__ */ jsx(Adjustment, { ...props });
3064
+ default: return null;
3065
+ }
3066
+ }
3067
+ /**
3068
+ * Whether two renders of an overlay would produce the same DOM. Placements
3069
+ * are new objects on every resolve, so compare what matters: timing, the
3070
+ * parsed props (referentially stable while untouched), children, the
3071
+ * element-relative frame, the transition progress, the text and the
3072
+ * composition box.
3073
+ */
3074
+ function sameElement(a, b) {
3075
+ const p = a.item.placement, q = b.item.placement;
3076
+ 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;
3077
+ if (a.interior !== b.interior) return false;
3078
+ 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;
3079
+ const ca = a.state.composition, cb = b.state.composition;
3080
+ if (ca.width !== cb.width || ca.height !== cb.height || ca.fps !== cb.fps) return false;
3081
+ if (p.kind === "title" && a.state.tree.byId.get(p.id)?.text !== b.state.tree.byId.get(p.id)?.text) return false;
3082
+ if (a.exact || b.exact ? a.time - p.start !== b.time - q.start : frameIndex(a.time - p.start, ca.fps) !== frameIndex(b.time - q.start, cb.fps)) return false;
3083
+ return (a.item.transition?.progress ?? -1) === (b.item.transition?.progress ?? -1);
3084
+ }
3085
+ // src/react/Player.tsx
3086
+ /**
3087
+ * Canvas for media, DOM for overlays, controls below. The root carries
3088
+ * `data-time` and `data-frame`; every element carries `data-el`/`data-kind`.
3089
+ */
3090
+ const Player = forwardRef(function Player(props, ref) {
3091
+ const { state, media, element, controls, loop, autoPlay, initiallyMuted, playbackRate, fit = "contain", style, className, audioContext, onError, onFrame, coreOptions } = props;
3092
+ const canvasRef = useRef(null);
3093
+ const rootRef = useRef(null);
3094
+ const [core, setCore] = useState(null);
3095
+ const interiors = useMemo(() => indexLayerInteriors(element ?? null), [element]);
3096
+ const [scale, setScale] = useState(1);
3097
+ useLayoutEffect(() => {
3098
+ const c = new PlayerCore({
3099
+ state,
3100
+ media,
3101
+ ...audioContext ? { audioContext } : {},
3102
+ loop: loop ?? false,
3103
+ initiallyMuted: initiallyMuted ?? false,
3104
+ ...playbackRate !== void 0 ? { playbackRate } : {},
3105
+ ...coreOptions ?? {}
3106
+ });
3107
+ const release = retainUseT();
3108
+ setCore(c);
3109
+ return () => {
3110
+ release();
3111
+ c.dispose();
3112
+ };
3113
+ }, [media, audioContext]);
3114
+ useLayoutEffect(() => {
3115
+ if (core && core.state !== state) core.setState(state);
3116
+ }, [core, state]);
3117
+ useLayoutEffect(() => {
3118
+ core?.attachCanvas(canvasRef.current);
3119
+ return () => core?.attachCanvas(null);
3120
+ }, [core]);
3121
+ useEffect(() => {
3122
+ if (core && loop !== void 0) core.loop = loop;
3123
+ }, [core, loop]);
3124
+ useEffect(() => {
3125
+ if (core && playbackRate !== void 0) core.playbackRate = playbackRate;
3126
+ }, [core, playbackRate]);
3127
+ useEffect(() => core && onError ? core.addEventListener("error", onError) : void 0, [core, onError]);
3128
+ useEffect(() => core && onFrame ? core.addEventListener("frameupdate", onFrame) : void 0, [core, onFrame]);
3129
+ useEffect(() => {
3130
+ if (core && autoPlay) core.play();
3131
+ }, [core, autoPlay]);
3132
+ useEffect(() => {
3133
+ const root = rootRef.current;
3134
+ if (!root || fit === "none" || typeof ResizeObserver === "undefined") return;
3135
+ const update = () => setScale(root.clientWidth / state.composition.width || 1);
3136
+ update();
3137
+ const ro = new ResizeObserver(update);
3138
+ ro.observe(root);
3139
+ return () => ro.disconnect();
3140
+ }, [fit, state.composition.width]);
3141
+ useImperativeHandle(ref, () => core ? refOf(core, canvasRef.current) : null, [core]);
3142
+ const { width, height } = state.composition;
3143
+ const snapshot = core?.getTime() ?? {
3144
+ time: 0,
3145
+ frame: 0
3146
+ };
3147
+ const backdrop = backdropTracks(state).size > 0;
3148
+ const layerStyle = {
3149
+ position: "absolute",
3150
+ left: 0,
3151
+ top: 0,
3152
+ width,
3153
+ height,
3154
+ transformOrigin: "0 0",
3155
+ transform: fit === "none" ? void 0 : `scale(${scale})`,
3156
+ pointerEvents: "none"
3157
+ };
3158
+ const rootStyle = fit === "none" ? {
3159
+ position: "relative",
3160
+ width,
3161
+ height,
3162
+ overflow: "hidden",
3163
+ background: "#000",
3164
+ ...style
3165
+ } : {
3166
+ position: "relative",
3167
+ width: "100%",
3168
+ aspectRatio: `${width} / ${height}`,
3169
+ overflow: "hidden",
3170
+ background: "#000",
3171
+ ...style
3172
+ };
3173
+ return /* @__PURE__ */ jsxs("div", {
3174
+ ref: rootRef,
3175
+ className,
3176
+ style: rootStyle,
3177
+ "data-time": snapshot.time.toFixed(4),
3178
+ "data-frame": snapshot.frame,
3179
+ "data-player": "",
3180
+ children: [
3181
+ backdrop && /* @__PURE__ */ jsx("div", {
3182
+ "data-ground": "",
3183
+ style: {
3184
+ position: "absolute",
3185
+ inset: 0,
3186
+ zIndex: 0,
3187
+ background: backgroundCss(compositionBackground(state))
3188
+ }
3189
+ }),
3190
+ backdrop && core && /* @__PURE__ */ jsx(PlayerContext.Provider, {
3191
+ value: core,
3192
+ children: /* @__PURE__ */ jsx("div", {
3193
+ "data-dom-layer": "backdrop",
3194
+ style: {
3195
+ ...layerStyle,
3196
+ zIndex: 0
3197
+ },
3198
+ children: /* @__PURE__ */ jsx(DomLayer, {
3199
+ state,
3200
+ interiors,
3201
+ layer: "backdrop"
3202
+ })
3203
+ })
3204
+ }),
3205
+ /* @__PURE__ */ jsx("canvas", {
3206
+ ref: canvasRef,
3207
+ "data-el": state.composition.id,
3208
+ "data-kind": "composition",
3209
+ width,
3210
+ height,
3211
+ style: {
3212
+ position: "absolute",
3213
+ inset: 0,
3214
+ width: "100%",
3215
+ height: "100%",
3216
+ display: "block",
3217
+ zIndex: 1
3218
+ }
3219
+ }),
3220
+ core && /* @__PURE__ */ jsxs(PlayerContext.Provider, {
3221
+ value: core,
3222
+ children: [/* @__PURE__ */ jsxs("div", {
3223
+ "data-dom-layer": "",
3224
+ style: {
3225
+ ...layerStyle,
3226
+ zIndex: 2
3227
+ },
3228
+ children: [/* @__PURE__ */ jsx(TimeAttrs, {}), /* @__PURE__ */ jsx(DomLayer, {
3229
+ state,
3230
+ interiors
3231
+ })]
3232
+ }), controls && /* @__PURE__ */ jsx(Controls, {})]
3233
+ })
3234
+ ]
3235
+ });
3236
+ });
3237
+ /** Keeps `data-time`/`data-frame` on the root current without re-rendering the whole player. */
3238
+ function TimeAttrs() {
3239
+ const core = usePlayer();
3240
+ const ref = useRef(null);
3241
+ useEffect(() => core.subscribeTime(() => {
3242
+ const root = ref.current?.closest("[data-player]");
3243
+ if (!root) return;
3244
+ const { time, frame } = core.getTime();
3245
+ root.dataset.time = time.toFixed(4);
3246
+ root.dataset.frame = String(frame);
3247
+ }), [core]);
3248
+ return /* @__PURE__ */ jsx("span", {
3249
+ ref,
3250
+ hidden: true
3251
+ });
3252
+ }
3253
+ function refOf(core, canvas) {
3254
+ return {
3255
+ core,
3256
+ canvas,
3257
+ play: () => core.play(),
3258
+ pause: () => core.pause(),
3259
+ toggle: () => core.toggle(),
3260
+ seekTo: (t) => core.seekTo(t),
3261
+ seekToFrame: (f) => core.seekToFrame(f),
3262
+ getCurrentTime: () => core.getCurrentTime(),
3263
+ getCurrentFrame: () => core.getCurrentFrame(),
3264
+ isPlaying: () => core.isPlaying(),
3265
+ addEventListener: (type, fn) => core.addEventListener(type, fn),
3266
+ removeEventListener: (type, fn) => core.removeEventListener(type, fn),
3267
+ renderFrame: (t) => core.renderFrame(t),
3268
+ captureSheet: (times, o) => core.captureSheet(times, o),
3269
+ get duration() {
3270
+ return core.duration;
3271
+ },
3272
+ get state() {
3273
+ return core.state;
3274
+ },
3275
+ get playbackRate() {
3276
+ return core.playbackRate;
3277
+ },
3278
+ set playbackRate(v) {
3279
+ core.playbackRate = v;
3280
+ },
3281
+ get loop() {
3282
+ return core.loop;
3283
+ },
3284
+ set loop(v) {
3285
+ core.loop = v;
3286
+ },
3287
+ get muted() {
3288
+ return core.muted;
3289
+ },
3290
+ set muted(v) {
3291
+ core.muted = v;
3292
+ },
3293
+ get volume() {
3294
+ return core.volume;
3295
+ },
3296
+ set volume(v) {
3297
+ core.volume = v;
3298
+ }
3299
+ };
3300
+ }
3301
+ 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, addFontFaces, 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, holdCssAnimations, indexLayerInteriors, isVisibleAt, keyframedValue, layerClock, layoutRect, loadFileFonts, localT, makeNestedResolver, matrixToCss, mediabunnyAudioSourceFactory, mediabunnyFrameSourceFactory, motionAt, multiply, nestedSourceTime, paintBackground, paramsAt, placementSourceTime, postEffects, retainUseT, rotate, roundedRect, sameElement, scaleM, setMatrix, timecode, toResolver, traceMask, trackItems, transformMatrix, translate, useCompositionFrame, useCompositionTime, useLayerClock, useLayerT, usePlayer, visiblePlacements };