@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
|
@@ -1,6 +1,113 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { EASING_FN, MASK_TYPES, TRANSITION_TYPES, capabilitiesOf, freezeHoldsOf, freezeShift, lookupAsset, rampAdvance, rampInverse, rampSpeed, resolveScene, shaderFor, speedKeysOf } from "@cutcraft/kit";
|
|
2
|
+
import { ALL_FORMATS, AudioBufferSink, BlobSource, CanvasSink, Input, UrlSource, VideoSampleSink } from "mediabunny";
|
|
3
|
+
// src/constants.ts
|
|
4
|
+
/** Seconds before a clip starts at which its decoder is mounted. */
|
|
5
|
+
const MOUNT_AHEAD_S = 2;
|
|
6
|
+
/** Seconds after a clip ends for which its decoder is kept. */
|
|
7
|
+
const UNMOUNT_BEHIND_S = .5;
|
|
8
|
+
/** Seconds an unreferenced mediabunny Input stays open before it is disposed. */
|
|
9
|
+
const INPUT_IDLE_CLOSE_S = 10;
|
|
10
|
+
/** Hard cap on open Inputs; idle ones are evicted LRU first. */
|
|
11
|
+
const MAX_OPEN_INPUTS = 6;
|
|
12
|
+
/** Decoded frames a clip decoder keeps queued ahead of the play head. */
|
|
13
|
+
const QUEUE_TARGET = 8;
|
|
14
|
+
const QUEUE_MAX = 10;
|
|
15
|
+
/** Effective speed (clip speed × rate) above which frames are fetched by seek per tick instead of streamed. */
|
|
16
|
+
const SEEK_MODE_SPEED = 4;
|
|
17
|
+
/** Reverse playback pre-decodes this window behind the cursor. */
|
|
18
|
+
const REVERSE_WINDOW_S = 1;
|
|
19
|
+
const REVERSE_WINDOW_FRAMES = 16;
|
|
20
|
+
/** Longest wait for a frame in `wait` mode (seek, export) before drawing without it. */
|
|
21
|
+
const FRAME_WAIT_MS = 250;
|
|
22
|
+
/** Audio scheduler period and horizon. */
|
|
23
|
+
const SCHEDULER_MS = 100;
|
|
24
|
+
const LOOKAHEAD_S = .2;
|
|
25
|
+
/** Source ranges longer than twice this are decoded in chunks of this length. */
|
|
26
|
+
const AUDIO_CHUNK_S = 30;
|
|
27
|
+
/** LRU cap, in decoded seconds, on cached PCM. */
|
|
28
|
+
const AUDIO_CACHE_S = 600;
|
|
29
|
+
/** Pre-decode window on play, and how long play() waits for it. */
|
|
30
|
+
const AUDIO_PRIME_S = .5;
|
|
31
|
+
const PLAY_PRIME_TIMEOUT_MS = 500;
|
|
32
|
+
/** Ducking defaults. */
|
|
33
|
+
const DUCK_ATTACK_S = .1;
|
|
34
|
+
const DUCK_RELEASE_S = .3;
|
|
35
|
+
/** Sampling step for multiplied gain ramps. */
|
|
36
|
+
const ENVELOPE_STEP_S = .05;
|
|
37
|
+
/** Playback rate clamp. */
|
|
38
|
+
const MIN_RATE = .25;
|
|
39
|
+
const MAX_RATE = 4;
|
|
40
|
+
/** Quiet time after the last setState before a paused preview fetches its exact frame. */
|
|
41
|
+
const SETTLE_MS = 120;
|
|
42
|
+
/** Tolerance when rounding a time to a frame index. */
|
|
43
|
+
const FRAME_EPS = 1e-6;
|
|
44
|
+
/**
|
|
45
|
+
* Container timestamps are often rounded to the millisecond, so the frame for
|
|
46
|
+
* the exact time n/fps can sit a fraction of a millisecond after it. A frame
|
|
47
|
+
* is picked when its timestamp is at most this far past the wanted time.
|
|
48
|
+
*/
|
|
49
|
+
const PICK_EPS = .001;
|
|
50
|
+
// src/warn.ts
|
|
51
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52
|
+
let sink = null;
|
|
53
|
+
/** Warn once per key. Tests capture warnings with `captureWarnings`. */
|
|
54
|
+
function devWarn(key, message) {
|
|
55
|
+
if (seen.has(key)) return;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
if (sink) sink(message);
|
|
58
|
+
else console.warn(`[cutcut/player] ${message}`);
|
|
59
|
+
}
|
|
60
|
+
function captureWarnings(fn) {
|
|
61
|
+
sink = fn;
|
|
62
|
+
seen.clear();
|
|
63
|
+
}
|
|
64
|
+
// src/model/keyframes.ts
|
|
65
|
+
const clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
|
66
|
+
function ease(name, x) {
|
|
67
|
+
const fn = EASING_FN[name];
|
|
68
|
+
if (!fn) {
|
|
69
|
+
if (name !== void 0) devWarn(`easing:${name}`, `unknown easing "${name}", using linear`);
|
|
70
|
+
return clamp01(x);
|
|
71
|
+
}
|
|
72
|
+
return fn(clamp01(x));
|
|
73
|
+
}
|
|
74
|
+
function keysFor(children, property) {
|
|
75
|
+
const keys = [];
|
|
76
|
+
for (const c of children) {
|
|
77
|
+
if (c.kind !== "keyframe" || c.props.property !== property) continue;
|
|
78
|
+
keys.push({
|
|
79
|
+
at: Number(c.props.at ?? 0),
|
|
80
|
+
value: c.props.value,
|
|
81
|
+
easing: c.props.easing
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return keys.sort((a, b) => a.at - b.at);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The keyframed value of `property` at element-relative time `rel`. Holds
|
|
88
|
+
* before the first and after the last key; the segment between two keys uses
|
|
89
|
+
* the easing of the key it leaves. Non-numeric values step.
|
|
90
|
+
*/
|
|
91
|
+
function keyframedValue(children, property, rel, fallback) {
|
|
92
|
+
const keys = keysFor(children, property);
|
|
93
|
+
const first = keys[0];
|
|
94
|
+
if (!first) return fallback;
|
|
95
|
+
const last = keys[keys.length - 1];
|
|
96
|
+
if (rel <= first.at) return first.value;
|
|
97
|
+
if (rel >= last.at) return last.value;
|
|
98
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
99
|
+
const a = keys[i], b = keys[i + 1];
|
|
100
|
+
if (rel < a.at || rel >= b.at) continue;
|
|
101
|
+
if (typeof a.value !== "number" || typeof b.value !== "number") return a.value;
|
|
102
|
+
const span = b.at - a.at;
|
|
103
|
+
const u = span <= 0 ? 1 : ease(a.easing, (rel - a.at) / span);
|
|
104
|
+
return a.value + (b.value - a.value) * u;
|
|
105
|
+
}
|
|
106
|
+
return last.value;
|
|
107
|
+
}
|
|
108
|
+
function hasKeyframes(children, property) {
|
|
109
|
+
return children.some((c) => c.kind === "keyframe" && c.props.property === property);
|
|
110
|
+
}
|
|
4
111
|
// src/model/audio-plan.ts
|
|
5
112
|
/** Every audio-bearing chunk that overlaps [from, to). */
|
|
6
113
|
function buildAudioPlan(state, from, to, resolveNested) {
|
|
@@ -259,24 +366,6 @@ var PerformanceClock = class {
|
|
|
259
366
|
return performance.now() / 1e3;
|
|
260
367
|
}
|
|
261
368
|
};
|
|
262
|
-
// src/media/resolver.ts
|
|
263
|
-
/** A resolver over a map of asset id → Blob or URL. */
|
|
264
|
-
function fromMap(map) {
|
|
265
|
-
return { resolve: (assetId) => {
|
|
266
|
-
const v = map[assetId];
|
|
267
|
-
if (v === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no media for asset "${assetId}"`));
|
|
268
|
-
return Promise.resolve(typeof v === "string" ? {
|
|
269
|
-
kind: "url",
|
|
270
|
-
url: v
|
|
271
|
-
} : {
|
|
272
|
-
kind: "blob",
|
|
273
|
-
blob: v
|
|
274
|
-
});
|
|
275
|
-
} };
|
|
276
|
-
}
|
|
277
|
-
function toResolver(media) {
|
|
278
|
-
return typeof media.resolve === "function" ? media : fromMap(media);
|
|
279
|
-
}
|
|
280
369
|
// src/media/frame-source.ts
|
|
281
370
|
function defaultCreateCanvas(width, height) {
|
|
282
371
|
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
@@ -531,6 +620,62 @@ var LazyFrameSource = class {
|
|
|
531
620
|
this.inner?.then((o) => o.source.close()).catch(() => void 0);
|
|
532
621
|
}
|
|
533
622
|
};
|
|
623
|
+
// src/model/time.ts
|
|
624
|
+
function frameIndex(t, fps) {
|
|
625
|
+
return Math.max(0, Math.floor(t * fps + FRAME_EPS));
|
|
626
|
+
}
|
|
627
|
+
function frameTime(frame, fps) {
|
|
628
|
+
return frame / fps;
|
|
629
|
+
}
|
|
630
|
+
/** `mm:ss.ff` for a composition time, what the controls show and the contact sheet labels. */
|
|
631
|
+
function timecode(t, fps) {
|
|
632
|
+
const total = Math.max(0, t);
|
|
633
|
+
const m = Math.floor(total / 60), s = Math.floor(total % 60), f = Math.floor((total - Math.floor(total)) * fps + 1e-6);
|
|
634
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(f).padStart(2, "0")}`;
|
|
635
|
+
}
|
|
636
|
+
/** 0..1 progress through a placement, what `useT()` returns inside a Layer. */
|
|
637
|
+
function localT(p, t) {
|
|
638
|
+
if (p.duration <= 0) return 0;
|
|
639
|
+
const u = (t - p.start) / p.duration;
|
|
640
|
+
return u < 0 ? 0 : u > 1 ? 1 : u;
|
|
641
|
+
}
|
|
642
|
+
/** What `useLayer()` returns inside a Layer: progress plus real seconds. */
|
|
643
|
+
function layerClock(p, t, fps) {
|
|
644
|
+
const duration = Math.max(0, p.duration);
|
|
645
|
+
const elapsed = Math.min(duration, Math.max(0, t - p.start));
|
|
646
|
+
return {
|
|
647
|
+
t: localT(p, t),
|
|
648
|
+
elapsed,
|
|
649
|
+
remaining: duration - elapsed,
|
|
650
|
+
duration,
|
|
651
|
+
time: t,
|
|
652
|
+
fps
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Source seconds a media placement shows at composition time `t`: in/out
|
|
657
|
+
* trim, constant speed or a speed-key ramp, reverse, and freeze holds.
|
|
658
|
+
* A freeze is an insert hold: it shows `at` over its window, then the clip
|
|
659
|
+
* resumes from where it stopped; the result is clamped to the trimmed range.
|
|
660
|
+
*/
|
|
661
|
+
function placementSourceTime(p, children, t) {
|
|
662
|
+
const src = p.source;
|
|
663
|
+
if (!src) return Math.max(0, t - p.start);
|
|
664
|
+
const shifted = freezeShift(freezeHoldsOf(children), Math.max(0, t - p.start));
|
|
665
|
+
if ("held" in shifted) return clampSource(shifted.held, src);
|
|
666
|
+
const keys = speedKeysOf(children);
|
|
667
|
+
const advance = keys.length > 0 ? rampAdvance(keys, shifted.rel) : shifted.rel * src.speed;
|
|
668
|
+
return clampSource(p.props.reverse === true ? src.out - advance : src.in + advance, src);
|
|
669
|
+
}
|
|
670
|
+
function nestedSourceTime(p, t) {
|
|
671
|
+
const src = p.source;
|
|
672
|
+
if (!src) return Math.max(0, t - p.start);
|
|
673
|
+
return clampSource(src.in + Math.max(0, t - p.start) * src.speed, src);
|
|
674
|
+
}
|
|
675
|
+
function clampSource(v, src) {
|
|
676
|
+
const end = src.out > src.in ? src.out : src.in;
|
|
677
|
+
return v < src.in ? src.in : v > end ? end : v;
|
|
678
|
+
}
|
|
534
679
|
// src/media/clip-decoder.ts
|
|
535
680
|
/** How far the play head may run past the newest queued frame before the stream is restarted from the head. */
|
|
536
681
|
const BEHIND_RESEEK_S = 1;
|
|
@@ -869,6 +1014,75 @@ function sourceKeyOf(p) {
|
|
|
869
1014
|
const s = p.source;
|
|
870
1015
|
return s ? `${s.asset}|${p.props.reverse === true ? "r" : "f"}` : p.id;
|
|
871
1016
|
}
|
|
1017
|
+
// src/model/visible.ts
|
|
1018
|
+
/** Visual, timed placements that show at `t`: tracks in document order (the first track is the bottom), items in track order. */
|
|
1019
|
+
function visiblePlacements(state, t) {
|
|
1020
|
+
const out = [];
|
|
1021
|
+
const tracks = [...state.tracks].sort((a, b) => a.order - b.order);
|
|
1022
|
+
for (const track of tracks) {
|
|
1023
|
+
if (track.hidden) continue;
|
|
1024
|
+
const roles = transitionRoles(state, track, t);
|
|
1025
|
+
for (const placement of trackItems(state, track)) {
|
|
1026
|
+
const caps = capabilitiesOf(placement.kind);
|
|
1027
|
+
if (!caps.visual || !caps.timed || placement.kind === "transition") continue;
|
|
1028
|
+
if (!isVisibleAt(placement, t, state.composition.duration)) continue;
|
|
1029
|
+
const role = roles.get(placement.id);
|
|
1030
|
+
out.push({
|
|
1031
|
+
placement,
|
|
1032
|
+
track,
|
|
1033
|
+
children: placement.children.map((id) => state.items.get(id)).filter((c) => c !== void 0),
|
|
1034
|
+
...role ? { transition: role } : {}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return out;
|
|
1039
|
+
}
|
|
1040
|
+
function isVisibleAt(p, t, compositionDuration) {
|
|
1041
|
+
if (t >= p.start && t < p.end) return true;
|
|
1042
|
+
return t === compositionDuration && p.end === t && p.end > p.start;
|
|
1043
|
+
}
|
|
1044
|
+
/** Placements that sit on a track, in track order; a multicam's generated clips follow their parent. */
|
|
1045
|
+
function trackItems(state, track) {
|
|
1046
|
+
const out = [];
|
|
1047
|
+
for (const id of track.items) {
|
|
1048
|
+
const p = state.items.get(id);
|
|
1049
|
+
if (!p) continue;
|
|
1050
|
+
if (p.kind === "multicam") {
|
|
1051
|
+
const generated = [...state.items.values()].filter((q) => q.parent === id && q.kind === "clip").sort((a, b) => a.start - b.start);
|
|
1052
|
+
out.push(...generated);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
out.push(p);
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
function transitionRoles(state, track, t) {
|
|
1060
|
+
const roles = /* @__PURE__ */ new Map();
|
|
1061
|
+
for (const id of track.items) {
|
|
1062
|
+
const x = state.items.get(id);
|
|
1063
|
+
if (x?.kind !== "transition" || x.duration <= 0) continue;
|
|
1064
|
+
if (t < x.start || t >= x.end) continue;
|
|
1065
|
+
const params = x.props.params ?? {};
|
|
1066
|
+
const progress = ease(typeof params.curve === "string" ? params.curve : "linear", (t - x.start) / x.duration);
|
|
1067
|
+
if (x.between) {
|
|
1068
|
+
roles.set(x.between[0], {
|
|
1069
|
+
placement: x,
|
|
1070
|
+
role: "out",
|
|
1071
|
+
progress
|
|
1072
|
+
});
|
|
1073
|
+
roles.set(x.between[1], {
|
|
1074
|
+
placement: x,
|
|
1075
|
+
role: "in",
|
|
1076
|
+
progress
|
|
1077
|
+
});
|
|
1078
|
+
} else if (typeof x.props.on === "string") roles.set(x.props.on, {
|
|
1079
|
+
placement: x,
|
|
1080
|
+
role: x.props.edge === "in" ? "in" : "out",
|
|
1081
|
+
progress
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
return roles;
|
|
1085
|
+
}
|
|
872
1086
|
// src/media/decoder-pool.ts
|
|
873
1087
|
/**
|
|
874
1088
|
* Keeps a ClipDecoder for every media placement near the play head and drops
|
|
@@ -939,43 +1153,148 @@ var DecoderPool = class {
|
|
|
939
1153
|
if (d.placement.source) this.sources.release(d.placement.source.asset);
|
|
940
1154
|
}
|
|
941
1155
|
};
|
|
942
|
-
// src/media/
|
|
943
|
-
/**
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1156
|
+
// src/media/fonts.ts
|
|
1157
|
+
/** Faces already added to `document.fonts`, by family, weight, style and source, so a remount adds none twice. */
|
|
1158
|
+
const added = /* @__PURE__ */ new Map();
|
|
1159
|
+
/**
|
|
1160
|
+
* Adds faces to `document.fonts` and resolves once every one has loaded. A URL source loads from that URL; bytes load as
|
|
1161
|
+
* given. Does nothing where there is no `FontFace` (Node, tests).
|
|
1162
|
+
*/
|
|
1163
|
+
function addFontFaces(faces) {
|
|
1164
|
+
if (typeof FontFace === "undefined" || typeof document === "undefined") return Promise.resolve();
|
|
1165
|
+
return Promise.all(faces.map((face) => {
|
|
1166
|
+
const weight = String(face.weight ?? 400);
|
|
1167
|
+
const style = face.style ?? "normal";
|
|
1168
|
+
const key = `${face.family}|${weight}|${style}|${typeof face.source === "string" ? face.source : face.source.byteLength}`;
|
|
1169
|
+
let loading = added.get(key);
|
|
1170
|
+
if (!loading) {
|
|
1171
|
+
const font = new FontFace(face.family, typeof face.source === "string" ? `url("${face.source}")` : face.source, {
|
|
1172
|
+
weight,
|
|
1173
|
+
style
|
|
1174
|
+
});
|
|
1175
|
+
loading = font.load().then(() => {
|
|
1176
|
+
document.fonts.add(font);
|
|
1177
|
+
});
|
|
1178
|
+
added.set(key, loading);
|
|
1179
|
+
loading.catch(() => added.delete(key));
|
|
958
1180
|
}
|
|
959
|
-
|
|
960
|
-
|
|
1181
|
+
return loading;
|
|
1182
|
+
})).then(() => void 0);
|
|
1183
|
+
}
|
|
1184
|
+
/** The stylesheet URL Google Fonts serves a family's face from, for a browser. */
|
|
1185
|
+
function googleCss(face) {
|
|
1186
|
+
return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(face.family).replace(/%20/g, "+")}:ital,wght@${face.style === "italic" ? 1 : 0},${face.weight}&display=block`;
|
|
1187
|
+
}
|
|
1188
|
+
async function addGoogleFace(face) {
|
|
1189
|
+
const href = googleCss(face);
|
|
1190
|
+
if (!document.querySelector(`link[href="${href}"]`)) {
|
|
1191
|
+
const link = document.createElement("link");
|
|
1192
|
+
link.rel = "stylesheet";
|
|
1193
|
+
link.href = href;
|
|
1194
|
+
const loaded = new Promise((ok, fail) => {
|
|
1195
|
+
link.onload = () => ok();
|
|
1196
|
+
link.onerror = () => fail(/* @__PURE__ */ new Error(`Google Fonts did not serve ${face.family} ${face.weight} ${face.style}`));
|
|
1197
|
+
});
|
|
1198
|
+
document.head.append(link);
|
|
1199
|
+
await loaded;
|
|
961
1200
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1201
|
+
if ((await document.fonts.load(`${face.style} ${face.weight} 16px "${face.family}"`)).length === 0) throw new Error(`Google Fonts has no ${face.family} ${face.weight} ${face.style}`);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Loads the faces a file declares (`state.fonts`) into the page: an `asset:` face is asked of `media` as an asset is,
|
|
1205
|
+
* a Google face loads from Google's stylesheet. Resolves once all have loaded; a face that fails is reported through
|
|
1206
|
+
* `onError` and the rest load. Does nothing where there is no `FontFace` (Node, tests).
|
|
1207
|
+
*/
|
|
1208
|
+
async function loadFileFonts(faces, media, onError) {
|
|
1209
|
+
if (typeof FontFace === "undefined" || typeof document === "undefined") return;
|
|
1210
|
+
await Promise.all(faces.map(async (face) => {
|
|
1211
|
+
try {
|
|
1212
|
+
if (face.source.kind === "google") return await addGoogleFace(face);
|
|
1213
|
+
const handle = await media.resolve(face.source.id);
|
|
1214
|
+
if (handle.kind === "path") throw new Error(`a browser cannot read the path ${handle.path}; give it a URL`);
|
|
1215
|
+
const source = handle.kind === "url" ? handle.url : await handle.blob.arrayBuffer();
|
|
1216
|
+
await addFontFaces([{
|
|
1217
|
+
family: face.family,
|
|
1218
|
+
weight: face.weight,
|
|
1219
|
+
style: face.style,
|
|
1220
|
+
source
|
|
1221
|
+
}]);
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
onError(face.family, error instanceof Error ? error : new Error(String(error)));
|
|
969
1224
|
}
|
|
1225
|
+
}));
|
|
1226
|
+
}
|
|
1227
|
+
// src/media/mediabunny-audio-source.ts
|
|
1228
|
+
/** PCM ranges of one audio track, decoded on demand into AudioBuffers of the track's sample rate. */
|
|
1229
|
+
var MediabunnyAudioSource = class {
|
|
1230
|
+
assetId;
|
|
1231
|
+
handle;
|
|
1232
|
+
inputs;
|
|
1233
|
+
context;
|
|
1234
|
+
trackIndex;
|
|
1235
|
+
input = null;
|
|
1236
|
+
sink = null;
|
|
1237
|
+
sampleRate = 48e3;
|
|
1238
|
+
channels = 2;
|
|
1239
|
+
constructor(assetId, handle, inputs, context, trackIndex = 0) {
|
|
1240
|
+
this.assetId = assetId;
|
|
1241
|
+
this.handle = handle;
|
|
1242
|
+
this.inputs = inputs;
|
|
1243
|
+
this.context = context;
|
|
1244
|
+
this.trackIndex = trackIndex;
|
|
970
1245
|
}
|
|
971
|
-
|
|
972
|
-
|
|
1246
|
+
async open() {
|
|
1247
|
+
this.input = this.inputs.acquire(this.assetId, this.handle);
|
|
1248
|
+
const track = (await this.input.getAudioTracks())[this.trackIndex] ?? await this.input.getPrimaryAudioTrack();
|
|
1249
|
+
if (!track) return {
|
|
1250
|
+
sampleRate: 0,
|
|
1251
|
+
channels: 0,
|
|
1252
|
+
duration: 0,
|
|
1253
|
+
decodable: false
|
|
1254
|
+
};
|
|
1255
|
+
const [decodable, sampleRate, channels, duration] = await Promise.all([
|
|
1256
|
+
track.canDecode(),
|
|
1257
|
+
track.getSampleRate(),
|
|
1258
|
+
track.getNumberOfChannels(),
|
|
1259
|
+
track.computeDuration()
|
|
1260
|
+
]);
|
|
1261
|
+
this.sampleRate = sampleRate;
|
|
1262
|
+
this.channels = channels;
|
|
1263
|
+
this.sink = new AudioBufferSink(track);
|
|
1264
|
+
return {
|
|
1265
|
+
sampleRate,
|
|
1266
|
+
channels,
|
|
1267
|
+
duration,
|
|
1268
|
+
decodable
|
|
1269
|
+
};
|
|
973
1270
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1271
|
+
async range(from, to) {
|
|
1272
|
+
const sr = this.sampleRate;
|
|
1273
|
+
const length = Math.max(1, Math.ceil((to - from) * sr));
|
|
1274
|
+
const out = this.context.createBuffer(Math.max(1, this.channels), length, sr);
|
|
1275
|
+
if (!this.sink) return out;
|
|
1276
|
+
for await (const { buffer, timestamp } of this.sink.buffers(Math.max(0, from), to)) {
|
|
1277
|
+
const offset = Math.round((timestamp - from) * sr);
|
|
1278
|
+
const srcStart = Math.max(0, -offset);
|
|
1279
|
+
const dstStart = Math.max(0, offset);
|
|
1280
|
+
const n = Math.min(buffer.length - srcStart, length - dstStart);
|
|
1281
|
+
if (n <= 0) continue;
|
|
1282
|
+
for (let ch = 0; ch < out.numberOfChannels; ch++) {
|
|
1283
|
+
const data = buffer.getChannelData(Math.min(ch, buffer.numberOfChannels - 1));
|
|
1284
|
+
out.copyToChannel(data.subarray(srcStart, srcStart + n), ch, dstStart);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return out;
|
|
1288
|
+
}
|
|
1289
|
+
close() {
|
|
1290
|
+
this.sink = null;
|
|
1291
|
+
if (this.input) this.inputs.release(this.assetId);
|
|
1292
|
+
this.input = null;
|
|
977
1293
|
}
|
|
978
1294
|
};
|
|
1295
|
+
function mediabunnyAudioSourceFactory(inputs) {
|
|
1296
|
+
return (assetId, handle, context) => new MediabunnyAudioSource(assetId, handle, inputs, context);
|
|
1297
|
+
}
|
|
979
1298
|
// src/media/mediabunny-frame-source.ts
|
|
980
1299
|
/** A decoded `VideoSample`: GPU-backed, drawn straight to the context, closed when consumed. */
|
|
981
1300
|
var SampleFrame = class {
|
|
@@ -1010,20 +1329,18 @@ var MediabunnyFrameSource = class {
|
|
|
1010
1329
|
handle;
|
|
1011
1330
|
inputs;
|
|
1012
1331
|
createCanvas;
|
|
1013
|
-
hooks;
|
|
1014
1332
|
input = null;
|
|
1015
1333
|
track = null;
|
|
1016
1334
|
sink = null;
|
|
1017
1335
|
stillSink = null;
|
|
1018
|
-
constructor(assetId, handle, inputs, createCanvas
|
|
1336
|
+
constructor(assetId, handle, inputs, createCanvas) {
|
|
1019
1337
|
this.assetId = assetId;
|
|
1020
1338
|
this.handle = handle;
|
|
1021
1339
|
this.inputs = inputs;
|
|
1022
1340
|
this.createCanvas = createCanvas;
|
|
1023
|
-
this.hooks = hooks;
|
|
1024
1341
|
}
|
|
1025
1342
|
wrap(sample) {
|
|
1026
|
-
return
|
|
1343
|
+
return new SampleFrame(sample, this.createCanvas);
|
|
1027
1344
|
}
|
|
1028
1345
|
async open() {
|
|
1029
1346
|
this.input = this.inputs.acquire(this.assetId, this.handle);
|
|
@@ -1042,7 +1359,7 @@ var MediabunnyFrameSource = class {
|
|
|
1042
1359
|
]);
|
|
1043
1360
|
this.track = track;
|
|
1044
1361
|
this.sink = new VideoSampleSink(track, {
|
|
1045
|
-
hardwareAcceleration:
|
|
1362
|
+
hardwareAcceleration: "no-preference",
|
|
1046
1363
|
optimizeForLatency: true
|
|
1047
1364
|
});
|
|
1048
1365
|
return {
|
|
@@ -1090,86 +1407,969 @@ var MediabunnyFrameSource = class {
|
|
|
1090
1407
|
}
|
|
1091
1408
|
};
|
|
1092
1409
|
async function loadImage(handle) {
|
|
1093
|
-
if (handle.kind === "path") throw new Error(
|
|
1410
|
+
if (handle.kind === "path") throw new Error(`a browser cannot read the path ${handle.path}; give it a URL`);
|
|
1094
1411
|
const blob = handle.kind === "blob" ? handle.blob : await (await fetch(handle.url)).blob();
|
|
1095
1412
|
return createImageBitmap(blob);
|
|
1096
1413
|
}
|
|
1097
|
-
function mediabunnyFrameSourceFactory(inputs, createCanvas
|
|
1098
|
-
return (assetId, handle, kind) => kind === "image" ? new ImageFrameSource(() =>
|
|
1414
|
+
function mediabunnyFrameSourceFactory(inputs, createCanvas) {
|
|
1415
|
+
return (assetId, handle, kind) => kind === "image" ? new ImageFrameSource(() => loadImage(handle)) : new MediabunnyFrameSource(assetId, handle, inputs, createCanvas);
|
|
1099
1416
|
}
|
|
1100
|
-
// src/media/mediabunny-
|
|
1101
|
-
/**
|
|
1102
|
-
var
|
|
1103
|
-
|
|
1104
|
-
handle
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
this.context = context;
|
|
1118
|
-
this.trackIndex = trackIndex;
|
|
1119
|
-
}
|
|
1120
|
-
async open() {
|
|
1121
|
-
this.input = this.inputs.acquire(this.assetId, this.handle);
|
|
1122
|
-
const track = (await this.input.getAudioTracks())[this.trackIndex] ?? await this.input.getPrimaryAudioTrack();
|
|
1123
|
-
if (!track) return {
|
|
1124
|
-
sampleRate: 0,
|
|
1125
|
-
channels: 0,
|
|
1126
|
-
duration: 0,
|
|
1127
|
-
decodable: false
|
|
1128
|
-
};
|
|
1129
|
-
const [decodable, sampleRate, channels, duration] = await Promise.all([
|
|
1130
|
-
track.canDecode(),
|
|
1131
|
-
track.getSampleRate(),
|
|
1132
|
-
track.getNumberOfChannels(),
|
|
1133
|
-
track.computeDuration()
|
|
1134
|
-
]);
|
|
1135
|
-
this.track = track;
|
|
1136
|
-
this.sampleRate = sampleRate;
|
|
1137
|
-
this.channels = channels;
|
|
1138
|
-
this.sink = new AudioBufferSink(track);
|
|
1139
|
-
return {
|
|
1140
|
-
sampleRate,
|
|
1141
|
-
channels,
|
|
1142
|
-
duration,
|
|
1143
|
-
decodable
|
|
1144
|
-
};
|
|
1145
|
-
}
|
|
1146
|
-
async range(from, to) {
|
|
1147
|
-
const sr = this.sampleRate;
|
|
1148
|
-
const length = Math.max(1, Math.ceil((to - from) * sr));
|
|
1149
|
-
const out = this.context.createBuffer(Math.max(1, this.channels), length, sr);
|
|
1150
|
-
if (!this.sink) return out;
|
|
1151
|
-
for await (const { buffer, timestamp } of this.sink.buffers(Math.max(0, from), to)) {
|
|
1152
|
-
const offset = Math.round((timestamp - from) * sr);
|
|
1153
|
-
const srcStart = Math.max(0, -offset);
|
|
1154
|
-
const dstStart = Math.max(0, offset);
|
|
1155
|
-
const n = Math.min(buffer.length - srcStart, length - dstStart);
|
|
1156
|
-
if (n <= 0) continue;
|
|
1157
|
-
for (let ch = 0; ch < out.numberOfChannels; ch++) {
|
|
1158
|
-
const data = buffer.getChannelData(Math.min(ch, buffer.numberOfChannels - 1));
|
|
1159
|
-
out.copyToChannel(data.subarray(srcStart, srcStart + n), ch, dstStart);
|
|
1160
|
-
}
|
|
1417
|
+
// src/media/mediabunny-input.ts
|
|
1418
|
+
/** One mediabunny `Input` per asset, shared by the video and audio sources of that asset. */
|
|
1419
|
+
var MediabunnyInputs = class {
|
|
1420
|
+
entries = /* @__PURE__ */ new Map();
|
|
1421
|
+
acquire(assetId, handle) {
|
|
1422
|
+
let e = this.entries.get(assetId);
|
|
1423
|
+
if (!e) {
|
|
1424
|
+
if (handle.kind === "path") throw new Error(`a browser cannot read the path ${handle.path}; give it a URL`);
|
|
1425
|
+
const source = handle.kind === "blob" ? new BlobSource(handle.blob) : new UrlSource(handle.url);
|
|
1426
|
+
e = {
|
|
1427
|
+
input: new Input({
|
|
1428
|
+
source,
|
|
1429
|
+
formats: ALL_FORMATS
|
|
1430
|
+
}),
|
|
1431
|
+
refs: 0
|
|
1432
|
+
};
|
|
1433
|
+
this.entries.set(assetId, e);
|
|
1161
1434
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1435
|
+
e.refs++;
|
|
1436
|
+
return e.input;
|
|
1437
|
+
}
|
|
1438
|
+
release(assetId) {
|
|
1439
|
+
const e = this.entries.get(assetId);
|
|
1440
|
+
if (!e) return;
|
|
1441
|
+
e.refs--;
|
|
1442
|
+
if (e.refs <= 0) {
|
|
1443
|
+
e.input.dispose();
|
|
1444
|
+
this.entries.delete(assetId);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
get openCount() {
|
|
1448
|
+
return this.entries.size;
|
|
1449
|
+
}
|
|
1450
|
+
dispose() {
|
|
1451
|
+
for (const e of this.entries.values()) e.input.dispose();
|
|
1452
|
+
this.entries.clear();
|
|
1169
1453
|
}
|
|
1170
1454
|
};
|
|
1171
|
-
|
|
1172
|
-
|
|
1455
|
+
// src/media/resolver.ts
|
|
1456
|
+
/** A resolver over a map of asset id → Blob or URL. */
|
|
1457
|
+
function fromMap(map) {
|
|
1458
|
+
return { resolve: (assetId) => {
|
|
1459
|
+
const v = map[assetId];
|
|
1460
|
+
if (v === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no media for asset "${assetId}"`));
|
|
1461
|
+
return Promise.resolve(typeof v === "string" ? {
|
|
1462
|
+
kind: "url",
|
|
1463
|
+
url: v
|
|
1464
|
+
} : {
|
|
1465
|
+
kind: "blob",
|
|
1466
|
+
blob: v
|
|
1467
|
+
});
|
|
1468
|
+
} };
|
|
1469
|
+
}
|
|
1470
|
+
function toResolver(media) {
|
|
1471
|
+
return typeof media.resolve === "function" ? media : fromMap(media);
|
|
1472
|
+
}
|
|
1473
|
+
// src/model/backdrop.ts
|
|
1474
|
+
/** Kinds the overlay plane draws (as DOM, in the preview and the export) rather than the canvas. */
|
|
1475
|
+
const OVERLAY_KINDS = /* @__PURE__ */ new Set([
|
|
1476
|
+
"title",
|
|
1477
|
+
"shape",
|
|
1478
|
+
"svg",
|
|
1479
|
+
"sticker",
|
|
1480
|
+
"graphic",
|
|
1481
|
+
"layer",
|
|
1482
|
+
"adjustment"
|
|
1483
|
+
]);
|
|
1484
|
+
/** Kinds that put footage on the canvas. */
|
|
1485
|
+
const FOOTAGE_KINDS = /* @__PURE__ */ new Set([
|
|
1486
|
+
"clip",
|
|
1487
|
+
"nested",
|
|
1488
|
+
"multicam"
|
|
1489
|
+
]);
|
|
1490
|
+
const memo = /* @__PURE__ */ new WeakMap();
|
|
1491
|
+
/**
|
|
1492
|
+
* The tracks whose overlays draw beneath the footage: those below every
|
|
1493
|
+
* visible track that holds footage, when one of them holds an overlay (a
|
|
1494
|
+
* background graphic under the cut, say). Empty otherwise, and then every
|
|
1495
|
+
* overlay draws above the footage as before. When it is not empty, the canvas
|
|
1496
|
+
* holds only the footage over a transparent ground, and the composition
|
|
1497
|
+
* background is painted beneath the backdrop instead.
|
|
1498
|
+
*/
|
|
1499
|
+
function backdropTracks(state) {
|
|
1500
|
+
const hit = memo.get(state);
|
|
1501
|
+
if (hit) return hit;
|
|
1502
|
+
const footage = state.tracks.filter((t) => !t.hidden && t.items.some((id) => FOOTAGE_KINDS.has(state.items.get(id)?.kind ?? "")));
|
|
1503
|
+
const lowest = footage.length ? Math.min(...footage.map((t) => t.order)) : -1;
|
|
1504
|
+
const drawn = (id) => {
|
|
1505
|
+
const k = state.items.get(id)?.kind ?? "";
|
|
1506
|
+
return k !== "adjustment" && OVERLAY_KINDS.has(k);
|
|
1507
|
+
};
|
|
1508
|
+
const below = state.tracks.filter((t) => t.order < lowest && !t.hidden && t.items.some(drawn));
|
|
1509
|
+
const set = new Set(below.map((t) => t.id));
|
|
1510
|
+
memo.set(state, set);
|
|
1511
|
+
return set;
|
|
1512
|
+
}
|
|
1513
|
+
// src/model/effects.ts
|
|
1514
|
+
function num(params, keys, fallback) {
|
|
1515
|
+
for (const k of keys) {
|
|
1516
|
+
const v = params[k];
|
|
1517
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
1518
|
+
}
|
|
1519
|
+
return fallback;
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Levels as `brightness(k) contrast(c)`: out = (in − black) / (white − black), with c = 1 + 2·black·s
|
|
1523
|
+
* and k = s/c for s = 1/(white − black). Gamma has no filter form; the lint reports it.
|
|
1524
|
+
*/
|
|
1525
|
+
function levels(p) {
|
|
1526
|
+
const black = Math.max(0, Math.min(1, num(p, ["black"], 0))), white = Math.max(0, Math.min(1, num(p, ["white"], 1)));
|
|
1527
|
+
if (white <= black) return "";
|
|
1528
|
+
const s = 1 / (white - black), c = 1 + 2 * black * s;
|
|
1529
|
+
return `brightness(${s / c}) contrast(${c})`;
|
|
1530
|
+
}
|
|
1531
|
+
/** Effects the painter draws over the element after it (`postEffects`), not as filters. */
|
|
1532
|
+
const POST_TYPES = ["vignette", "grain"];
|
|
1533
|
+
/** One filter per effect type (kit's VIDEO_EFFECT_TYPES), less the post effects. */
|
|
1534
|
+
const CANVAS_FILTERABLE = {
|
|
1535
|
+
brightness: (p) => `brightness(${1 + num(p, ["amount", "value"], 0) / 100})`,
|
|
1536
|
+
contrast: (p) => `contrast(${1 + num(p, ["amount", "value"], 0) / 100})`,
|
|
1537
|
+
saturation: (p) => `saturate(${1 + num(p, ["amount", "value"], 0) / 100})`,
|
|
1538
|
+
hue: (p) => `hue-rotate(${num(p, [
|
|
1539
|
+
"angle",
|
|
1540
|
+
"amount",
|
|
1541
|
+
"value"
|
|
1542
|
+
], 0)}deg)`,
|
|
1543
|
+
blur: (p) => `blur(${num(p, [
|
|
1544
|
+
"radius",
|
|
1545
|
+
"amount",
|
|
1546
|
+
"value"
|
|
1547
|
+
], 4)}px)`,
|
|
1548
|
+
grayscale: (p) => `grayscale(${num(p, ["amount", "value"], 100) / 100})`,
|
|
1549
|
+
sepia: (p) => `sepia(${num(p, ["amount", "value"], 100) / 100})`,
|
|
1550
|
+
invert: (p) => `invert(${num(p, ["amount", "value"], 100) / 100})`,
|
|
1551
|
+
temperature: (p) => colorGradeFilters({ temperature: num(p, ["amount", "value"], 0) }).join(" "),
|
|
1552
|
+
tint: (p) => colorGradeFilters({ tint: num(p, ["amount", "value"], 0) }).join(" "),
|
|
1553
|
+
levels,
|
|
1554
|
+
shadow: (p) => `drop-shadow(${num(p, ["x"], 0)}px ${num(p, ["y"], 4)}px ${num(p, ["blur"], 8)}px ${typeof p.color === "string" ? p.color : "rgba(0,0,0,0.5)"})`
|
|
1555
|
+
};
|
|
1556
|
+
/** An effect's params with its keyframed ones (`effect.<id>.<param>` keyframes on the element) evaluated at element-relative `rel`. */
|
|
1557
|
+
function paramsAt(children, effect, rel) {
|
|
1558
|
+
const base = effect.props.params ?? {};
|
|
1559
|
+
const prefix = `effect.${effect.id}.`;
|
|
1560
|
+
let out = null;
|
|
1561
|
+
for (const k of children) {
|
|
1562
|
+
const property = k.kind === "keyframe" ? String(k.props.property) : "";
|
|
1563
|
+
if (!property.startsWith(prefix)) continue;
|
|
1564
|
+
const name = property.slice(prefix.length);
|
|
1565
|
+
out ??= { ...base };
|
|
1566
|
+
out[name] = keyframedValue(children, property, rel, base[name]);
|
|
1567
|
+
}
|
|
1568
|
+
return out ?? base;
|
|
1569
|
+
}
|
|
1570
|
+
/** A single canvas/CSS `filter` string for an element's effect and colour-grade children at element-relative `rel`; unsupported types are listed. */
|
|
1571
|
+
function filterString(children, elementId, rel, declared = /* @__PURE__ */ new Set()) {
|
|
1572
|
+
const parts = [];
|
|
1573
|
+
const unsupported = [];
|
|
1574
|
+
for (const c of children) if (c.kind === "effect") {
|
|
1575
|
+
if (c.props.enabled === false) continue;
|
|
1576
|
+
const type = String(c.props.type);
|
|
1577
|
+
if (POST_TYPES.includes(type) || declared.has(type)) continue;
|
|
1578
|
+
const make = CANVAS_FILTERABLE[type];
|
|
1579
|
+
if (make) {
|
|
1580
|
+
const f = make(paramsAt(children, c, rel));
|
|
1581
|
+
if (f) parts.push(f);
|
|
1582
|
+
} else {
|
|
1583
|
+
unsupported.push(type);
|
|
1584
|
+
devWarn(`effect:${elementId}:${type}`, `effect "${type}" on "${elementId}" is not rendered by the browser player`);
|
|
1585
|
+
}
|
|
1586
|
+
} else if (c.kind === "colorGrade") parts.push(...colorGradeFilters({
|
|
1587
|
+
temperature: keyframedValue(children, "colorGrade.temperature", rel, c.props.temperature),
|
|
1588
|
+
tint: keyframedValue(children, "colorGrade.tint", rel, c.props.tint)
|
|
1589
|
+
}));
|
|
1590
|
+
return {
|
|
1591
|
+
filter: parts.join(" "),
|
|
1592
|
+
unsupported
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
/** Vignette and grain, in order, with `amount` on 0..1; grain is seeded by the frame, so a frame always looks the same. */
|
|
1596
|
+
function postEffects(children, frame, rel) {
|
|
1597
|
+
const out = [];
|
|
1598
|
+
for (const c of children) {
|
|
1599
|
+
if (c.kind !== "effect" || c.props.enabled === false) continue;
|
|
1600
|
+
const amount = Math.max(0, Math.min(1, num(paramsAt(children, c, rel), [
|
|
1601
|
+
"amount",
|
|
1602
|
+
"intensity",
|
|
1603
|
+
"value"
|
|
1604
|
+
], c.props.type === "grain" ? 20 : 50) / 100));
|
|
1605
|
+
if (amount <= 0) continue;
|
|
1606
|
+
if (c.props.type === "vignette") out.push({
|
|
1607
|
+
type: "vignette",
|
|
1608
|
+
amount
|
|
1609
|
+
});
|
|
1610
|
+
else if (c.props.type === "grain") out.push({
|
|
1611
|
+
type: "grain",
|
|
1612
|
+
amount,
|
|
1613
|
+
seed: frame
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
return out.length ? out : void 0;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Temperature and tint approximated with CSS filters: warm pulls toward
|
|
1620
|
+
* sepia, cool toward a slight hue rotation; tint rotates hue. Wheels, curves,
|
|
1621
|
+
* LUTs and HSL bands are not rendered by the browser player.
|
|
1622
|
+
*/
|
|
1623
|
+
function colorGradeFilters(props) {
|
|
1624
|
+
const out = [];
|
|
1625
|
+
const temperature = typeof props.temperature === "number" ? props.temperature : 0;
|
|
1626
|
+
const tint = typeof props.tint === "number" ? props.tint : 0;
|
|
1627
|
+
if (temperature > 0) out.push(`sepia(${temperature / 100 * .5})`);
|
|
1628
|
+
else if (temperature < 0) out.push(`hue-rotate(${-temperature / 10}deg) saturate(${1 + -temperature / 200})`);
|
|
1629
|
+
if (tint !== 0) out.push(`hue-rotate(${tint / 4}deg)`);
|
|
1630
|
+
return out;
|
|
1631
|
+
}
|
|
1632
|
+
// src/model/shaders.ts
|
|
1633
|
+
/** The element's declared effects, in file order, with keyframed params evaluated at element-relative `rel`. */
|
|
1634
|
+
function effectPasses(state, children, rel) {
|
|
1635
|
+
if (state.shaders.length === 0) return void 0;
|
|
1636
|
+
const out = [];
|
|
1637
|
+
for (const c of children) {
|
|
1638
|
+
if (c.kind !== "effect" || c.props.enabled === false) continue;
|
|
1639
|
+
const def = shaderFor(state.shaders, "effect", c.props.type);
|
|
1640
|
+
if (def) out.push({
|
|
1641
|
+
name: def.name,
|
|
1642
|
+
source: def.shader,
|
|
1643
|
+
params: paramsAt(children, c, rel),
|
|
1644
|
+
time: rel
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
return out.length > 0 ? out : void 0;
|
|
1648
|
+
}
|
|
1649
|
+
/** The side of a declared transition this clip plays, or undefined for a built-in transition. */
|
|
1650
|
+
function transitionPass(state, role) {
|
|
1651
|
+
const x = role.placement;
|
|
1652
|
+
const def = shaderFor(state.shaders, "transition", x.props.type);
|
|
1653
|
+
if (!def) return void 0;
|
|
1654
|
+
return {
|
|
1655
|
+
id: x.id,
|
|
1656
|
+
name: def.name,
|
|
1657
|
+
source: def.shader,
|
|
1658
|
+
params: x.props.params ?? {},
|
|
1659
|
+
progress: role.progress,
|
|
1660
|
+
time: role.progress * x.duration,
|
|
1661
|
+
role: role.role,
|
|
1662
|
+
paired: x.between !== void 0
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
/** The effect types the file declares, which the filter string leaves to the shader passes. */
|
|
1666
|
+
function declaredEffects(state) {
|
|
1667
|
+
return new Set(state.shaders.filter((s) => s.kind === "effect").map((s) => s.name));
|
|
1668
|
+
}
|
|
1669
|
+
// src/model/transform.ts
|
|
1670
|
+
const IDENTITY = {
|
|
1671
|
+
a: 1,
|
|
1672
|
+
b: 0,
|
|
1673
|
+
c: 0,
|
|
1674
|
+
d: 1,
|
|
1675
|
+
e: 0,
|
|
1676
|
+
f: 0
|
|
1677
|
+
};
|
|
1678
|
+
function multiply(m, n) {
|
|
1679
|
+
return {
|
|
1680
|
+
a: m.a * n.a + m.c * n.b,
|
|
1681
|
+
b: m.b * n.a + m.d * n.b,
|
|
1682
|
+
c: m.a * n.c + m.c * n.d,
|
|
1683
|
+
d: m.b * n.c + m.d * n.d,
|
|
1684
|
+
e: m.a * n.e + m.c * n.f + m.e,
|
|
1685
|
+
f: m.b * n.e + m.d * n.f + m.f
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
const translate = (x, y) => ({
|
|
1689
|
+
...IDENTITY,
|
|
1690
|
+
e: x,
|
|
1691
|
+
f: y
|
|
1692
|
+
});
|
|
1693
|
+
const scaleM = (sx, sy) => ({
|
|
1694
|
+
...IDENTITY,
|
|
1695
|
+
a: sx,
|
|
1696
|
+
d: sy
|
|
1697
|
+
});
|
|
1698
|
+
function rotate(deg) {
|
|
1699
|
+
const r = deg * Math.PI / 180;
|
|
1700
|
+
const cos = Math.cos(r), sin = Math.sin(r);
|
|
1701
|
+
return {
|
|
1702
|
+
a: cos,
|
|
1703
|
+
b: sin,
|
|
1704
|
+
c: -sin,
|
|
1705
|
+
d: cos,
|
|
1706
|
+
e: 0,
|
|
1707
|
+
f: 0
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
function matrixToCss(m) {
|
|
1711
|
+
const n = (v) => Math.abs(v) < 1e-9 ? "0" : String(Math.round(v * 1e6) / 1e6);
|
|
1712
|
+
return `matrix(${n(m.a)}, ${n(m.b)}, ${n(m.c)}, ${n(m.d)}, ${n(m.e)}, ${n(m.f)})`;
|
|
1713
|
+
}
|
|
1714
|
+
/** The fitted rectangle of a source inside the composition, centred. */
|
|
1715
|
+
function fitRect(comp, src, fit) {
|
|
1716
|
+
const W = comp.width, H = comp.height;
|
|
1717
|
+
const w = src.width > 0 ? src.width : W, h = src.height > 0 ? src.height : H;
|
|
1718
|
+
if (fit === "stretch") return {
|
|
1719
|
+
x: 0,
|
|
1720
|
+
y: 0,
|
|
1721
|
+
w: W,
|
|
1722
|
+
h: H
|
|
1723
|
+
};
|
|
1724
|
+
let s = 1;
|
|
1725
|
+
if (fit === "contain") s = Math.min(W / w, H / h);
|
|
1726
|
+
else if (fit === "cover") s = Math.max(W / w, H / h);
|
|
1727
|
+
const dw = w * s, dh = h * s;
|
|
1728
|
+
return {
|
|
1729
|
+
x: (W - dw) / 2,
|
|
1730
|
+
y: (H - dh) / 2,
|
|
1731
|
+
w: dw,
|
|
1732
|
+
h: dh
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Lay out a source of `src` pixels in the composition: crop, fit, then the
|
|
1737
|
+
* element transform (position offset, scale, rotation about the anchor).
|
|
1738
|
+
*/
|
|
1739
|
+
function layoutRect(comp, src, fit, transform) {
|
|
1740
|
+
const t = transform ?? {};
|
|
1741
|
+
let crop;
|
|
1742
|
+
let effective = src;
|
|
1743
|
+
if (t.crop && t.crop.width > 0 && t.crop.height > 0) {
|
|
1744
|
+
crop = {
|
|
1745
|
+
x: t.crop.x * src.width,
|
|
1746
|
+
y: t.crop.y * src.height,
|
|
1747
|
+
w: t.crop.width * src.width,
|
|
1748
|
+
h: t.crop.height * src.height
|
|
1749
|
+
};
|
|
1750
|
+
effective = {
|
|
1751
|
+
width: crop.w,
|
|
1752
|
+
height: crop.h
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
const rect = fitRect(comp, effective, fit);
|
|
1756
|
+
return {
|
|
1757
|
+
rect,
|
|
1758
|
+
matrix: transformMatrix(rect, t),
|
|
1759
|
+
...crop ? { crop } : {},
|
|
1760
|
+
opacity: t.opacity ?? 1
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
/** The matrix mapping an element's local pixels to the composition, given its untransformed rect. */
|
|
1764
|
+
function transformMatrix(rect, t) {
|
|
1765
|
+
const tr = t ?? {};
|
|
1766
|
+
const anchor = tr.anchor ?? {
|
|
1767
|
+
x: .5,
|
|
1768
|
+
y: .5
|
|
1769
|
+
};
|
|
1770
|
+
const ox = rect.x + anchor.x * rect.w, oy = rect.y + anchor.y * rect.h;
|
|
1771
|
+
const sx = typeof tr.scale === "number" ? tr.scale : tr.scale?.x ?? 1;
|
|
1772
|
+
const sy = typeof tr.scale === "number" ? tr.scale : tr.scale?.y ?? 1;
|
|
1773
|
+
const px = tr.position?.x ?? 0, py = tr.position?.y ?? 0;
|
|
1774
|
+
let m = translate(px + ox, py + oy);
|
|
1775
|
+
if (tr.rotation) m = multiply(m, rotate(tr.rotation));
|
|
1776
|
+
if (sx !== 1 || sy !== 1) m = multiply(m, scaleM(sx, sy));
|
|
1777
|
+
m = multiply(m, translate(-ox, -oy));
|
|
1778
|
+
return multiply(m, translate(rect.x, rect.y));
|
|
1779
|
+
}
|
|
1780
|
+
/** The upright box around `rect` after `m`: where a rectangle of an element's local pixels lands in the composition. */
|
|
1781
|
+
function boundsOf(rect, m) {
|
|
1782
|
+
const xs = [
|
|
1783
|
+
rect.x,
|
|
1784
|
+
rect.x + rect.w,
|
|
1785
|
+
rect.x,
|
|
1786
|
+
rect.x + rect.w
|
|
1787
|
+
], ys = [
|
|
1788
|
+
rect.y,
|
|
1789
|
+
rect.y,
|
|
1790
|
+
rect.y + rect.h,
|
|
1791
|
+
rect.y + rect.h
|
|
1792
|
+
];
|
|
1793
|
+
const px = xs.map((x, i) => m.a * x + m.c * ys[i] + m.e), py = xs.map((x, i) => m.b * x + m.d * ys[i] + m.f);
|
|
1794
|
+
const x = Math.min(...px), y = Math.min(...py);
|
|
1795
|
+
return {
|
|
1796
|
+
x,
|
|
1797
|
+
y,
|
|
1798
|
+
w: Math.max(...px) - x,
|
|
1799
|
+
h: Math.max(...py) - y
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/** The transform with its keyframed properties evaluated at element-relative time `rel`. */
|
|
1803
|
+
function animatedTransform(base, children, rel) {
|
|
1804
|
+
if (!children.some((c) => c.kind === "keyframe")) return base ?? {};
|
|
1805
|
+
const t = { ...base ?? {} };
|
|
1806
|
+
const num = (prop, fallback) => Number(keyframedValue(children, prop, rel, fallback));
|
|
1807
|
+
const pos = base?.position ?? {
|
|
1808
|
+
x: 0,
|
|
1809
|
+
y: 0
|
|
1810
|
+
};
|
|
1811
|
+
const scl = typeof base?.scale === "number" ? {
|
|
1812
|
+
x: base.scale,
|
|
1813
|
+
y: base.scale
|
|
1814
|
+
} : base?.scale ?? {
|
|
1815
|
+
x: 1,
|
|
1816
|
+
y: 1
|
|
1817
|
+
};
|
|
1818
|
+
const anc = base?.anchor ?? {
|
|
1819
|
+
x: .5,
|
|
1820
|
+
y: .5
|
|
1821
|
+
};
|
|
1822
|
+
const crop = base?.crop ?? {
|
|
1823
|
+
x: 0,
|
|
1824
|
+
y: 0,
|
|
1825
|
+
width: 1,
|
|
1826
|
+
height: 1
|
|
1827
|
+
};
|
|
1828
|
+
t.position = {
|
|
1829
|
+
x: num("transform.position.x", pos.x),
|
|
1830
|
+
y: num("transform.position.y", pos.y)
|
|
1831
|
+
};
|
|
1832
|
+
t.scale = {
|
|
1833
|
+
x: num("transform.scale.x", scl.x),
|
|
1834
|
+
y: num("transform.scale.y", scl.y)
|
|
1835
|
+
};
|
|
1836
|
+
t.rotation = num("transform.rotation", base?.rotation ?? 0);
|
|
1837
|
+
t.opacity = num("transform.opacity", base?.opacity ?? 1);
|
|
1838
|
+
t.anchor = {
|
|
1839
|
+
x: num("transform.anchor.x", anc.x),
|
|
1840
|
+
y: num("transform.anchor.y", anc.y)
|
|
1841
|
+
};
|
|
1842
|
+
t.borderRadius = num("transform.borderRadius", base?.borderRadius ?? 0);
|
|
1843
|
+
if (base?.crop || [
|
|
1844
|
+
"x",
|
|
1845
|
+
"y",
|
|
1846
|
+
"width",
|
|
1847
|
+
"height"
|
|
1848
|
+
].some((k) => children.some((c) => c.kind === "keyframe" && c.props.property === `transform.crop.${k}`))) t.crop = {
|
|
1849
|
+
x: num("transform.crop.x", crop.x),
|
|
1850
|
+
y: num("transform.crop.y", crop.y),
|
|
1851
|
+
width: num("transform.crop.width", crop.width),
|
|
1852
|
+
height: num("transform.crop.height", crop.height)
|
|
1853
|
+
};
|
|
1854
|
+
return t;
|
|
1855
|
+
}
|
|
1856
|
+
/** CSS for a DOM element laid out like `layoutRect` does: it sits at the origin with `transform-origin: 0 0`. */
|
|
1857
|
+
function cssTransform(layout) {
|
|
1858
|
+
return matrixToCss(layout.matrix);
|
|
1859
|
+
}
|
|
1860
|
+
// src/model/motion.ts
|
|
1861
|
+
const STILL = {
|
|
1862
|
+
opacity: 1,
|
|
1863
|
+
matrix: IDENTITY,
|
|
1864
|
+
clip: null,
|
|
1865
|
+
draw: null
|
|
1866
|
+
};
|
|
1867
|
+
const DEFAULT_DURATION = .5;
|
|
1868
|
+
/** How far a slide travels: a quarter of the element's size, at most this many pixels (a title's box is the frame). */
|
|
1869
|
+
const SLIDE = .25;
|
|
1870
|
+
const SLIDE_MAX = 160;
|
|
1871
|
+
/**
|
|
1872
|
+
* The entrance and exit of an element `rel` seconds into its `duration`, for a
|
|
1873
|
+
* `width` by `height` box. Both can play at once on a short element; their
|
|
1874
|
+
* effects multiply. Pop, bounce and elastic carry their own curve; the others
|
|
1875
|
+
* follow the animation's easing (default ease-out).
|
|
1876
|
+
*/
|
|
1877
|
+
function motionAt(enter, exit, rel, duration, width, height) {
|
|
1878
|
+
if (!enter && !exit) return STILL;
|
|
1879
|
+
let m = STILL;
|
|
1880
|
+
if (enter && enter.type !== "none") {
|
|
1881
|
+
const len = enter.duration ?? DEFAULT_DURATION;
|
|
1882
|
+
m = combine(m, effect(enter, len > 0 ? rel / len : 1, width, height));
|
|
1883
|
+
}
|
|
1884
|
+
if (exit && exit.type !== "none") {
|
|
1885
|
+
const len = exit.duration ?? DEFAULT_DURATION;
|
|
1886
|
+
m = combine(m, effect(exit, len > 0 ? (duration - rel) / len : 1, width, height));
|
|
1887
|
+
}
|
|
1888
|
+
return m;
|
|
1889
|
+
}
|
|
1890
|
+
/** One animation at raw progress `x` (0 hidden, 1 fully shown). */
|
|
1891
|
+
function effect(anim, x, w, h) {
|
|
1892
|
+
const raw = Math.max(0, Math.min(1, x));
|
|
1893
|
+
if (raw >= 1) return STILL;
|
|
1894
|
+
const k = ease(anim.easing ?? "ease-out", raw);
|
|
1895
|
+
const about = (m) => multiply(multiply(translate(w / 2, h / 2), m), translate(-w / 2, -h / 2));
|
|
1896
|
+
const fadeIn = Math.min(1, raw * 3);
|
|
1897
|
+
switch (anim.type) {
|
|
1898
|
+
case "fade": return {
|
|
1899
|
+
...STILL,
|
|
1900
|
+
opacity: k
|
|
1901
|
+
};
|
|
1902
|
+
case "slide-left": return {
|
|
1903
|
+
...STILL,
|
|
1904
|
+
opacity: k,
|
|
1905
|
+
matrix: translate(Math.min(w * SLIDE, SLIDE_MAX) * (1 - k), 0)
|
|
1906
|
+
};
|
|
1907
|
+
case "slide-right": return {
|
|
1908
|
+
...STILL,
|
|
1909
|
+
opacity: k,
|
|
1910
|
+
matrix: translate(-Math.min(w * SLIDE, SLIDE_MAX) * (1 - k), 0)
|
|
1911
|
+
};
|
|
1912
|
+
case "slide-up": return {
|
|
1913
|
+
...STILL,
|
|
1914
|
+
opacity: k,
|
|
1915
|
+
matrix: translate(0, Math.min(h * SLIDE, SLIDE_MAX) * (1 - k))
|
|
1916
|
+
};
|
|
1917
|
+
case "slide-down": return {
|
|
1918
|
+
...STILL,
|
|
1919
|
+
opacity: k,
|
|
1920
|
+
matrix: translate(0, -Math.min(h * SLIDE, SLIDE_MAX) * (1 - k))
|
|
1921
|
+
};
|
|
1922
|
+
case "scale": return {
|
|
1923
|
+
...STILL,
|
|
1924
|
+
opacity: k,
|
|
1925
|
+
matrix: about(scaleM(.6 + .4 * k, .6 + .4 * k))
|
|
1926
|
+
};
|
|
1927
|
+
case "rotate": return {
|
|
1928
|
+
...STILL,
|
|
1929
|
+
opacity: k,
|
|
1930
|
+
matrix: about(multiply(rotate(-90 * (1 - k)), scaleM(.8 + .2 * k, .8 + .2 * k)))
|
|
1931
|
+
};
|
|
1932
|
+
case "pop": {
|
|
1933
|
+
const s = ease("easeOutBack", raw);
|
|
1934
|
+
return {
|
|
1935
|
+
...STILL,
|
|
1936
|
+
opacity: fadeIn,
|
|
1937
|
+
matrix: about(scaleM(s, s))
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
case "bounce": {
|
|
1941
|
+
const s = ease("easeOutBounce", raw);
|
|
1942
|
+
return {
|
|
1943
|
+
...STILL,
|
|
1944
|
+
opacity: fadeIn,
|
|
1945
|
+
matrix: about(scaleM(s, s))
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
case "elastic": {
|
|
1949
|
+
const s = ease("easeOutElastic", raw);
|
|
1950
|
+
return {
|
|
1951
|
+
...STILL,
|
|
1952
|
+
opacity: fadeIn,
|
|
1953
|
+
matrix: about(scaleM(s, s))
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
case "flip-horizontal": return {
|
|
1957
|
+
...STILL,
|
|
1958
|
+
matrix: about(scaleM(Math.max(.001, Math.sin(k * Math.PI / 2)), 1))
|
|
1959
|
+
};
|
|
1960
|
+
case "flip-vertical": return {
|
|
1961
|
+
...STILL,
|
|
1962
|
+
matrix: about(scaleM(1, Math.max(.001, Math.sin(k * Math.PI / 2))))
|
|
1963
|
+
};
|
|
1964
|
+
case "wipe-right": return {
|
|
1965
|
+
...STILL,
|
|
1966
|
+
clip: [{
|
|
1967
|
+
x: 0,
|
|
1968
|
+
y: 0,
|
|
1969
|
+
w: w * k,
|
|
1970
|
+
h
|
|
1971
|
+
}]
|
|
1972
|
+
};
|
|
1973
|
+
case "wipe-left": return {
|
|
1974
|
+
...STILL,
|
|
1975
|
+
clip: [{
|
|
1976
|
+
x: w * (1 - k),
|
|
1977
|
+
y: 0,
|
|
1978
|
+
w: w * k,
|
|
1979
|
+
h
|
|
1980
|
+
}]
|
|
1981
|
+
};
|
|
1982
|
+
case "wipe-down": return {
|
|
1983
|
+
...STILL,
|
|
1984
|
+
clip: [{
|
|
1985
|
+
x: 0,
|
|
1986
|
+
y: 0,
|
|
1987
|
+
w,
|
|
1988
|
+
h: h * k
|
|
1989
|
+
}]
|
|
1990
|
+
};
|
|
1991
|
+
case "wipe-up": return {
|
|
1992
|
+
...STILL,
|
|
1993
|
+
clip: [{
|
|
1994
|
+
x: 0,
|
|
1995
|
+
y: h * (1 - k),
|
|
1996
|
+
w,
|
|
1997
|
+
h: h * k
|
|
1998
|
+
}]
|
|
1999
|
+
};
|
|
2000
|
+
case "reveal-center": return {
|
|
2001
|
+
...STILL,
|
|
2002
|
+
clip: [{
|
|
2003
|
+
x: w * (1 - k) / 2,
|
|
2004
|
+
y: h * (1 - k) / 2,
|
|
2005
|
+
w: w * k,
|
|
2006
|
+
h: h * k
|
|
2007
|
+
}]
|
|
2008
|
+
};
|
|
2009
|
+
case "reveal-edges": return {
|
|
2010
|
+
...STILL,
|
|
2011
|
+
clip: [{
|
|
2012
|
+
x: 0,
|
|
2013
|
+
y: 0,
|
|
2014
|
+
w: w * k / 2,
|
|
2015
|
+
h
|
|
2016
|
+
}, {
|
|
2017
|
+
x: w - w * k / 2,
|
|
2018
|
+
y: 0,
|
|
2019
|
+
w: w * k / 2,
|
|
2020
|
+
h
|
|
2021
|
+
}]
|
|
2022
|
+
};
|
|
2023
|
+
case "draw": return {
|
|
2024
|
+
...STILL,
|
|
2025
|
+
draw: k
|
|
2026
|
+
};
|
|
2027
|
+
default: return {
|
|
2028
|
+
...STILL,
|
|
2029
|
+
opacity: k
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
/** Both effects at once: opacities and draw amounts multiply, matrices compose, clips intersect. */
|
|
2034
|
+
function combine(a, b) {
|
|
2035
|
+
if (b === STILL) return a;
|
|
2036
|
+
if (a === STILL) return b;
|
|
2037
|
+
return {
|
|
2038
|
+
opacity: a.opacity * b.opacity,
|
|
2039
|
+
matrix: multiply(a.matrix, b.matrix),
|
|
2040
|
+
clip: a.clip && b.clip ? intersect(a.clip, b.clip) : a.clip ?? b.clip,
|
|
2041
|
+
draw: a.draw !== null && b.draw !== null ? a.draw * b.draw : a.draw ?? b.draw
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
function intersect(a, b) {
|
|
2045
|
+
const out = [];
|
|
2046
|
+
for (const r of a) for (const s of b) {
|
|
2047
|
+
const x = Math.max(r.x, s.x), y = Math.max(r.y, s.y);
|
|
2048
|
+
const x2 = Math.min(r.x + r.w, s.x + s.w), y2 = Math.min(r.y + r.h, s.y + s.h);
|
|
2049
|
+
if (x2 > x && y2 > y) out.push({
|
|
2050
|
+
x,
|
|
2051
|
+
y,
|
|
2052
|
+
w: x2 - x,
|
|
2053
|
+
h: y2 - y
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
return out;
|
|
2057
|
+
}
|
|
2058
|
+
// src/model/transition.ts
|
|
2059
|
+
const DIRECTION = {
|
|
2060
|
+
left: [-1, 0],
|
|
2061
|
+
right: [1, 0],
|
|
2062
|
+
up: [0, -1],
|
|
2063
|
+
down: [0, 1]
|
|
2064
|
+
};
|
|
2065
|
+
/**
|
|
2066
|
+
* One side of a transition at its eased progress. Between two clips the
|
|
2067
|
+
* incoming one goes over the outgoing one, which stays put unless the
|
|
2068
|
+
* transition moves it; an edge transition plays its side against what is beneath.
|
|
2069
|
+
* Types without a look of their own draw as a crossfade.
|
|
2070
|
+
*/
|
|
2071
|
+
function transitionLook(t, W, H) {
|
|
2072
|
+
const x = t.placement, p = t.progress, role = t.role, between = x.between !== void 0;
|
|
2073
|
+
const type = String(x.props.type ?? "crossfade");
|
|
2074
|
+
const params = x.props.params ?? {};
|
|
2075
|
+
const dir = DIRECTION[String(params.direction)] ?? DIRECTION.left;
|
|
2076
|
+
switch (type) {
|
|
2077
|
+
case "dipToBlack":
|
|
2078
|
+
case "dipToWhite": {
|
|
2079
|
+
if (!between) return {
|
|
2080
|
+
opacity: 1,
|
|
2081
|
+
dip: role === "in" ? 1 - p : p
|
|
2082
|
+
};
|
|
2083
|
+
if (role === "out") return { opacity: 1 };
|
|
2084
|
+
const ramp = (1 - (x.duration > 0 ? Math.max(0, Math.min(1, Number(params.holdDuration ?? .1) / x.duration)) : 1)) / 2;
|
|
2085
|
+
const dip = ramp <= 0 ? 1 : Math.min(1, p / ramp, (1 - p) / ramp);
|
|
2086
|
+
return {
|
|
2087
|
+
opacity: p >= .5 ? 1 : 0,
|
|
2088
|
+
dip
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
case "wipe":
|
|
2092
|
+
if (role === "out" && between) return { opacity: 1 };
|
|
2093
|
+
return {
|
|
2094
|
+
opacity: 1,
|
|
2095
|
+
clip: effect({
|
|
2096
|
+
type: `wipe-${params.direction === "diagonal" || params.direction === void 0 ? "left" : String(params.direction)}`,
|
|
2097
|
+
easing: "linear"
|
|
2098
|
+
}, role === "in" ? p : 1 - p, W, H).clip ?? [{
|
|
2099
|
+
x: 0,
|
|
2100
|
+
y: 0,
|
|
2101
|
+
w: W,
|
|
2102
|
+
h: H
|
|
2103
|
+
}]
|
|
2104
|
+
};
|
|
2105
|
+
case "slide":
|
|
2106
|
+
case "push":
|
|
2107
|
+
if (role === "in") return {
|
|
2108
|
+
opacity: 1,
|
|
2109
|
+
offset: {
|
|
2110
|
+
x: -dir[0] * W * (1 - p),
|
|
2111
|
+
y: -dir[1] * H * (1 - p)
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
if (between && type === "slide" && params.pushOut !== true) return { opacity: 1 };
|
|
2115
|
+
return {
|
|
2116
|
+
opacity: 1,
|
|
2117
|
+
offset: {
|
|
2118
|
+
x: dir[0] * W * p,
|
|
2119
|
+
y: dir[1] * H * p
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
default: return { opacity: role === "in" ? p : between ? 1 : 1 - p };
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
// src/model/frame-plan.ts
|
|
2126
|
+
const BLEND_TO_CANVAS = {
|
|
2127
|
+
normal: "source-over",
|
|
2128
|
+
multiply: "multiply",
|
|
2129
|
+
screen: "screen",
|
|
2130
|
+
overlay: "overlay",
|
|
2131
|
+
darken: "darken",
|
|
2132
|
+
lighten: "lighten",
|
|
2133
|
+
"color-dodge": "color-dodge",
|
|
2134
|
+
"color-burn": "color-burn",
|
|
2135
|
+
"hard-light": "hard-light",
|
|
2136
|
+
"soft-light": "soft-light",
|
|
2137
|
+
difference: "difference",
|
|
2138
|
+
exclusion: "exclusion",
|
|
2139
|
+
hue: "hue",
|
|
2140
|
+
saturation: "saturation",
|
|
2141
|
+
color: "color",
|
|
2142
|
+
luminosity: "luminosity",
|
|
2143
|
+
add: "lighter",
|
|
2144
|
+
"linear-dodge": "lighter"
|
|
2145
|
+
};
|
|
2146
|
+
function blendOf(value) {
|
|
2147
|
+
return BLEND_TO_CANVAS[value] ?? "source-over";
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Everything the painter needs to draw composition time `t`, in draw order.
|
|
2151
|
+
* Pure: the same state and time give a deep-equal plan. DOM-rendered kinds
|
|
2152
|
+
* (title, shape, svg, sticker, layer, captions) are not in it; the DOM layer
|
|
2153
|
+
* reads `visiblePlacements` itself.
|
|
2154
|
+
*/
|
|
2155
|
+
function buildFramePlan(state, t, resolveNested) {
|
|
2156
|
+
const comp = state.composition;
|
|
2157
|
+
const warnings = [];
|
|
2158
|
+
const ops = [];
|
|
2159
|
+
const { background, backgroundGradient: gradient, blur } = compositionBackground(state);
|
|
2160
|
+
const clear = backdropTracks(state).size > 0;
|
|
2161
|
+
if (blur && clear) warnings.push("background mode blur is drawn as a colour under backdrop overlays");
|
|
2162
|
+
for (const item of visiblePlacements(state, t)) {
|
|
2163
|
+
const p = item.placement;
|
|
2164
|
+
if (p.kind === "clip" || p.kind === "nested") {
|
|
2165
|
+
const op = mediaOp(state, item, t, resolveNested, warnings);
|
|
2166
|
+
if (op) ops.push(op);
|
|
2167
|
+
const dip = item.transition && !op?.transition && transitionLook(item.transition, comp.width, comp.height).dip;
|
|
2168
|
+
if (op && dip) ops.push(dipOp(item.transition.placement, dip, comp.width, comp.height));
|
|
2169
|
+
} else if (p.kind === "adjustment") {
|
|
2170
|
+
if (p.props.enabled === false) continue;
|
|
2171
|
+
const { filter } = filterString(item.children, p.id, t - p.start, declaredEffects(state));
|
|
2172
|
+
const shaders = effectPasses(state, item.children, t - p.start);
|
|
2173
|
+
if (ops.length === 0) continue;
|
|
2174
|
+
ops.push({
|
|
2175
|
+
id: p.id,
|
|
2176
|
+
kind: "adjustment",
|
|
2177
|
+
rect: {
|
|
2178
|
+
x: 0,
|
|
2179
|
+
y: 0,
|
|
2180
|
+
w: comp.width,
|
|
2181
|
+
h: comp.height
|
|
2182
|
+
},
|
|
2183
|
+
matrix: {
|
|
2184
|
+
a: 1,
|
|
2185
|
+
b: 0,
|
|
2186
|
+
c: 0,
|
|
2187
|
+
d: 1,
|
|
2188
|
+
e: 0,
|
|
2189
|
+
f: 0
|
|
2190
|
+
},
|
|
2191
|
+
fit: "stretch",
|
|
2192
|
+
opacity: Number(p.props.opacity ?? 1),
|
|
2193
|
+
blend: blendOf(p.props.blend),
|
|
2194
|
+
filter,
|
|
2195
|
+
borderRadius: 0,
|
|
2196
|
+
...shaders ? { shaders } : {}
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
const footage = blur && !clear ? ops.find((o) => o.kind === "video" || o.kind === "image" || o.kind === "nested") : void 0;
|
|
2201
|
+
return {
|
|
2202
|
+
time: t,
|
|
2203
|
+
frame: frameIndex(t, comp.fps),
|
|
2204
|
+
background,
|
|
2205
|
+
...gradient ? { backgroundGradient: gradient } : {},
|
|
2206
|
+
...clear ? { clear: true } : {},
|
|
2207
|
+
...footage ? { backgroundBlur: { id: footage.id } } : {},
|
|
2208
|
+
ops,
|
|
2209
|
+
warnings
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
/** The composition's background as the painter draws it: a colour, or a gradient (whose first colour is `background`). */
|
|
2213
|
+
function compositionBackground(state) {
|
|
2214
|
+
const bg = state.items.get(state.composition.id)?.props.background;
|
|
2215
|
+
const gradient = bg?.mode === "gradient" && bg.gradient && bg.gradient.stops.length >= 2 ? bg.gradient : void 0;
|
|
2216
|
+
return {
|
|
2217
|
+
background: bg?.color ?? gradient?.stops[0]?.color ?? "#000000",
|
|
2218
|
+
...gradient ? { backgroundGradient: gradient } : {},
|
|
2219
|
+
blur: bg?.mode === "blur"
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
function mediaOp(state, item, t, resolveNested, warnings) {
|
|
2223
|
+
const p = item.placement;
|
|
2224
|
+
const comp = state.composition;
|
|
2225
|
+
const rel = t - p.start;
|
|
2226
|
+
const transform = animatedTransform(p.props.transform, item.children, rel);
|
|
2227
|
+
const fit = p.props.fit ?? "contain";
|
|
2228
|
+
let opacity = (transform.opacity ?? 1) * (typeof p.props.blendOpacity === "number" ? p.props.blendOpacity : 1);
|
|
2229
|
+
let look;
|
|
2230
|
+
const shaded = item.transition ? transitionPass(state, item.transition) : void 0;
|
|
2231
|
+
if (item.transition && !shaded) {
|
|
2232
|
+
const x = item.transition.placement;
|
|
2233
|
+
const type = String(x.props.type ?? "crossfade");
|
|
2234
|
+
if (!TRANSITION_TYPES.includes(type)) {
|
|
2235
|
+
devWarn(`transition:${x.id}:${type}`, `transition "${type}" on "${x.id}" is drawn as a crossfade`);
|
|
2236
|
+
warnings.push(`transition ${x.id}: ${type} drawn as crossfade`);
|
|
2237
|
+
}
|
|
2238
|
+
look = transitionLook(item.transition, comp.width, comp.height);
|
|
2239
|
+
opacity *= look.opacity;
|
|
2240
|
+
}
|
|
2241
|
+
const { filter } = filterString(item.children, p.id, rel, declaredEffects(state));
|
|
2242
|
+
const shaders = effectPasses(state, item.children, rel);
|
|
2243
|
+
const mask = maskOf(item.children, warnings);
|
|
2244
|
+
const post = postEffects(item.children, frameIndex(t, comp.fps), rel);
|
|
2245
|
+
const common = {
|
|
2246
|
+
id: p.id,
|
|
2247
|
+
fit,
|
|
2248
|
+
opacity,
|
|
2249
|
+
blend: blendOf(p.props.blend),
|
|
2250
|
+
filter,
|
|
2251
|
+
borderRadius: transform.borderRadius ?? 0,
|
|
2252
|
+
...mask ? { mask } : {},
|
|
2253
|
+
...look?.clip ? { clip: look.clip } : {},
|
|
2254
|
+
...post ? { post } : {},
|
|
2255
|
+
...shaders ? { shaders } : {},
|
|
2256
|
+
...shaded ? { transition: shaded } : {}
|
|
2257
|
+
};
|
|
2258
|
+
const shift = (m) => look?.offset ? multiply(translate(look.offset.x, look.offset.y), m) : m;
|
|
2259
|
+
if (p.kind === "nested") {
|
|
2260
|
+
const nested = resolveNested?.(p) ?? null;
|
|
2261
|
+
if (!nested) {
|
|
2262
|
+
warnings.push(`nested ${p.id}: no resolver`);
|
|
2263
|
+
const layout = layoutRect(comp, comp, fit, transform);
|
|
2264
|
+
return {
|
|
2265
|
+
...common,
|
|
2266
|
+
kind: "error",
|
|
2267
|
+
rect: layout.rect,
|
|
2268
|
+
matrix: shift(layout.matrix)
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
const layout = layoutRect(comp, nested.composition, fit, transform);
|
|
2272
|
+
return {
|
|
2273
|
+
...common,
|
|
2274
|
+
kind: "nested",
|
|
2275
|
+
nested: {
|
|
2276
|
+
state: nested,
|
|
2277
|
+
time: nestedSourceTime(p, t)
|
|
2278
|
+
},
|
|
2279
|
+
rect: layout.rect,
|
|
2280
|
+
matrix: shift(layout.matrix),
|
|
2281
|
+
...layout.crop ? { crop: layout.crop } : {}
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
const src = p.source;
|
|
2285
|
+
const asset = src ? lookupAsset(state.manifest, src.asset) : null;
|
|
2286
|
+
if (!src || !asset) {
|
|
2287
|
+
const layout = layoutRect(comp, comp, "contain", transform);
|
|
2288
|
+
return {
|
|
2289
|
+
...common,
|
|
2290
|
+
kind: "error",
|
|
2291
|
+
...src ? { asset: src.asset } : {},
|
|
2292
|
+
rect: layout.rect,
|
|
2293
|
+
matrix: shift(layout.matrix)
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
const size = {
|
|
2297
|
+
width: asset.width ?? comp.width,
|
|
2298
|
+
height: asset.height ?? comp.height
|
|
2299
|
+
};
|
|
2300
|
+
const layout = layoutRect(comp, size, fit, transform);
|
|
2301
|
+
return {
|
|
2302
|
+
...common,
|
|
2303
|
+
kind: asset.kind === "image" ? "image" : "video",
|
|
2304
|
+
asset: asset.id,
|
|
2305
|
+
sourceTime: placementSourceTime(p, item.children, t),
|
|
2306
|
+
rect: layout.rect,
|
|
2307
|
+
matrix: shift(layout.matrix),
|
|
2308
|
+
...layout.crop ? {
|
|
2309
|
+
crop: layout.crop,
|
|
2310
|
+
cropReference: size
|
|
2311
|
+
} : {}
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
/** A dip to black or white over everything drawn so far: an adjustment that maps the picture to the colour, `amount` of the way. */
|
|
2315
|
+
function dipOp(x, amount, width, height) {
|
|
2316
|
+
return {
|
|
2317
|
+
id: x.id,
|
|
2318
|
+
kind: "adjustment",
|
|
2319
|
+
rect: {
|
|
2320
|
+
x: 0,
|
|
2321
|
+
y: 0,
|
|
2322
|
+
w: width,
|
|
2323
|
+
h: height
|
|
2324
|
+
},
|
|
2325
|
+
matrix: {
|
|
2326
|
+
a: 1,
|
|
2327
|
+
b: 0,
|
|
2328
|
+
c: 0,
|
|
2329
|
+
d: 1,
|
|
2330
|
+
e: 0,
|
|
2331
|
+
f: 0
|
|
2332
|
+
},
|
|
2333
|
+
fit: "stretch",
|
|
2334
|
+
opacity: amount,
|
|
2335
|
+
blend: "source-over",
|
|
2336
|
+
filter: x.props.type === "dipToWhite" ? "brightness(0) invert(1)" : "brightness(0)",
|
|
2337
|
+
borderRadius: 0
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function maskOf(children, warnings) {
|
|
2341
|
+
const m = children.find((c) => c.kind === "mask");
|
|
2342
|
+
if (!m) return void 0;
|
|
2343
|
+
if (!MASK_TYPES.includes(m.props.type ?? "shape")) {
|
|
2344
|
+
warnings.push(`mask ${m.id}: type ${String(m.props.type)} is not rendered`);
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
return {
|
|
2348
|
+
shape: m.props.shape,
|
|
2349
|
+
path: m.props.path,
|
|
2350
|
+
feather: Number(m.props.feather ?? 0),
|
|
2351
|
+
inverted: m.props.inverted === true,
|
|
2352
|
+
opacity: Number(m.props.opacity ?? 1)
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
// src/nested.ts
|
|
2356
|
+
/**
|
|
2357
|
+
* Resolves `<Nested of={Helper}>` through the kit's `resolveScene`, once per
|
|
2358
|
+
* helper. File references (`of="file:..."`) have no loader yet and resolve
|
|
2359
|
+
* to nothing.
|
|
2360
|
+
*/
|
|
2361
|
+
function makeNestedResolver(parent) {
|
|
2362
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
2363
|
+
return (p) => {
|
|
2364
|
+
const of = p.props.of;
|
|
2365
|
+
if (typeof of !== "function") return null;
|
|
2366
|
+
const hit = cache.get(of);
|
|
2367
|
+
if (hit) return hit;
|
|
2368
|
+
const outer = parent();
|
|
2369
|
+
const state = resolveScene(of, p.props, outer.composition, outer.manifest);
|
|
2370
|
+
cache.set(of, state);
|
|
2371
|
+
return state;
|
|
2372
|
+
};
|
|
1173
2373
|
}
|
|
1174
2374
|
// src/render/masks.ts
|
|
1175
2375
|
/**
|
|
@@ -1223,6 +2423,201 @@ function roundedRect(ctx, x, y, w, h, r) {
|
|
|
1223
2423
|
ctx.arcTo(x, y, x + rr, y, rr);
|
|
1224
2424
|
ctx.closePath();
|
|
1225
2425
|
}
|
|
2426
|
+
// src/render/shaders.ts
|
|
2427
|
+
const VERTEX = `#version 300 es
|
|
2428
|
+
in vec2 position;
|
|
2429
|
+
out vec2 cutcraft_uv;
|
|
2430
|
+
void main() {
|
|
2431
|
+
// uv (0, 0) is the picture's top left, as in CSS.
|
|
2432
|
+
cutcraft_uv = vec2((position.x + 1.0) / 2.0, (1.0 - position.y) / 2.0);
|
|
2433
|
+
gl_Position = vec4(position, 0.0, 1.0);
|
|
2434
|
+
}`;
|
|
2435
|
+
/** Names the runner gives every shader; a param may not take one. */
|
|
2436
|
+
const RESERVED = /* @__PURE__ */ new Set([
|
|
2437
|
+
"image",
|
|
2438
|
+
"from",
|
|
2439
|
+
"to",
|
|
2440
|
+
"resolution",
|
|
2441
|
+
"time",
|
|
2442
|
+
"progress",
|
|
2443
|
+
"position"
|
|
2444
|
+
]);
|
|
2445
|
+
/** A param as a uniform: a number as a float, a boolean as a bool, 2 to 4 numbers as a vec, `#rgb`/`#rrggbb` as a vec3. */
|
|
2446
|
+
function uniformOf(value) {
|
|
2447
|
+
if (typeof value === "number" && Number.isFinite(value)) return {
|
|
2448
|
+
type: "float",
|
|
2449
|
+
value: [value]
|
|
2450
|
+
};
|
|
2451
|
+
if (typeof value === "boolean") return {
|
|
2452
|
+
type: "bool",
|
|
2453
|
+
value: [value ? 1 : 0]
|
|
2454
|
+
};
|
|
2455
|
+
if (Array.isArray(value) && value.length >= 2 && value.length <= 4 && value.every((v) => typeof v === "number")) return {
|
|
2456
|
+
type: `vec${value.length}`,
|
|
2457
|
+
value
|
|
2458
|
+
};
|
|
2459
|
+
if (typeof value === "string") {
|
|
2460
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value)?.[1];
|
|
2461
|
+
if (!hex) return null;
|
|
2462
|
+
const full = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex;
|
|
2463
|
+
return {
|
|
2464
|
+
type: "vec3",
|
|
2465
|
+
value: [
|
|
2466
|
+
0,
|
|
2467
|
+
2,
|
|
2468
|
+
4
|
|
2469
|
+
].map((i) => Number.parseInt(full.slice(i, i + 2), 16) / 255)
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
return null;
|
|
2473
|
+
}
|
|
2474
|
+
/** The fragment shader around a declared GLSL function: its inputs, its params as uniforms, and a `main` that calls it. */
|
|
2475
|
+
function fragmentSource(kind, source, params) {
|
|
2476
|
+
const uniforms = Object.entries(params).map(([name, value]) => [name, uniformOf(value)]).filter(([name, u]) => u && /^[A-Za-z_]\w*$/.test(name) && !RESERVED.has(name)).map(([name, u]) => `uniform ${u.type} ${name};`);
|
|
2477
|
+
return `#version 300 es
|
|
2478
|
+
precision highp float;
|
|
2479
|
+
${kind === "effect" ? "uniform sampler2D image;" : "uniform sampler2D from;\nuniform sampler2D to;\nuniform float progress;"}
|
|
2480
|
+
uniform vec2 resolution;
|
|
2481
|
+
uniform float time;
|
|
2482
|
+
${uniforms.join("\n")}
|
|
2483
|
+
in vec2 cutcraft_uv;
|
|
2484
|
+
out vec4 cutcraft_out;
|
|
2485
|
+
#line 1
|
|
2486
|
+
${source}
|
|
2487
|
+
void main() {
|
|
2488
|
+
vec4 c = ${kind}(cutcraft_uv);
|
|
2489
|
+
cutcraft_out = vec4(c.rgb * c.a, c.a);
|
|
2490
|
+
}`;
|
|
2491
|
+
}
|
|
2492
|
+
var ShaderRunner = class {
|
|
2493
|
+
width;
|
|
2494
|
+
height;
|
|
2495
|
+
createCanvas;
|
|
2496
|
+
canvas = null;
|
|
2497
|
+
gl = null;
|
|
2498
|
+
tried = false;
|
|
2499
|
+
programs = /* @__PURE__ */ new Map();
|
|
2500
|
+
textures = [];
|
|
2501
|
+
blank = null;
|
|
2502
|
+
constructor(width, height, createCanvas) {
|
|
2503
|
+
this.width = width;
|
|
2504
|
+
this.height = height;
|
|
2505
|
+
this.createCanvas = createCanvas;
|
|
2506
|
+
}
|
|
2507
|
+
/** Whether WebGL 2 is there to run shaders. */
|
|
2508
|
+
get available() {
|
|
2509
|
+
return this.context() !== null;
|
|
2510
|
+
}
|
|
2511
|
+
/** The effects run in order over `source`; the result is the runner's own canvas, valid until its next run. */
|
|
2512
|
+
effects(source, passes) {
|
|
2513
|
+
const gl = this.context();
|
|
2514
|
+
if (!gl) return null;
|
|
2515
|
+
let input = source;
|
|
2516
|
+
for (const pass of passes) {
|
|
2517
|
+
this.draw(gl, "effect", pass.source, pass.params, { image: input }, { time: pass.time });
|
|
2518
|
+
input = this.canvas;
|
|
2519
|
+
}
|
|
2520
|
+
return this.canvas;
|
|
2521
|
+
}
|
|
2522
|
+
/** The transition between two pictures (either may be absent: an edge blends with nothing). */
|
|
2523
|
+
transition(from, to, pass) {
|
|
2524
|
+
const gl = this.context();
|
|
2525
|
+
if (!gl) return null;
|
|
2526
|
+
const empty = this.empty();
|
|
2527
|
+
this.draw(gl, "transition", pass.source, pass.params, {
|
|
2528
|
+
from: from ?? empty,
|
|
2529
|
+
to: to ?? empty
|
|
2530
|
+
}, {
|
|
2531
|
+
time: pass.time,
|
|
2532
|
+
progress: pass.progress
|
|
2533
|
+
});
|
|
2534
|
+
return this.canvas;
|
|
2535
|
+
}
|
|
2536
|
+
context() {
|
|
2537
|
+
if (this.tried) return this.gl;
|
|
2538
|
+
this.tried = true;
|
|
2539
|
+
try {
|
|
2540
|
+
this.canvas = this.createCanvas(this.width, this.height);
|
|
2541
|
+
const gl = this.canvas.getContext?.("webgl2", {
|
|
2542
|
+
premultipliedAlpha: true,
|
|
2543
|
+
preserveDrawingBuffer: true,
|
|
2544
|
+
antialias: false
|
|
2545
|
+
});
|
|
2546
|
+
if (!gl) return null;
|
|
2547
|
+
const buffer = gl.createBuffer();
|
|
2548
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
2549
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
|
2550
|
+
-1,
|
|
2551
|
+
-1,
|
|
2552
|
+
3,
|
|
2553
|
+
-1,
|
|
2554
|
+
-1,
|
|
2555
|
+
3
|
|
2556
|
+
]), gl.STATIC_DRAW);
|
|
2557
|
+
this.gl = gl;
|
|
2558
|
+
} catch {
|
|
2559
|
+
this.gl = null;
|
|
2560
|
+
}
|
|
2561
|
+
return this.gl;
|
|
2562
|
+
}
|
|
2563
|
+
empty() {
|
|
2564
|
+
this.blank ??= this.createCanvas(this.width, this.height);
|
|
2565
|
+
return this.blank;
|
|
2566
|
+
}
|
|
2567
|
+
program(gl, fragment) {
|
|
2568
|
+
const cached = this.programs.get(fragment);
|
|
2569
|
+
if (cached) return cached;
|
|
2570
|
+
const compile = (type, text) => {
|
|
2571
|
+
const shader = gl.createShader(type);
|
|
2572
|
+
gl.shaderSource(shader, text);
|
|
2573
|
+
gl.compileShader(shader);
|
|
2574
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(`the shader does not compile: ${gl.getShaderInfoLog(shader)?.trim()}`);
|
|
2575
|
+
return shader;
|
|
2576
|
+
};
|
|
2577
|
+
const program = gl.createProgram();
|
|
2578
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
|
|
2579
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment));
|
|
2580
|
+
gl.bindAttribLocation(program, 0, "position");
|
|
2581
|
+
gl.linkProgram(program);
|
|
2582
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`the shader does not link: ${gl.getProgramInfoLog(program)?.trim()}`);
|
|
2583
|
+
this.programs.set(fragment, program);
|
|
2584
|
+
return program;
|
|
2585
|
+
}
|
|
2586
|
+
draw(gl, kind, source, params, images, values) {
|
|
2587
|
+
const program = this.program(gl, fragmentSource(kind, source, params));
|
|
2588
|
+
gl.useProgram(program);
|
|
2589
|
+
gl.viewport(0, 0, this.width, this.height);
|
|
2590
|
+
Object.entries(images).forEach(([name, image], unit) => {
|
|
2591
|
+
this.textures[unit] ??= gl.createTexture();
|
|
2592
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
2593
|
+
gl.bindTexture(gl.TEXTURE_2D, this.textures[unit]);
|
|
2594
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
2595
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
2596
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
2597
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
2598
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
2599
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
2600
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
2601
|
+
gl.uniform1i(gl.getUniformLocation(program, name), unit);
|
|
2602
|
+
});
|
|
2603
|
+
gl.uniform2f(gl.getUniformLocation(program, "resolution"), this.width, this.height);
|
|
2604
|
+
for (const [name, value] of Object.entries(values)) gl.uniform1f(gl.getUniformLocation(program, name), value);
|
|
2605
|
+
for (const [name, value] of Object.entries(params)) {
|
|
2606
|
+
const u = uniformOf(value);
|
|
2607
|
+
const at = u && gl.getUniformLocation(program, name);
|
|
2608
|
+
if (!u || !at) continue;
|
|
2609
|
+
if (u.type === "float" || u.type === "bool") gl.uniform1f(at, u.value[0]);
|
|
2610
|
+
else if (u.type === "vec2") gl.uniform2fv(at, u.value);
|
|
2611
|
+
else if (u.type === "vec3") gl.uniform3fv(at, u.value);
|
|
2612
|
+
else gl.uniform4fv(at, u.value);
|
|
2613
|
+
}
|
|
2614
|
+
gl.enableVertexAttribArray(0);
|
|
2615
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
2616
|
+
gl.clearColor(0, 0, 0, 0);
|
|
2617
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
2618
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
1226
2621
|
// src/render/painter.ts
|
|
1227
2622
|
/**
|
|
1228
2623
|
* Executes a FramePlan on a 2D context of composition size. Each op is
|
|
@@ -1237,6 +2632,11 @@ var CanvasPainter = class {
|
|
|
1237
2632
|
/** An element drawn on its own before compositing: a feathered mask, vignette, grain. */
|
|
1238
2633
|
layer = null;
|
|
1239
2634
|
grain = null;
|
|
2635
|
+
/** Runs the effects and transitions the file declares; made on first use. */
|
|
2636
|
+
shaders = null;
|
|
2637
|
+
/** The outgoing side of a declared transition between two clips, waiting for the incoming side. */
|
|
2638
|
+
outgoing = null;
|
|
2639
|
+
outgoingPicture = null;
|
|
1240
2640
|
constructor(width, height, createCanvas) {
|
|
1241
2641
|
this.width = width;
|
|
1242
2642
|
this.height = height;
|
|
@@ -1250,8 +2650,12 @@ var CanvasPainter = class {
|
|
|
1250
2650
|
if (plan.clear) ctx.clearRect(0, 0, this.width, this.height);
|
|
1251
2651
|
else paintBackground(ctx, plan, this.width, this.height);
|
|
1252
2652
|
if (plan.backgroundBlur) this.blurBackground(ctx, plan, plan.backgroundBlur.id, inputs);
|
|
2653
|
+
this.outgoing = null;
|
|
1253
2654
|
for (const op of plan.ops) if (op.kind === "adjustment") this.adjust(ctx, op);
|
|
1254
2655
|
else this.drawOp(ctx, op, inputs);
|
|
2656
|
+
const left = this.outgoing;
|
|
2657
|
+
if (left) this.composite(ctx, left.op, left.canvas);
|
|
2658
|
+
this.outgoing = null;
|
|
1255
2659
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1256
2660
|
}
|
|
1257
2661
|
drawOp(ctx, op, inputs) {
|
|
@@ -1259,6 +2663,11 @@ var CanvasPainter = class {
|
|
|
1259
2663
|
const nested = op.kind === "nested" ? inputs.nested.get(op.id) ?? null : null;
|
|
1260
2664
|
if (op.kind !== "nested" && !frame) return;
|
|
1261
2665
|
if (op.kind === "nested" && !nested) return;
|
|
2666
|
+
if ((op.shaders || op.transition) && this.runner().available) {
|
|
2667
|
+
this.drawShaded(ctx, op, frame, nested);
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
if (op.shaders || op.transition) warnNoShaders(op);
|
|
1262
2671
|
ctx.save();
|
|
1263
2672
|
ctx.globalAlpha = op.opacity;
|
|
1264
2673
|
ctx.globalCompositeOperation = op.blend;
|
|
@@ -1275,6 +2684,48 @@ var CanvasPainter = class {
|
|
|
1275
2684
|
this.blit(ctx, op, frame, nested);
|
|
1276
2685
|
ctx.restore();
|
|
1277
2686
|
}
|
|
2687
|
+
/**
|
|
2688
|
+
* An element with effects or a transition the file declares: drawn alone, run through its shaders, then composited.
|
|
2689
|
+
* The outgoing side of a transition between two clips is kept until the incoming side arrives, and the two are
|
|
2690
|
+
* blended in one pass.
|
|
2691
|
+
*/
|
|
2692
|
+
drawShaded(ctx, op, frame, nested) {
|
|
2693
|
+
const feathered = !!op.mask && op.mask.feather > 0;
|
|
2694
|
+
const layer = this.alone(op, frame, nested, feathered);
|
|
2695
|
+
if (!layer) return;
|
|
2696
|
+
const runner = this.runner();
|
|
2697
|
+
let picture = layer;
|
|
2698
|
+
try {
|
|
2699
|
+
if (op.shaders) picture = runner.effects(layer, op.shaders) ?? layer;
|
|
2700
|
+
const x = op.transition;
|
|
2701
|
+
if (x?.paired && x.role === "out") {
|
|
2702
|
+
this.outgoing = {
|
|
2703
|
+
id: x.id,
|
|
2704
|
+
canvas: copyInto(this.outgoingCanvas(), picture),
|
|
2705
|
+
op
|
|
2706
|
+
};
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
if (x) {
|
|
2710
|
+
const from = x.paired ? this.outgoing?.id === x.id ? this.outgoing.canvas : null : x.role === "out" ? picture : null;
|
|
2711
|
+
const to = x.paired || x.role === "in" ? picture : null;
|
|
2712
|
+
picture = runner.transition(from, to, x) ?? picture;
|
|
2713
|
+
if (x.paired) this.outgoing = null;
|
|
2714
|
+
}
|
|
2715
|
+
} catch (error) {
|
|
2716
|
+
warnShaderError(op, error);
|
|
2717
|
+
picture = layer;
|
|
2718
|
+
}
|
|
2719
|
+
this.composite(ctx, op, picture);
|
|
2720
|
+
}
|
|
2721
|
+
runner() {
|
|
2722
|
+
this.shaders ??= new ShaderRunner(this.width, this.height, this.createCanvas);
|
|
2723
|
+
return this.shaders;
|
|
2724
|
+
}
|
|
2725
|
+
outgoingCanvas() {
|
|
2726
|
+
this.outgoingPicture ??= this.createCanvas(this.width, this.height);
|
|
2727
|
+
return this.outgoingPicture;
|
|
2728
|
+
}
|
|
1278
2729
|
/** The footage's picture, blurred and scaled to cover the frame, under everything; skipped when the footage covers the frame itself. */
|
|
1279
2730
|
blurBackground(ctx, plan, id, inputs) {
|
|
1280
2731
|
const op = plan.ops.find((o) => o.id === id);
|
|
@@ -1295,7 +2746,7 @@ var CanvasPainter = class {
|
|
|
1295
2746
|
blit(ctx, op, frame, nested) {
|
|
1296
2747
|
const { w, h } = op.rect;
|
|
1297
2748
|
if (nested) ctx.drawImage(nested, 0, 0, w, h);
|
|
1298
|
-
else if (frame
|
|
2749
|
+
else if (frame) {
|
|
1299
2750
|
const reference = op.cropReference;
|
|
1300
2751
|
const crop = op.crop && reference ? {
|
|
1301
2752
|
x: op.crop.x * frame.width / reference.width,
|
|
@@ -1327,10 +2778,15 @@ var CanvasPainter = class {
|
|
|
1327
2778
|
* blurred alpha there, and vignette and grain darken or speckle only its own pixels.
|
|
1328
2779
|
*/
|
|
1329
2780
|
drawLayered(ctx, op, frame, nested, feathered) {
|
|
2781
|
+
const layer = this.alone(op, frame, nested, feathered);
|
|
2782
|
+
if (layer) this.composite(ctx, op, layer);
|
|
2783
|
+
}
|
|
2784
|
+
/** The element drawn alone on the layer canvas, at its place in the frame, its mask and vignette or grain applied. */
|
|
2785
|
+
alone(op, frame, nested, feathered) {
|
|
1330
2786
|
this.layer ??= this.createCanvas(this.width, this.height);
|
|
1331
2787
|
const layer = this.layer;
|
|
1332
2788
|
const lctx = layer.getContext("2d");
|
|
1333
|
-
if (!lctx) return;
|
|
2789
|
+
if (!lctx) return null;
|
|
1334
2790
|
lctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1335
2791
|
lctx.clearRect(0, 0, this.width, this.height);
|
|
1336
2792
|
lctx.save();
|
|
@@ -1372,6 +2828,10 @@ var CanvasPainter = class {
|
|
|
1372
2828
|
}
|
|
1373
2829
|
lctx.restore();
|
|
1374
2830
|
}
|
|
2831
|
+
return layer;
|
|
2832
|
+
}
|
|
2833
|
+
/** A picture of the whole frame composited as the op: its opacity, blend, filter, wipe and rounded corners. */
|
|
2834
|
+
composite(ctx, op, layer) {
|
|
1375
2835
|
ctx.save();
|
|
1376
2836
|
ctx.globalAlpha = op.opacity;
|
|
1377
2837
|
ctx.globalCompositeOperation = op.blend;
|
|
@@ -1404,6 +2864,13 @@ var CanvasPainter = class {
|
|
|
1404
2864
|
return this.grain;
|
|
1405
2865
|
}
|
|
1406
2866
|
adjust(ctx, op) {
|
|
2867
|
+
if (op.shaders) {
|
|
2868
|
+
if (this.runner().available) {
|
|
2869
|
+
this.adjustShaded(ctx, op);
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
warnNoShaders(op);
|
|
2873
|
+
}
|
|
1407
2874
|
if (!op.filter && op.opacity >= 1) return;
|
|
1408
2875
|
this.scratch ??= this.createCanvas(this.width, this.height);
|
|
1409
2876
|
const sctx = this.scratch.getContext("2d");
|
|
@@ -1421,8 +2888,45 @@ var CanvasPainter = class {
|
|
|
1421
2888
|
ctx.drawImage(this.scratch, 0, 0);
|
|
1422
2889
|
ctx.restore();
|
|
1423
2890
|
}
|
|
2891
|
+
/** An adjustment with declared effects: everything drawn so far, through the shaders, back over itself. */
|
|
2892
|
+
adjustShaded(ctx, op) {
|
|
2893
|
+
this.scratch ??= this.createCanvas(this.width, this.height);
|
|
2894
|
+
const scratch = copyInto(this.scratch, canvasOf(ctx));
|
|
2895
|
+
let picture = scratch;
|
|
2896
|
+
try {
|
|
2897
|
+
picture = this.runner().effects(scratch, op.shaders ?? []) ?? scratch;
|
|
2898
|
+
} catch (error) {
|
|
2899
|
+
warnShaderError(op, error);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
ctx.save();
|
|
2903
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2904
|
+
ctx.globalCompositeOperation = "source-over";
|
|
2905
|
+
ctx.globalAlpha = op.opacity;
|
|
2906
|
+
ctx.filter = op.filter || "none";
|
|
2907
|
+
ctx.drawImage(picture, 0, 0);
|
|
2908
|
+
ctx.restore();
|
|
2909
|
+
}
|
|
1424
2910
|
};
|
|
1425
2911
|
const GRAIN_TILE = 128;
|
|
2912
|
+
/** `target` holding a copy of `source`, both the frame's size. */
|
|
2913
|
+
function copyInto(target, source) {
|
|
2914
|
+
const c = target.getContext("2d");
|
|
2915
|
+
if (!c) return source;
|
|
2916
|
+
c.setTransform(1, 0, 0, 1, 0, 0);
|
|
2917
|
+
c.globalCompositeOperation = "copy";
|
|
2918
|
+
c.globalAlpha = 1;
|
|
2919
|
+
c.filter = "none";
|
|
2920
|
+
c.drawImage(source, 0, 0);
|
|
2921
|
+
c.globalCompositeOperation = "source-over";
|
|
2922
|
+
return target;
|
|
2923
|
+
}
|
|
2924
|
+
function warnNoShaders(op) {
|
|
2925
|
+
devWarn(`shaders:${op.id}`, `"${op.id}" uses an effect or transition the file declares; this renderer has no WebGL, so it draws without it (the browser export draws it)`);
|
|
2926
|
+
}
|
|
2927
|
+
function warnShaderError(op, error) {
|
|
2928
|
+
devWarn(`shader-error:${op.id}`, `"${op.id}": ${error instanceof Error ? error.message : String(error)}`);
|
|
2929
|
+
}
|
|
1426
2930
|
/** A small seeded PRNG on 0..1. */
|
|
1427
2931
|
function mulberry32(seed) {
|
|
1428
2932
|
let a = seed >>> 0;
|
|
@@ -1564,11 +3068,10 @@ var Compositor = class Compositor {
|
|
|
1564
3068
|
* for. Paint order and output are the same as `seek` then `tick`, at a
|
|
1565
3069
|
* fraction of the decode work.
|
|
1566
3070
|
*/
|
|
1567
|
-
step(t
|
|
3071
|
+
step(t) {
|
|
1568
3072
|
return this.tick(t, {
|
|
1569
3073
|
wait: true,
|
|
1570
|
-
exact: true
|
|
1571
|
-
...intercept ? { intercept } : {}
|
|
3074
|
+
exact: true
|
|
1572
3075
|
});
|
|
1573
3076
|
}
|
|
1574
3077
|
tick(t, opts) {
|
|
@@ -1576,11 +3079,9 @@ var Compositor = class Compositor {
|
|
|
1576
3079
|
if (this.inFlight) {
|
|
1577
3080
|
if (this.pending) {
|
|
1578
3081
|
this.pending.t = t;
|
|
1579
|
-
const intercept = opts.intercept ?? this.pending.opts.intercept;
|
|
1580
3082
|
this.pending.opts = {
|
|
1581
3083
|
wait: opts.wait || this.pending.opts.wait,
|
|
1582
|
-
exact: Boolean(opts.exact || this.pending.opts.exact)
|
|
1583
|
-
...intercept ? { intercept } : {}
|
|
3084
|
+
exact: Boolean(opts.exact || this.pending.opts.exact)
|
|
1584
3085
|
};
|
|
1585
3086
|
return this.pending.done;
|
|
1586
3087
|
}
|
|
@@ -1637,14 +3138,10 @@ var Compositor = class Compositor {
|
|
|
1637
3138
|
frames.set(op.id, d ? await d.frameFor(t, opts.wait, opts.exact) : null);
|
|
1638
3139
|
}));
|
|
1639
3140
|
if (this.disposed) return;
|
|
1640
|
-
if (
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
frames,
|
|
1645
|
-
nested: nestedCanvases
|
|
1646
|
-
});
|
|
1647
|
-
}
|
|
3141
|
+
if (this.ctx) this.painter.paint(this.ctx, plan, {
|
|
3142
|
+
frames,
|
|
3143
|
+
nested: nestedCanvases
|
|
3144
|
+
});
|
|
1648
3145
|
for (const op of plan.ops) this.pool.decoderFor(op.id)?.advance(t);
|
|
1649
3146
|
this.lastFrame = frame;
|
|
1650
3147
|
this.deps.onFrame?.(t, frame);
|
|
@@ -1676,23 +3173,4 @@ var Compositor = class Compositor {
|
|
|
1676
3173
|
}
|
|
1677
3174
|
}
|
|
1678
3175
|
};
|
|
1679
|
-
|
|
1680
|
-
/**
|
|
1681
|
-
* Resolves `<Nested of={Helper}>` through the kit's `resolveScene`, once per
|
|
1682
|
-
* helper. File references (`of="file:..."`) have no loader yet and resolve
|
|
1683
|
-
* to nothing.
|
|
1684
|
-
*/
|
|
1685
|
-
function makeNestedResolver(parent) {
|
|
1686
|
-
const cache = /* @__PURE__ */ new WeakMap();
|
|
1687
|
-
return (p) => {
|
|
1688
|
-
const of = p.props.of;
|
|
1689
|
-
if (typeof of !== "function") return null;
|
|
1690
|
-
const hit = cache.get(of);
|
|
1691
|
-
if (hit) return hit;
|
|
1692
|
-
const outer = parent();
|
|
1693
|
-
const state = resolveScene(of, p.props, outer.composition, outer.manifest);
|
|
1694
|
-
cache.set(of, state);
|
|
1695
|
-
return state;
|
|
1696
|
-
};
|
|
1697
|
-
}
|
|
1698
|
-
export { buildAudioPlan as A, ImageFrameSource as C, toResolver as D, fromMap as E, duckIntervals as M, ManualClock as O, ImageFrame as S, defaultCreateCanvas as T, DecoderPool as _, backgroundFill as a, ErrorFrame as b, setMatrix as c, MediabunnyAudioSource as d, mediabunnyAudioSourceFactory as f, MediabunnyInputs as g, mediabunnyFrameSourceFactory as h, backgroundCss as i, buildGainEnvelope as j, PerformanceClock as k, roundedRect as l, SampleFrame as m, Compositor as n, coversCanvas as o, MediabunnyFrameSource as p, CanvasPainter as r, paintBackground as s, makeNestedResolver as t, traceMask as u, ClipDecoder as v, copyFrame as w, ErrorFrameSource as x, AssetSources as y };
|
|
3176
|
+
export { ClipDecoder as $, transformMatrix as A, ENVELOPE_STEP_S as At, toResolver as B, PLAY_PRIME_TIMEOUT_MS as Bt, cssTransform as C, captureWarnings as Ct, multiply as D, AUDIO_PRIME_S as Dt, matrixToCss as E, AUDIO_CHUNK_S as Et, paramsAt as F, MAX_OPEN_INPUTS as Ft, MediabunnyAudioSource as G, SCHEDULER_MS as Gt, MediabunnyFrameSource as H, QUEUE_TARGET as Ht, postEffects as I, MAX_RATE as It, loadFileFonts as J, UNMOUNT_BEHIND_S as Jt, mediabunnyAudioSourceFactory as K, SEEK_MODE_SPEED as Kt, OVERLAY_KINDS as L, MIN_RATE as Lt, declaredEffects as M, FRAME_WAIT_MS as Mt, effectPasses as N, INPUT_IDLE_CLOSE_S as Nt, rotate as O, DUCK_ATTACK_S as Ot, filterString as P, LOOKAHEAD_S as Pt, visiblePlacements as Q, backdropTracks as R, MOUNT_AHEAD_S as Rt, boundsOf as S, keyframedValue as St, layoutRect as T, AUDIO_CACHE_S as Tt, SampleFrame as U, REVERSE_WINDOW_FRAMES as Ut, MediabunnyInputs as V, QUEUE_MAX as Vt, mediabunnyFrameSourceFactory as W, REVERSE_WINDOW_S as Wt, isVisibleAt as X, DecoderPool as Y, trackItems as Z, combine as _, buildGainEnvelope as _t, coversCanvas as a, placementSourceTime as at, IDENTITY as b, ease as bt, ShaderRunner as c, ErrorFrame as ct, makeNestedResolver as d, ImageFrameSource as dt, frameIndex as et, blendOf as f, copyFrame as ft, STILL as g, buildAudioPlan as gt, transitionLook as h, PerformanceClock as ht, backgroundFill as i, nestedSourceTime as it, translate as j, FRAME_EPS as jt, scaleM as k, DUCK_RELEASE_S as kt, roundedRect as l, ErrorFrameSource as lt, compositionBackground as m, ManualClock as mt, CanvasPainter as n, layerClock as nt, paintBackground as o, timecode as ot, buildFramePlan as p, defaultCreateCanvas as pt, addFontFaces as q, SETTLE_MS as qt, backgroundCss as r, localT as rt, setMatrix as s, AssetSources as st, Compositor as t, frameTime as tt, traceMask as u, ImageFrame as ut, effect as v, duckIntervals as vt, fitRect as w, devWarn as wt, animatedTransform as x, hasKeyframes as xt, motionAt as y, EASING_FN as yt, fromMap as z, PICK_EPS as zt };
|