@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/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/engine.d.mts +2 -0
- package/dist/engine.mjs +1 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +3301 -0
- package/dist/render-DA1KFyei.mjs +3176 -0
- package/dist/warn-GHM3SBI2.d.mts +892 -0
- package/fonts.css +23 -0
- package/package.json +74 -3
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2337
|
+
}
|
|
2338
|
+
function escapeAttr(t) {
|
|
2339
|
+
return escapeText(t).replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/"/g, """);
|
|
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 };
|