@cutcraft/player 0.1.0 → 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/README.md +45 -86
- package/dist/engine.d.mts +2 -3
- package/dist/engine.mjs +1 -3
- package/dist/index.d.mts +60 -31
- package/dist/index.mjs +2168 -277
- package/dist/{nested-DSXA1q8d.mjs → render-DA1KFyei.mjs} +1643 -165
- package/dist/{nested-Bd4Y7qrF.d.mts → warn-GHM3SBI2.d.mts} +527 -355
- package/package.json +19 -12
- package/dist/backdrop-BqrzCzTB.d.mts +0 -131
- package/dist/frame-plan-BNwblf1s.mjs +0 -1074
- package/dist/layer-interiors-r4SeMbp2.d.mts +0 -8
- package/dist/overlays.d.mts +0 -201
- package/dist/overlays.mjs +0 -3
- package/dist/svg-element-BUXbz-U5.mjs +0 -1736
package/dist/index.mjs
CHANGED
|
@@ -1,117 +1,10 @@
|
|
|
1
|
-
import { $ as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { installLayerClock, rampAdvance, rampSpeed } from "@cutcraft/kit";
|
|
5
|
-
import { createContext, forwardRef, memo, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
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";
|
|
6
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* otherwise, with an offset captured at every state change so `now()` never
|
|
11
|
-
* jumps. Audio scheduling converts back with `toContextTime`.
|
|
12
|
-
*/
|
|
13
|
-
var AudioContextClock = class {
|
|
14
|
-
ctx;
|
|
15
|
-
offset = 0;
|
|
16
|
-
wasRunning = false;
|
|
17
|
-
lastNow = 0;
|
|
18
|
-
constructor(ctx) {
|
|
19
|
-
this.ctx = ctx;
|
|
20
|
-
}
|
|
21
|
-
get running() {
|
|
22
|
-
return this.ctx.state === "running";
|
|
23
|
-
}
|
|
24
|
-
now() {
|
|
25
|
-
const running = this.running;
|
|
26
|
-
const raw = running ? this.ctx.currentTime : performance.now() / 1e3;
|
|
27
|
-
if (running !== this.wasRunning) {
|
|
28
|
-
this.offset = this.lastNow - raw;
|
|
29
|
-
this.wasRunning = running;
|
|
30
|
-
}
|
|
31
|
-
const now = raw + this.offset;
|
|
32
|
-
this.lastNow = Math.max(now, this.lastNow);
|
|
33
|
-
return this.lastNow;
|
|
34
|
-
}
|
|
35
|
-
/** Audio context seconds for a clock time; exact while the context runs. */
|
|
36
|
-
toContextTime(clockTime) {
|
|
37
|
-
this.now();
|
|
38
|
-
return this.running ? clockTime - this.offset : this.ctx.currentTime + (clockTime - this.lastNow);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
// src/clock/transport.ts
|
|
42
|
-
/**
|
|
43
|
-
* Composition time as an anchor on the clock: `anchorTime + (now − anchorClock) × rate`.
|
|
44
|
-
* Every play, pause, seek and rate change re-anchors, so time is always derived from
|
|
45
|
-
* the clock and never accumulates drift.
|
|
46
|
-
*/
|
|
47
|
-
var Transport = class {
|
|
48
|
-
clock;
|
|
49
|
-
duration;
|
|
50
|
-
anchorTime = 0;
|
|
51
|
-
anchorClock = 0;
|
|
52
|
-
_rate = 1;
|
|
53
|
-
_playing = false;
|
|
54
|
-
loop = false;
|
|
55
|
-
constructor(clock, duration) {
|
|
56
|
-
this.clock = clock;
|
|
57
|
-
this.duration = duration;
|
|
58
|
-
}
|
|
59
|
-
get playing() {
|
|
60
|
-
return this._playing;
|
|
61
|
-
}
|
|
62
|
-
get rate() {
|
|
63
|
-
return this._rate;
|
|
64
|
-
}
|
|
65
|
-
time() {
|
|
66
|
-
return this.timeAt(this.clock.now());
|
|
67
|
-
}
|
|
68
|
-
timeAt(clockTime) {
|
|
69
|
-
if (!this._playing) return this.anchorTime;
|
|
70
|
-
return this.clamp(this.anchorTime + (clockTime - this.anchorClock) * this._rate);
|
|
71
|
-
}
|
|
72
|
-
/** Clock time at which composition time `t` plays; the inverse of `timeAt` while playing. */
|
|
73
|
-
clockTimeFor(t) {
|
|
74
|
-
if (!this._playing) return this.clock.now();
|
|
75
|
-
return this.anchorClock + (t - this.anchorTime) / this._rate;
|
|
76
|
-
}
|
|
77
|
-
play() {
|
|
78
|
-
if (this._playing) return;
|
|
79
|
-
if (this.anchorTime >= this.duration && this.duration > 0) this.anchorTime = 0;
|
|
80
|
-
this.anchorClock = this.clock.now();
|
|
81
|
-
this._playing = true;
|
|
82
|
-
}
|
|
83
|
-
pause() {
|
|
84
|
-
if (!this._playing) return;
|
|
85
|
-
this.anchorTime = this.time();
|
|
86
|
-
this._playing = false;
|
|
87
|
-
}
|
|
88
|
-
seek(t) {
|
|
89
|
-
this.anchorTime = this.clamp(t);
|
|
90
|
-
this.anchorClock = this.clock.now();
|
|
91
|
-
}
|
|
92
|
-
setRate(rate) {
|
|
93
|
-
const r = Math.min(4, Math.max(MIN_RATE, rate));
|
|
94
|
-
this.anchorTime = this.time();
|
|
95
|
-
this.anchorClock = this.clock.now();
|
|
96
|
-
this._rate = r;
|
|
97
|
-
}
|
|
98
|
-
/** Advance bookkeeping at the end: loops wrap, otherwise playback stops on the last time. */
|
|
99
|
-
check() {
|
|
100
|
-
if (!this._playing || this.duration <= 0) return null;
|
|
101
|
-
const raw = this.anchorTime + (this.clock.now() - this.anchorClock) * this._rate;
|
|
102
|
-
if (raw < this.duration) return null;
|
|
103
|
-
if (this.loop) {
|
|
104
|
-
this.seek(raw - this.duration >= this.duration ? 0 : raw - this.duration);
|
|
105
|
-
return "looped";
|
|
106
|
-
}
|
|
107
|
-
this.anchorTime = this.duration;
|
|
108
|
-
this._playing = false;
|
|
109
|
-
return "ended";
|
|
110
|
-
}
|
|
111
|
-
clamp(t) {
|
|
112
|
-
return t < 0 ? 0 : t > this.duration ? this.duration : t;
|
|
113
|
-
}
|
|
114
|
-
};
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { GRAPHIC_KINDS, drawGraphic, graphicSize, isIcon } from "@cutcraft/graphics";
|
|
7
|
+
import { SaxesParser } from "saxes";
|
|
115
8
|
// src/audio/audio-engine.ts
|
|
116
9
|
/**
|
|
117
10
|
* Schedules audio chunks on the output ahead of the transport. Every play
|
|
@@ -367,10 +260,10 @@ function automate(param, points, when, fallback) {
|
|
|
367
260
|
return;
|
|
368
261
|
}
|
|
369
262
|
const sorted = [...points].sort((a, b) => a.at - b.at);
|
|
370
|
-
param.setValueAtTime(valueAt(sorted, when, fallback), Math.max(0, when));
|
|
263
|
+
param.setValueAtTime(valueAt$1(sorted, when, fallback), Math.max(0, when));
|
|
371
264
|
for (const p of sorted) if (p.at > when) param.linearRampToValueAtTime(p.value, p.at);
|
|
372
265
|
}
|
|
373
|
-
function valueAt(points, at, fallback) {
|
|
266
|
+
function valueAt$1(points, at, fallback) {
|
|
374
267
|
const first = points[0], last = points[points.length - 1];
|
|
375
268
|
if (!first || !last) return fallback;
|
|
376
269
|
if (at <= first.at) return first.value;
|
|
@@ -381,6 +274,114 @@ function valueAt(points, at, fallback) {
|
|
|
381
274
|
}
|
|
382
275
|
return last.value;
|
|
383
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
|
+
};
|
|
384
385
|
// src/player.ts
|
|
385
386
|
/**
|
|
386
387
|
* The framework-free player: owns the transport, the compositor (canvas),
|
|
@@ -415,6 +416,9 @@ var PlayerCore = class {
|
|
|
415
416
|
_muted;
|
|
416
417
|
_volume = 1;
|
|
417
418
|
disposed = false;
|
|
419
|
+
fonts = Promise.resolve();
|
|
420
|
+
fontsOf = null;
|
|
421
|
+
fontsWanted;
|
|
418
422
|
constructor(options) {
|
|
419
423
|
this._state = options.state;
|
|
420
424
|
this.media = toResolver(options.media);
|
|
@@ -466,6 +470,23 @@ var PlayerCore = class {
|
|
|
466
470
|
});
|
|
467
471
|
this._muted = options.initiallyMuted ?? false;
|
|
468
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
|
+
}));
|
|
469
490
|
}
|
|
470
491
|
get state() {
|
|
471
492
|
return this._state;
|
|
@@ -627,6 +648,7 @@ var PlayerCore = class {
|
|
|
627
648
|
*/
|
|
628
649
|
setState(state) {
|
|
629
650
|
this._state = state;
|
|
651
|
+
this.loadFonts(state);
|
|
630
652
|
this.transport.duration = state.composition.duration;
|
|
631
653
|
this.compositor.setState(state);
|
|
632
654
|
if (this.transport.playing) return;
|
|
@@ -751,6 +773,43 @@ function drawLabel(ctx, text, x, y, w, h) {
|
|
|
751
773
|
ctx.textBaseline = "middle";
|
|
752
774
|
ctx.fillText(text, x + Math.round(band / 3), y + h - band / 2);
|
|
753
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
|
+
}
|
|
754
813
|
// src/react/context.ts
|
|
755
814
|
const PlayerContext = createContext(null);
|
|
756
815
|
/** The layer's clock inside a `<Layer>`; null outside one. */
|
|
@@ -787,43 +846,1858 @@ function retainUseT() {
|
|
|
787
846
|
if (--mounted === 0) installLayerClock(null);
|
|
788
847
|
};
|
|
789
848
|
}
|
|
790
|
-
// src/react/
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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,
|
|
816
2457
|
height: comp.height
|
|
817
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
|
+
});
|
|
818
2464
|
return /* @__PURE__ */ jsx("div", {
|
|
2465
|
+
ref,
|
|
819
2466
|
...dataAttrs(p.placement),
|
|
820
2467
|
style,
|
|
821
2468
|
children: /* @__PURE__ */ jsx(LayerContext.Provider, {
|
|
822
|
-
value:
|
|
823
|
-
children:
|
|
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)
|
|
824
2475
|
})
|
|
825
2476
|
});
|
|
826
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
|
+
}
|
|
827
2701
|
// src/react/elements/Shape.tsx
|
|
828
2702
|
/** A vector shape, drawn from the same markup as the export, with room around it for its stroke and shadows. */
|
|
829
2703
|
function Shape(p) {
|
|
@@ -843,24 +2717,47 @@ function Shape(p) {
|
|
|
843
2717
|
})
|
|
844
2718
|
});
|
|
845
2719
|
}
|
|
846
|
-
// src/
|
|
847
|
-
/**
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
860
2740
|
},
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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;
|
|
864
2761
|
}
|
|
865
2762
|
// src/react/elements/Svg.tsx
|
|
866
2763
|
/** Inline SVG markup, or an SVG asset read as text, prepared the way the export prepares it. */
|
|
@@ -989,10 +2886,103 @@ function Title(p) {
|
|
|
989
2886
|
children: titleContent(p)
|
|
990
2887
|
});
|
|
991
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
|
+
}
|
|
992
2981
|
// src/react/DomLayer.tsx
|
|
993
2982
|
/** Overlay elements as DOM, one absolutely positioned group per track in compositing order, above the canvas (or, for the backdrop, below it). */
|
|
994
2983
|
function DomLayer({ state, interiors, layer = "front" }) {
|
|
995
2984
|
const time = useCompositionTime();
|
|
2985
|
+
const exact = usePlayer().exactTime;
|
|
996
2986
|
const items = visiblePlacements(state, time).filter((v) => OVERLAY_KINDS.has(v.placement.kind));
|
|
997
2987
|
const byTrack = /* @__PURE__ */ new Map();
|
|
998
2988
|
for (const item of items) {
|
|
@@ -1018,6 +3008,7 @@ function DomLayer({ state, interiors, layer = "front" }) {
|
|
|
1018
3008
|
item,
|
|
1019
3009
|
state,
|
|
1020
3010
|
time,
|
|
3011
|
+
exact,
|
|
1021
3012
|
interior: interiors.get(item.placement.id)
|
|
1022
3013
|
}, item.placement.id))
|
|
1023
3014
|
}, track.id);
|
|
@@ -1037,13 +3028,25 @@ function DomLayer({ state, interiors, layer = "front" }) {
|
|
|
1037
3028
|
}
|
|
1038
3029
|
/** Re-renders only when the element's own relative frame or placement changes. */
|
|
1039
3030
|
const Element = memo(function Element({ item, state, time, interior }) {
|
|
1040
|
-
const
|
|
3031
|
+
const content = elementContent(item, {
|
|
1041
3032
|
placement: item.placement,
|
|
1042
3033
|
children: item.children,
|
|
1043
3034
|
state,
|
|
1044
3035
|
time,
|
|
1045
3036
|
transition: item.transition
|
|
1046
|
-
};
|
|
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) {
|
|
1047
3050
|
switch (item.placement.kind) {
|
|
1048
3051
|
case "title": return /* @__PURE__ */ jsx(Title, {
|
|
1049
3052
|
...props,
|
|
@@ -1060,7 +3063,7 @@ const Element = memo(function Element({ item, state, time, interior }) {
|
|
|
1060
3063
|
case "adjustment": return /* @__PURE__ */ jsx(Adjustment, { ...props });
|
|
1061
3064
|
default: return null;
|
|
1062
3065
|
}
|
|
1063
|
-
}
|
|
3066
|
+
}
|
|
1064
3067
|
/**
|
|
1065
3068
|
* Whether two renders of an overlay would produce the same DOM. Placements
|
|
1066
3069
|
* are new objects on every resolve, so compare what matters: timing, the
|
|
@@ -1076,121 +3079,9 @@ function sameElement(a, b) {
|
|
|
1076
3079
|
const ca = a.state.composition, cb = b.state.composition;
|
|
1077
3080
|
if (ca.width !== cb.width || ca.height !== cb.height || ca.fps !== cb.fps) return false;
|
|
1078
3081
|
if (p.kind === "title" && a.state.tree.byId.get(p.id)?.text !== b.state.tree.byId.get(p.id)?.text) return false;
|
|
1079
|
-
if (frameIndex(a.time - p.start, ca.fps) !== frameIndex(b.time - q.start, cb.fps)) return false;
|
|
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;
|
|
1080
3083
|
return (a.item.transition?.progress ?? -1) === (b.item.transition?.progress ?? -1);
|
|
1081
3084
|
}
|
|
1082
|
-
// src/react/Controls.tsx
|
|
1083
|
-
const bar = {
|
|
1084
|
-
display: "flex",
|
|
1085
|
-
alignItems: "center",
|
|
1086
|
-
gap: 8,
|
|
1087
|
-
padding: "6px 8px",
|
|
1088
|
-
background: "rgba(0,0,0,0.75)",
|
|
1089
|
-
color: "#eee",
|
|
1090
|
-
font: "12px system-ui, sans-serif",
|
|
1091
|
-
position: "absolute",
|
|
1092
|
-
left: 0,
|
|
1093
|
-
right: 0,
|
|
1094
|
-
bottom: 0,
|
|
1095
|
-
zIndex: 1e3
|
|
1096
|
-
};
|
|
1097
|
-
const button = {
|
|
1098
|
-
background: "none",
|
|
1099
|
-
border: "1px solid #777",
|
|
1100
|
-
color: "inherit",
|
|
1101
|
-
borderRadius: 4,
|
|
1102
|
-
padding: "2px 8px",
|
|
1103
|
-
cursor: "pointer",
|
|
1104
|
-
font: "inherit"
|
|
1105
|
-
};
|
|
1106
|
-
/** Play/pause, scrubber, time readout, mute and rate. */
|
|
1107
|
-
function Controls() {
|
|
1108
|
-
const core = usePlayer();
|
|
1109
|
-
const time = useCompositionTime();
|
|
1110
|
-
const [playing, setPlaying] = useState(core.isPlaying());
|
|
1111
|
-
const [muted, setMuted] = useState(core.muted);
|
|
1112
|
-
const [rate, setRate] = useState(core.playbackRate);
|
|
1113
|
-
const resumeAfterScrub = useRef(false);
|
|
1114
|
-
useEffect(() => {
|
|
1115
|
-
const offs = [
|
|
1116
|
-
core.addEventListener("play", () => setPlaying(true)),
|
|
1117
|
-
core.addEventListener("pause", () => setPlaying(false)),
|
|
1118
|
-
core.addEventListener("ended", () => setPlaying(false)),
|
|
1119
|
-
core.addEventListener("volumechange", (e) => setMuted(e.muted)),
|
|
1120
|
-
core.addEventListener("ratechange", (e) => setRate(e.rate))
|
|
1121
|
-
];
|
|
1122
|
-
return () => offs.forEach((off) => off());
|
|
1123
|
-
}, [core]);
|
|
1124
|
-
const fps = core.state.composition.fps;
|
|
1125
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
1126
|
-
style: bar,
|
|
1127
|
-
"data-controls": "",
|
|
1128
|
-
children: [
|
|
1129
|
-
/* @__PURE__ */ jsx("button", {
|
|
1130
|
-
type: "button",
|
|
1131
|
-
style: button,
|
|
1132
|
-
onClick: () => void core.toggle(),
|
|
1133
|
-
"aria-label": playing ? "Pause" : "Play",
|
|
1134
|
-
children: playing ? "❚❚" : "▶"
|
|
1135
|
-
}),
|
|
1136
|
-
/* @__PURE__ */ jsx("input", {
|
|
1137
|
-
type: "range",
|
|
1138
|
-
min: 0,
|
|
1139
|
-
max: core.duration,
|
|
1140
|
-
step: 1 / fps,
|
|
1141
|
-
value: time,
|
|
1142
|
-
"aria-label": "Scrub",
|
|
1143
|
-
style: { flex: 1 },
|
|
1144
|
-
onPointerDown: () => {
|
|
1145
|
-
resumeAfterScrub.current = core.isPlaying();
|
|
1146
|
-
core.pause();
|
|
1147
|
-
},
|
|
1148
|
-
onPointerUp: () => {
|
|
1149
|
-
if (resumeAfterScrub.current) core.play();
|
|
1150
|
-
resumeAfterScrub.current = false;
|
|
1151
|
-
},
|
|
1152
|
-
onChange: (e) => void core.seekTo(Number(e.target.value))
|
|
1153
|
-
}),
|
|
1154
|
-
/* @__PURE__ */ jsxs("span", {
|
|
1155
|
-
style: { fontVariantNumeric: "tabular-nums" },
|
|
1156
|
-
children: [
|
|
1157
|
-
timecode(time, fps),
|
|
1158
|
-
" / ",
|
|
1159
|
-
timecode(core.duration, fps)
|
|
1160
|
-
]
|
|
1161
|
-
}),
|
|
1162
|
-
/* @__PURE__ */ jsx("button", {
|
|
1163
|
-
type: "button",
|
|
1164
|
-
style: button,
|
|
1165
|
-
onClick: () => {
|
|
1166
|
-
core.muted = !core.muted;
|
|
1167
|
-
},
|
|
1168
|
-
"aria-label": muted ? "Unmute" : "Mute",
|
|
1169
|
-
children: muted ? "🔇" : "🔊"
|
|
1170
|
-
}),
|
|
1171
|
-
/* @__PURE__ */ jsx("select", {
|
|
1172
|
-
value: rate,
|
|
1173
|
-
onChange: (e) => {
|
|
1174
|
-
core.playbackRate = Number(e.target.value);
|
|
1175
|
-
},
|
|
1176
|
-
"aria-label": "Speed",
|
|
1177
|
-
style: {
|
|
1178
|
-
...button,
|
|
1179
|
-
padding: 2
|
|
1180
|
-
},
|
|
1181
|
-
children: [
|
|
1182
|
-
.5,
|
|
1183
|
-
1,
|
|
1184
|
-
1.5,
|
|
1185
|
-
2
|
|
1186
|
-
].map((r) => /* @__PURE__ */ jsxs("option", {
|
|
1187
|
-
value: r,
|
|
1188
|
-
children: [r, "×"]
|
|
1189
|
-
}, r))
|
|
1190
|
-
})
|
|
1191
|
-
]
|
|
1192
|
-
});
|
|
1193
|
-
}
|
|
1194
3085
|
// src/react/Player.tsx
|
|
1195
3086
|
/**
|
|
1196
3087
|
* Canvas for media, DOM for overlays, controls below. The root carries
|
|
@@ -1407,4 +3298,4 @@ function refOf(core, canvas) {
|
|
|
1407
3298
|
}
|
|
1408
3299
|
};
|
|
1409
3300
|
}
|
|
1410
|
-
export { AUDIO_CACHE_S, AUDIO_CHUNK_S, AUDIO_PRIME_S, AssetSources, AudioContextClock, AudioEngine, CanvasPainter, ClipDecoder, Compositor, Controls, DUCK_ATTACK_S, DUCK_RELEASE_S, DecoderPool, DomLayer, EASING_FN, ENVELOPE_STEP_S, ErrorFrame, ErrorFrameSource, FRAME_EPS, FRAME_WAIT_MS, IDENTITY, INPUT_IDLE_CLOSE_S, ImageFrame, ImageFrameSource, LOOKAHEAD_S, LayerContext, MAX_OPEN_INPUTS, MAX_RATE, MIN_RATE, MOUNT_AHEAD_S, ManualClock, MediabunnyAudioSource, MediabunnyFrameSource, MediabunnyInputs, OVERLAY_KINDS, PICK_EPS, PLAY_PRIME_TIMEOUT_MS, PerformanceClock, Player, PlayerContext, PlayerCore, QUEUE_MAX, QUEUE_TARGET, REVERSE_WINDOW_FRAMES, REVERSE_WINDOW_S, SCHEDULER_MS, SEEK_MODE_SPEED, SETTLE_MS, STILL, SampleFrame, Transport, UNMOUNT_BEHIND_S, WebAudioOutput, animatedTransform, backdropTracks, backgroundCss, backgroundFill, blendOf, boundsOf, buildAudioPlan, buildFramePlan, buildGainEnvelope, captureWarnings, combine, compositionBackground, copyFrame, coversCanvas, cssTransform, defaultCreateCanvas, devWarn, duckIntervals, ease, effect, filterString, fitRect, frameIndex, frameTime, fromMap, hasKeyframes, indexLayerInteriors, isVisibleAt, keyframedValue, layerClock, layoutRect, localT, makeNestedResolver, matrixToCss, mediabunnyAudioSourceFactory, mediabunnyFrameSourceFactory, motionAt, multiply, nestedSourceTime, paintBackground, placementSourceTime, postEffects, retainUseT, rotate, roundedRect, sameElement, scaleM, setMatrix, timecode, toResolver, traceMask, trackItems, transformMatrix, translate, useCompositionFrame, useCompositionTime, useLayerClock, useLayerT, usePlayer, visiblePlacements };
|
|
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 };
|