@cutcraft/player 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/dist/engine.d.mts +2 -0
- package/dist/engine.mjs +1 -0
- package/dist/index.d.mts +327 -0
- package/dist/index.mjs +3301 -0
- package/dist/render-DA1KFyei.mjs +3176 -0
- package/dist/warn-GHM3SBI2.d.mts +892 -0
- package/fonts.css +23 -0
- package/package.json +74 -3
|
@@ -0,0 +1,3176 @@
|
|
|
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
|
+
}
|
|
111
|
+
// src/model/audio-plan.ts
|
|
112
|
+
/** Every audio-bearing chunk that overlaps [from, to). */
|
|
113
|
+
function buildAudioPlan(state, from, to, resolveNested) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const anySolo = state.tracks.some((t) => t.solo);
|
|
116
|
+
for (const track of state.tracks) {
|
|
117
|
+
if (track.hidden || track.muted || anySolo && !track.solo) continue;
|
|
118
|
+
for (const p of [...state.items.values()].filter((q) => q.track === track.id && q.parent !== null && q.kind !== "transition")) {
|
|
119
|
+
if (!capabilitiesOf(p.kind).audio) continue;
|
|
120
|
+
if (p.end <= from || p.start >= to) continue;
|
|
121
|
+
if (p.kind === "nested") {
|
|
122
|
+
out.push(...nestedOccurrences(state, p, track, from, to, resolveNested));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const src = p.source;
|
|
126
|
+
if (!src || src.out <= src.in) continue;
|
|
127
|
+
const asset = lookupAsset(state.manifest, src.asset);
|
|
128
|
+
if (!asset || asset.kind === "image" || asset.kind === "video" && !asset.audio) continue;
|
|
129
|
+
const reverse = p.props.reverse === true;
|
|
130
|
+
const envelope = buildGainEnvelope(p, track, state, p.start, p.end);
|
|
131
|
+
const children = p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0);
|
|
132
|
+
const pan = panEnvelope(p, children);
|
|
133
|
+
for (const chunk of chunksOf(p, src, reverse, speedKeysOf(children), freezeHoldsOf(children))) {
|
|
134
|
+
if (chunk.compEnd <= from || chunk.compStart >= to) continue;
|
|
135
|
+
out.push({
|
|
136
|
+
id: p.id,
|
|
137
|
+
asset: asset.id,
|
|
138
|
+
chunk: chunk.index,
|
|
139
|
+
compStart: chunk.compStart,
|
|
140
|
+
compEnd: chunk.compEnd,
|
|
141
|
+
bufferFrom: chunk.bufferFrom,
|
|
142
|
+
bufferTo: chunk.bufferTo,
|
|
143
|
+
speed: src.speed,
|
|
144
|
+
...chunk.rate ? { rate: chunk.rate } : {},
|
|
145
|
+
gain: envelope,
|
|
146
|
+
...pan ? { pan } : {},
|
|
147
|
+
...reverse ? { reverse } : {}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pieces of the source range in source order: cut every `AUDIO_CHUNK_S` when
|
|
156
|
+
* long, and where each freeze hold starts, so a hold is a gap with no audio.
|
|
157
|
+
* A piece maps to composition time through the speed (or the speed-key ramp's
|
|
158
|
+
* inverse) plus the holds before it. A reversed placement plays its last
|
|
159
|
+
* piece first, so its range counts back from `src.out`.
|
|
160
|
+
*/
|
|
161
|
+
function chunksOf(p, src, reverse, keys, holds) {
|
|
162
|
+
const length = src.out - src.in;
|
|
163
|
+
const ramp = keys.length > 0;
|
|
164
|
+
const advance = (u) => ramp ? rampAdvance(keys, u) : u * src.speed;
|
|
165
|
+
const inverse = (a) => ramp ? rampInverse(keys, a) : a / src.speed;
|
|
166
|
+
let before = 0;
|
|
167
|
+
const holdAt = holds.map((h) => {
|
|
168
|
+
const u = h.start - before;
|
|
169
|
+
before += h.duration;
|
|
170
|
+
return {
|
|
171
|
+
u,
|
|
172
|
+
duration: h.duration
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
const cuts = /* @__PURE__ */ new Set([0, length]);
|
|
176
|
+
if (length > 60) for (let s = 30; s < length; s += 30) cuts.add(s);
|
|
177
|
+
for (const { u } of holdAt) {
|
|
178
|
+
const a = Math.min(length, Math.max(0, advance(u)));
|
|
179
|
+
cuts.add(reverse ? length - a : a);
|
|
180
|
+
}
|
|
181
|
+
const sorted = [...cuts].sort((x, y) => x - y);
|
|
182
|
+
const chunks = [];
|
|
183
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
184
|
+
const s1 = sorted[i], s2 = sorted[i + 1];
|
|
185
|
+
if (s2 - s1 < 1e-9) continue;
|
|
186
|
+
const [u1, u2] = (reverse ? [length - s2, length - s1] : [s1, s2]).map(inverse);
|
|
187
|
+
const mid = (u1 + u2) / 2;
|
|
188
|
+
const offset = p.start + holdAt.reduce((n, h) => h.u <= mid ? n + h.duration : n, 0);
|
|
189
|
+
const compStart = round(offset + u1), compEnd = round(offset + u2);
|
|
190
|
+
const rate = ramp ? rateEnvelope(keys, u1, u2).map(([u, v]) => [round(offset + u), v]) : void 0;
|
|
191
|
+
chunks.push({
|
|
192
|
+
index: chunks.length,
|
|
193
|
+
compStart,
|
|
194
|
+
compEnd,
|
|
195
|
+
bufferFrom: src.in + s1,
|
|
196
|
+
bufferTo: src.in + s2,
|
|
197
|
+
...rate ? { rate } : {}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return chunks;
|
|
201
|
+
}
|
|
202
|
+
/** Ramp speed over clip seconds [u1, u2] (holds taken out): the key times plus a fixed step inside ramps, as the gain envelope samples. */
|
|
203
|
+
function rateEnvelope(keys, u1, u2) {
|
|
204
|
+
const times = /* @__PURE__ */ new Set([u1, u2]);
|
|
205
|
+
for (let i = 0; i < keys.length; i++) {
|
|
206
|
+
const a = keys[i], b = keys[i + 1];
|
|
207
|
+
if (a.at > u1 && a.at < u2) times.add(a.at);
|
|
208
|
+
if (b && a.speed !== b.speed) {
|
|
209
|
+
for (let u = a.at + ENVELOPE_STEP_S; u < b.at; u += ENVELOPE_STEP_S) if (u > u1 && u < u2) times.add(u);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...times].sort((x, y) => x - y).map((u) => [u, round(rampSpeed(keys, u))]);
|
|
213
|
+
}
|
|
214
|
+
function nestedOccurrences(state, p, track, from, to, resolveNested) {
|
|
215
|
+
const inner = resolveNested?.(p);
|
|
216
|
+
const src = p.source;
|
|
217
|
+
if (!inner || !src) return [];
|
|
218
|
+
const toInner = (t) => src.in + (t - p.start) * src.speed;
|
|
219
|
+
const toOuter = (t) => p.start + (t - src.in) / src.speed;
|
|
220
|
+
const outerEnvelope = buildGainEnvelope(p, track, state, p.start, p.end);
|
|
221
|
+
return buildAudioPlan(inner, toInner(Math.max(from, p.start)), toInner(Math.min(to, p.end)), resolveNested).map((o) => {
|
|
222
|
+
const compStart = Math.max(p.start, toOuter(o.compStart));
|
|
223
|
+
const compEnd = Math.min(p.end, toOuter(o.compEnd));
|
|
224
|
+
const inner = o.gain.map(([t, v]) => [toOuter(t), v]);
|
|
225
|
+
return {
|
|
226
|
+
...o,
|
|
227
|
+
id: `${p.id}/${o.id}`,
|
|
228
|
+
compStart,
|
|
229
|
+
compEnd,
|
|
230
|
+
speed: o.speed * src.speed,
|
|
231
|
+
...o.rate ? { rate: o.rate.map(([t, v]) => [round(toOuter(t)), v * src.speed]) } : {},
|
|
232
|
+
gain: multiplyEnvelopes([inner, outerEnvelope], compStart, compEnd)
|
|
233
|
+
};
|
|
234
|
+
}).filter((o) => o.compEnd > o.compStart);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Gain over [winFrom, winTo] in composition seconds: volume × keyframed
|
|
238
|
+
* `audio.volume` × fades × automation × ducking. The factors are multiplied
|
|
239
|
+
* and sampled at their breakpoints plus a fixed step inside ramps, so the
|
|
240
|
+
* product stays close to piecewise linear.
|
|
241
|
+
*/
|
|
242
|
+
function buildGainEnvelope(p, track, state, winFrom, winTo) {
|
|
243
|
+
const children = p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0);
|
|
244
|
+
const volume = typeof p.props.volume === "number" ? p.props.volume : 1;
|
|
245
|
+
const factors = [];
|
|
246
|
+
const breaks = /* @__PURE__ */ new Set([winFrom, winTo]);
|
|
247
|
+
const ramps = [];
|
|
248
|
+
const kfs = children.filter((c) => c.kind === "keyframe" && c.props.property === "audio.volume");
|
|
249
|
+
if (kfs.length > 0) {
|
|
250
|
+
factors.push((t) => Number(keyframedValue(children, "audio.volume", t - p.start, volume)));
|
|
251
|
+
const ats = kfs.map((k) => p.start + Number(k.props.at ?? 0)).sort((a, b) => a - b);
|
|
252
|
+
ats.forEach((at) => breaks.add(at));
|
|
253
|
+
for (let i = 0; i < ats.length - 1; i++) ramps.push([ats[i], ats[i + 1]]);
|
|
254
|
+
} else factors.push(() => volume);
|
|
255
|
+
const fade = p.props.fade;
|
|
256
|
+
if (fade?.in && fade.in > 0) {
|
|
257
|
+
const a = p.start, b = p.start + fade.in;
|
|
258
|
+
factors.push((t) => t <= a ? 0 : t >= b ? 1 : (t - a) / (b - a));
|
|
259
|
+
breaks.add(a);
|
|
260
|
+
breaks.add(b);
|
|
261
|
+
}
|
|
262
|
+
if (fade?.out && fade.out > 0) {
|
|
263
|
+
const a = p.end - fade.out, b = p.end;
|
|
264
|
+
factors.push((t) => t <= a ? 1 : t >= b ? 0 : (b - t) / (b - a));
|
|
265
|
+
breaks.add(a);
|
|
266
|
+
breaks.add(b);
|
|
267
|
+
}
|
|
268
|
+
const automation = p.props.automation?.volume;
|
|
269
|
+
if (automation && automation.length > 0) {
|
|
270
|
+
const pts = [...automation].sort((x, y) => x[0] - y[0]).map(([rel, v]) => [p.start + rel, v]);
|
|
271
|
+
factors.push((t) => interpolate(pts, t));
|
|
272
|
+
pts.forEach(([t]) => breaks.add(t));
|
|
273
|
+
}
|
|
274
|
+
const duck = p.props.duck;
|
|
275
|
+
if (duck) {
|
|
276
|
+
const attack = duck.attack ?? .1, release = duck.release ?? .3;
|
|
277
|
+
const intervals = duckIntervals(state, duck.under, p.id);
|
|
278
|
+
for (const [a, b] of intervals) {
|
|
279
|
+
breaks.add(a - attack);
|
|
280
|
+
breaks.add(a);
|
|
281
|
+
breaks.add(b);
|
|
282
|
+
breaks.add(b + release);
|
|
283
|
+
if (attack > 0) ramps.push([a - attack, a]);
|
|
284
|
+
if (release > 0) ramps.push([b, b + release]);
|
|
285
|
+
}
|
|
286
|
+
factors.push((t) => {
|
|
287
|
+
let f = 1;
|
|
288
|
+
for (const [a, b] of intervals) {
|
|
289
|
+
let g = 1;
|
|
290
|
+
if (t >= a && t <= b) g = duck.to;
|
|
291
|
+
else if (t < a && t > a - attack) g = 1 - (1 - duck.to) * ((t - (a - attack)) / attack);
|
|
292
|
+
else if (t > b && t < b + release) g = duck.to + (1 - duck.to) * ((t - b) / release);
|
|
293
|
+
f = Math.min(f, g);
|
|
294
|
+
}
|
|
295
|
+
return f;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const times = [...breaks].filter((t) => t >= winFrom && t <= winTo).sort((a, b) => a - b);
|
|
299
|
+
const sampled = new Set(times);
|
|
300
|
+
for (const [a, b] of ramps) for (let t = a + ENVELOPE_STEP_S; t < b; t += ENVELOPE_STEP_S) if (t > winFrom && t < winTo) sampled.add(round(t));
|
|
301
|
+
const value = (t) => factors.reduce((acc, f) => acc * f(t), 1);
|
|
302
|
+
return [...sampled].sort((a, b) => a - b).map((t) => [round(t), round(value(t))]);
|
|
303
|
+
}
|
|
304
|
+
/** Composition intervals where sound plays on the track `under` names by id, else on the tracks of that role; silent assets and muted clips do not duck. */
|
|
305
|
+
function duckIntervals(state, under, exceptId) {
|
|
306
|
+
const tracks = state.tracks.some((t) => t.id === under) ? [under] : state.tracks.filter((t) => t.role === under).map((t) => t.id);
|
|
307
|
+
const ranges = [...state.items.values()].filter((q) => q.track !== null && tracks.includes(q.track) && q.id !== exceptId && q.parent !== null && capabilitiesOf(q.kind).audio && hasSound(state, q)).filter((q) => q.end > q.start).map((q) => [q.start, q.end]).sort((a, b) => a[0] - b[0]);
|
|
308
|
+
const merged = [];
|
|
309
|
+
for (const r of ranges) {
|
|
310
|
+
const last = merged[merged.length - 1];
|
|
311
|
+
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
|
|
312
|
+
else merged.push([r[0], r[1]]);
|
|
313
|
+
}
|
|
314
|
+
return merged;
|
|
315
|
+
}
|
|
316
|
+
function hasSound(state, p) {
|
|
317
|
+
if (p.kind === "nested") return true;
|
|
318
|
+
const asset = p.source ? lookupAsset(state.manifest, p.source.asset) : null;
|
|
319
|
+
return asset !== null && asset.kind !== "image" && (asset.kind === "audio" || asset.audio !== void 0) && p.props.volume !== 0;
|
|
320
|
+
}
|
|
321
|
+
/** Pan over the placement: its `audio.pan` keyframes (sampled inside each segment), else `automation.pan`; keyframes win when both are written. */
|
|
322
|
+
function panEnvelope(p, children) {
|
|
323
|
+
const ats = children.filter((c) => c.kind === "keyframe" && c.props.property === "audio.pan").map((k) => Number(k.props.at ?? 0)).sort((a, b) => a - b);
|
|
324
|
+
if (ats.length > 0) {
|
|
325
|
+
const rels = new Set(ats);
|
|
326
|
+
for (let i = 0; i < ats.length - 1; i++) for (let r = ats[i] + ENVELOPE_STEP_S; r < ats[i + 1]; r += ENVELOPE_STEP_S) rels.add(r);
|
|
327
|
+
return [...rels].sort((a, b) => a - b).map((rel) => [round(p.start + rel), round(Number(keyframedValue(children, "audio.pan", rel, 0)))]);
|
|
328
|
+
}
|
|
329
|
+
const automation = p.props.automation?.pan;
|
|
330
|
+
if (!automation || automation.length === 0) return void 0;
|
|
331
|
+
return [...automation].sort((x, y) => x[0] - y[0]).map(([rel, v]) => [round(p.start + rel), v]);
|
|
332
|
+
}
|
|
333
|
+
function interpolate(pts, t) {
|
|
334
|
+
const first = pts[0], last = pts[pts.length - 1];
|
|
335
|
+
if (t <= first[0]) return first[1];
|
|
336
|
+
if (t >= last[0]) return last[1];
|
|
337
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
338
|
+
const a = pts[i], b = pts[i + 1];
|
|
339
|
+
if (t >= a[0] && t < b[0]) return b[0] === a[0] ? b[1] : a[1] + (b[1] - a[1]) * (t - a[0]) / (b[0] - a[0]);
|
|
340
|
+
}
|
|
341
|
+
return last[1];
|
|
342
|
+
}
|
|
343
|
+
function multiplyEnvelopes(envs, from, to) {
|
|
344
|
+
const times = /* @__PURE__ */ new Set([from, to]);
|
|
345
|
+
for (const e of envs) for (const [t] of e) if (t >= from && t <= to) times.add(t);
|
|
346
|
+
return [...times].sort((a, b) => a - b).map((t) => [round(t), round(envs.reduce((acc, e) => acc * (e.length ? interpolate(e, t) : 1), 1))]);
|
|
347
|
+
}
|
|
348
|
+
function round(v) {
|
|
349
|
+
return Math.round(v * 1e6) / 1e6;
|
|
350
|
+
}
|
|
351
|
+
// src/clock/clock.ts
|
|
352
|
+
var ManualClock = class {
|
|
353
|
+
t = 0;
|
|
354
|
+
now() {
|
|
355
|
+
return this.t;
|
|
356
|
+
}
|
|
357
|
+
advance(dt) {
|
|
358
|
+
this.t += dt;
|
|
359
|
+
}
|
|
360
|
+
set(t) {
|
|
361
|
+
this.t = t;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
var PerformanceClock = class {
|
|
365
|
+
now() {
|
|
366
|
+
return performance.now() / 1e3;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
// src/media/frame-source.ts
|
|
370
|
+
function defaultCreateCanvas(width, height) {
|
|
371
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
372
|
+
const c = document.createElement("canvas");
|
|
373
|
+
c.width = width;
|
|
374
|
+
c.height = height;
|
|
375
|
+
return c;
|
|
376
|
+
}
|
|
377
|
+
/** A frame backed by an image we own (canvas or bitmap); `retain` is free. */
|
|
378
|
+
var ImageFrame = class {
|
|
379
|
+
image;
|
|
380
|
+
timestamp;
|
|
381
|
+
duration;
|
|
382
|
+
owned;
|
|
383
|
+
constructor(image, timestamp, duration, owned) {
|
|
384
|
+
this.image = image;
|
|
385
|
+
this.timestamp = timestamp;
|
|
386
|
+
this.duration = duration;
|
|
387
|
+
this.owned = owned;
|
|
388
|
+
}
|
|
389
|
+
get width() {
|
|
390
|
+
return this.image.width;
|
|
391
|
+
}
|
|
392
|
+
get height() {
|
|
393
|
+
return this.image.height;
|
|
394
|
+
}
|
|
395
|
+
draw(ctx, dx, dy, dw, dh, crop) {
|
|
396
|
+
if (crop) ctx.drawImage(this.image, crop.x, crop.y, crop.w, crop.h, dx, dy, dw, dh);
|
|
397
|
+
else ctx.drawImage(this.image, dx, dy, dw, dh);
|
|
398
|
+
}
|
|
399
|
+
retain() {
|
|
400
|
+
return this;
|
|
401
|
+
}
|
|
402
|
+
close() {
|
|
403
|
+
if (this.owned && "close" in this.image && typeof this.image.close === "function") this.image.close();
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
/** Copies any frame into a canvas we own. */
|
|
407
|
+
function copyFrame(frame, createCanvas) {
|
|
408
|
+
const canvas = createCanvas(Math.max(1, frame.width), Math.max(1, frame.height));
|
|
409
|
+
const ctx = canvas.getContext("2d");
|
|
410
|
+
if (ctx) frame.draw(ctx, 0, 0, frame.width, frame.height);
|
|
411
|
+
return new ImageFrame(canvas, frame.timestamp, frame.duration, false);
|
|
412
|
+
}
|
|
413
|
+
/** Still images: one bitmap, returned for every time. */
|
|
414
|
+
var ImageFrameSource = class {
|
|
415
|
+
load;
|
|
416
|
+
bitmap = null;
|
|
417
|
+
constructor(load) {
|
|
418
|
+
this.load = load;
|
|
419
|
+
}
|
|
420
|
+
async open() {
|
|
421
|
+
const f = await this.frame();
|
|
422
|
+
return {
|
|
423
|
+
width: f.width,
|
|
424
|
+
height: f.height,
|
|
425
|
+
duration: 0,
|
|
426
|
+
decodable: true
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async frameAt() {
|
|
430
|
+
return this.frame();
|
|
431
|
+
}
|
|
432
|
+
async *stream() {
|
|
433
|
+
yield await this.frame();
|
|
434
|
+
}
|
|
435
|
+
close() {
|
|
436
|
+
this.bitmap?.then((f) => f.close()).catch(() => void 0);
|
|
437
|
+
this.bitmap = null;
|
|
438
|
+
}
|
|
439
|
+
frame() {
|
|
440
|
+
this.bitmap ??= this.load().then((img) => new ImageFrame(img, 0, Number.POSITIVE_INFINITY, "close" in img));
|
|
441
|
+
return this.bitmap;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/** Draws a grey box with a label; used for missing or undecodable assets. */
|
|
445
|
+
var ErrorFrame = class {
|
|
446
|
+
width;
|
|
447
|
+
height;
|
|
448
|
+
label;
|
|
449
|
+
timestamp = 0;
|
|
450
|
+
duration = Number.POSITIVE_INFINITY;
|
|
451
|
+
constructor(width, height, label) {
|
|
452
|
+
this.width = width;
|
|
453
|
+
this.height = height;
|
|
454
|
+
this.label = label;
|
|
455
|
+
}
|
|
456
|
+
draw(ctx, dx, dy, dw, dh) {
|
|
457
|
+
ctx.fillStyle = "#444444";
|
|
458
|
+
ctx.fillRect(dx, dy, dw, dh);
|
|
459
|
+
ctx.fillStyle = "#cccccc";
|
|
460
|
+
ctx.font = "24px monospace";
|
|
461
|
+
ctx.textAlign = "center";
|
|
462
|
+
ctx.textBaseline = "middle";
|
|
463
|
+
ctx.fillText(this.label, dx + dw / 2, dy + dh / 2);
|
|
464
|
+
}
|
|
465
|
+
retain() {
|
|
466
|
+
return this;
|
|
467
|
+
}
|
|
468
|
+
close() {}
|
|
469
|
+
};
|
|
470
|
+
var ErrorFrameSource = class {
|
|
471
|
+
frame;
|
|
472
|
+
constructor(label, width = 640, height = 360) {
|
|
473
|
+
this.frame = new ErrorFrame(width, height, label);
|
|
474
|
+
}
|
|
475
|
+
open() {
|
|
476
|
+
return Promise.resolve({
|
|
477
|
+
width: this.frame.width,
|
|
478
|
+
height: this.frame.height,
|
|
479
|
+
duration: 0,
|
|
480
|
+
decodable: false
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
frameAt() {
|
|
484
|
+
return Promise.resolve(this.frame);
|
|
485
|
+
}
|
|
486
|
+
async *stream() {
|
|
487
|
+
yield this.frame;
|
|
488
|
+
}
|
|
489
|
+
close() {}
|
|
490
|
+
};
|
|
491
|
+
// src/media/asset-sources.ts
|
|
492
|
+
/**
|
|
493
|
+
* One FrameSource per asset, shared by every clip of that asset and by nested
|
|
494
|
+
* compositors. Ref-counted; an unreferenced source stays open for a while
|
|
495
|
+
* (a seek back is cheap) and is closed when idle too long or when too many
|
|
496
|
+
* are open.
|
|
497
|
+
*/
|
|
498
|
+
var AssetSources = class {
|
|
499
|
+
deps;
|
|
500
|
+
entries = /* @__PURE__ */ new Map();
|
|
501
|
+
errored = /* @__PURE__ */ new Set();
|
|
502
|
+
constructor(deps) {
|
|
503
|
+
this.deps = deps;
|
|
504
|
+
}
|
|
505
|
+
/** Acquire a reference; the source opens lazily on first use. */
|
|
506
|
+
acquire(assetId) {
|
|
507
|
+
let e = this.entries.get(assetId);
|
|
508
|
+
if (!e) {
|
|
509
|
+
const source = new LazyFrameSource(() => this.open(assetId));
|
|
510
|
+
e = {
|
|
511
|
+
source,
|
|
512
|
+
info: source.open(),
|
|
513
|
+
refs: 0,
|
|
514
|
+
idleSince: null,
|
|
515
|
+
lastUse: this.deps.clock.now()
|
|
516
|
+
};
|
|
517
|
+
e.info.catch(() => void 0);
|
|
518
|
+
this.entries.set(assetId, e);
|
|
519
|
+
}
|
|
520
|
+
e.refs++;
|
|
521
|
+
e.idleSince = null;
|
|
522
|
+
e.lastUse = this.deps.clock.now();
|
|
523
|
+
return {
|
|
524
|
+
source: e.source,
|
|
525
|
+
info: e.info
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
release(assetId) {
|
|
529
|
+
const e = this.entries.get(assetId);
|
|
530
|
+
if (!e) return;
|
|
531
|
+
e.refs = Math.max(0, e.refs - 1);
|
|
532
|
+
if (e.refs === 0) e.idleSince = this.deps.clock.now();
|
|
533
|
+
}
|
|
534
|
+
/** Close sources idle for too long, then the least recently used idle ones beyond the cap. */
|
|
535
|
+
sweep() {
|
|
536
|
+
const now = this.deps.clock.now();
|
|
537
|
+
for (const [id, e] of this.entries) if (e.idleSince !== null && now - e.idleSince >= 10) this.close(id);
|
|
538
|
+
const idle = [...this.entries].filter(([, e]) => e.idleSince !== null).sort((a, b) => a[1].lastUse - b[1].lastUse);
|
|
539
|
+
let open = this.entries.size;
|
|
540
|
+
for (const [id] of idle) {
|
|
541
|
+
if (open <= 6) break;
|
|
542
|
+
this.close(id);
|
|
543
|
+
open--;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
get openCount() {
|
|
547
|
+
return this.entries.size;
|
|
548
|
+
}
|
|
549
|
+
isOpen(assetId) {
|
|
550
|
+
return this.entries.has(assetId);
|
|
551
|
+
}
|
|
552
|
+
dispose() {
|
|
553
|
+
for (const id of [...this.entries.keys()]) this.close(id);
|
|
554
|
+
}
|
|
555
|
+
close(id) {
|
|
556
|
+
const e = this.entries.get(id);
|
|
557
|
+
if (!e) return;
|
|
558
|
+
e.source.close();
|
|
559
|
+
this.entries.delete(id);
|
|
560
|
+
}
|
|
561
|
+
async open(assetId) {
|
|
562
|
+
const asset = lookupAsset(this.deps.manifest, assetId);
|
|
563
|
+
const fail = async (error) => {
|
|
564
|
+
if (!this.errored.has(assetId)) {
|
|
565
|
+
this.errored.add(assetId);
|
|
566
|
+
this.deps.onError({
|
|
567
|
+
asset: assetId,
|
|
568
|
+
error,
|
|
569
|
+
fatal: false
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const source = new ErrorFrameSource(assetId, asset?.width ?? 640, asset?.height ?? 360);
|
|
573
|
+
return {
|
|
574
|
+
source,
|
|
575
|
+
info: await source.open()
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
try {
|
|
579
|
+
const handle = await this.deps.media.resolve(assetId);
|
|
580
|
+
const source = this.deps.frameSourceFactory(assetId, handle, asset?.kind ?? "video");
|
|
581
|
+
const info = await source.open();
|
|
582
|
+
if (!info.decodable) {
|
|
583
|
+
source.close();
|
|
584
|
+
return fail(/* @__PURE__ */ new Error(`asset "${assetId}" cannot be decoded in this browser`));
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
source,
|
|
588
|
+
info
|
|
589
|
+
};
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return fail(error instanceof Error ? error : new Error(String(error)));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/** Opens the real source once, on first use, and forwards every call to it. */
|
|
596
|
+
var LazyFrameSource = class {
|
|
597
|
+
factory;
|
|
598
|
+
inner = null;
|
|
599
|
+
closed = false;
|
|
600
|
+
constructor(factory) {
|
|
601
|
+
this.factory = factory;
|
|
602
|
+
}
|
|
603
|
+
get() {
|
|
604
|
+
this.inner ??= this.factory();
|
|
605
|
+
return this.inner;
|
|
606
|
+
}
|
|
607
|
+
async open() {
|
|
608
|
+
return (await this.get()).info;
|
|
609
|
+
}
|
|
610
|
+
async frameAt(t) {
|
|
611
|
+
return (await this.get()).source.frameAt(t);
|
|
612
|
+
}
|
|
613
|
+
async *stream(from, to) {
|
|
614
|
+
const { source } = await this.get();
|
|
615
|
+
yield* source.stream(from, to);
|
|
616
|
+
}
|
|
617
|
+
close() {
|
|
618
|
+
if (this.closed) return;
|
|
619
|
+
this.closed = true;
|
|
620
|
+
this.inner?.then((o) => o.source.close()).catch(() => void 0);
|
|
621
|
+
}
|
|
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
|
+
}
|
|
679
|
+
// src/media/clip-decoder.ts
|
|
680
|
+
/** How far the play head may run past the newest queued frame before the stream is restarted from the head. */
|
|
681
|
+
const BEHIND_RESEEK_S = 1;
|
|
682
|
+
/**
|
|
683
|
+
* Decoded frames for one placement. Forward playback streams from the source
|
|
684
|
+
* into a short queue; reverse pre-decodes windows behind the cursor; seek
|
|
685
|
+
* mode (high speeds, scrubbing) fetches single frames. Freezes hold one
|
|
686
|
+
* retained frame. The decoder never blocks a playing tick: when the frame for
|
|
687
|
+
* `t` is not ready it returns the last frame it delivered.
|
|
688
|
+
*/
|
|
689
|
+
var ClipDecoder = class {
|
|
690
|
+
source;
|
|
691
|
+
effectiveSpeed;
|
|
692
|
+
id;
|
|
693
|
+
sourceKey;
|
|
694
|
+
queue = [];
|
|
695
|
+
current = null;
|
|
696
|
+
generation = 0;
|
|
697
|
+
gen = null;
|
|
698
|
+
pumping = null;
|
|
699
|
+
streamEnd = 0;
|
|
700
|
+
seekPending = null;
|
|
701
|
+
reverseLow = Number.POSITIVE_INFINITY;
|
|
702
|
+
reverseLoading = null;
|
|
703
|
+
freezes = /* @__PURE__ */ new Map();
|
|
704
|
+
disposed = false;
|
|
705
|
+
primed = null;
|
|
706
|
+
_placement;
|
|
707
|
+
_children;
|
|
708
|
+
constructor(source, placement, children, effectiveSpeed) {
|
|
709
|
+
this.source = source;
|
|
710
|
+
this.effectiveSpeed = effectiveSpeed;
|
|
711
|
+
this.id = placement.id;
|
|
712
|
+
this.sourceKey = sourceKeyOf(placement);
|
|
713
|
+
this._placement = placement;
|
|
714
|
+
this._children = children;
|
|
715
|
+
}
|
|
716
|
+
/** Asset and direction: everything else (trim, speed, freezes) may change under a live decoder. */
|
|
717
|
+
static sourceKey(p) {
|
|
718
|
+
return sourceKeyOf(p);
|
|
719
|
+
}
|
|
720
|
+
get placement() {
|
|
721
|
+
return this._placement;
|
|
722
|
+
}
|
|
723
|
+
get children() {
|
|
724
|
+
return this._children;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Take a new resolve of the same placement. A trim or speed change moves
|
|
728
|
+
* where source time lands; the queue is kept and the next `frameFor` reseeks
|
|
729
|
+
* only if the new source time falls outside what is queued.
|
|
730
|
+
*/
|
|
731
|
+
update(placement, children) {
|
|
732
|
+
this._placement = placement;
|
|
733
|
+
this._children = children;
|
|
734
|
+
}
|
|
735
|
+
get mode() {
|
|
736
|
+
if (this.placement.props.reverse === true) return "reverse";
|
|
737
|
+
return Math.abs(this.effectiveSpeed()) > 4 ? "seek" : "forward";
|
|
738
|
+
}
|
|
739
|
+
get queued() {
|
|
740
|
+
return this.queue.length;
|
|
741
|
+
}
|
|
742
|
+
get ready() {
|
|
743
|
+
return this.primed ?? Promise.resolve();
|
|
744
|
+
}
|
|
745
|
+
sourceTime(t) {
|
|
746
|
+
return placementSourceTime(this.placement, this.children, t);
|
|
747
|
+
}
|
|
748
|
+
/** Jump to composition time `t`: cancel streaming, fetch the frame, restart from there. */
|
|
749
|
+
prime(t) {
|
|
750
|
+
this.cancelStream();
|
|
751
|
+
const st = this.sourceTime(t);
|
|
752
|
+
const run = async () => {
|
|
753
|
+
const gen = this.generation;
|
|
754
|
+
const frame = await this.source.frameAt(st);
|
|
755
|
+
if (gen !== this.generation || this.disposed) {
|
|
756
|
+
frame?.close();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
this.setCurrent(frame);
|
|
760
|
+
if (this.mode === "forward") this.startStream(st);
|
|
761
|
+
else if (this.mode === "reverse") this.reverseLow = st;
|
|
762
|
+
};
|
|
763
|
+
this.primed = run();
|
|
764
|
+
return this.primed;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* The frame to draw at composition time `t`. With `wait`, fetch it if it is
|
|
768
|
+
* not queued, for at most `FRAME_WAIT_MS`; with `exact` as well, for as long
|
|
769
|
+
* as it takes, so an export never draws a frame it did not wait for.
|
|
770
|
+
*/
|
|
771
|
+
async frameFor(t, wait, exact = false) {
|
|
772
|
+
if (this.disposed) return null;
|
|
773
|
+
const freeze = this.activeFreeze(t);
|
|
774
|
+
if (freeze) return this.freezeFrame(freeze, wait);
|
|
775
|
+
const st = this.sourceTime(t);
|
|
776
|
+
if (this.mode === "seek") return this.seekFrame(st, wait);
|
|
777
|
+
if (this.mode === "reverse") return this.reverseFrame(st, wait);
|
|
778
|
+
return this.forwardFrame(st, wait, exact);
|
|
779
|
+
}
|
|
780
|
+
/** Close consumed frames; called after a frame was painted. */
|
|
781
|
+
advance(t) {
|
|
782
|
+
if (this.mode !== "forward") return;
|
|
783
|
+
const st = this.sourceTime(t);
|
|
784
|
+
while (this.queue.length > 0 && this.queue[0].timestamp + this.queue[0].duration <= st && this.queue.length > 1 && this.queue[1].timestamp <= st + .001) this.queue.shift().close();
|
|
785
|
+
this.pump();
|
|
786
|
+
}
|
|
787
|
+
dispose() {
|
|
788
|
+
this.disposed = true;
|
|
789
|
+
this.cancelStream();
|
|
790
|
+
this.setCurrent(null);
|
|
791
|
+
for (const p of this.freezes.values()) p.then((f) => f?.close());
|
|
792
|
+
this.freezes.clear();
|
|
793
|
+
}
|
|
794
|
+
async forwardFrame(st, wait, exact = false) {
|
|
795
|
+
const newest = this.queue[this.queue.length - 1] ?? this.current;
|
|
796
|
+
const behind = newest !== null && st - (newest.timestamp + newest.duration) > BEHIND_RESEEK_S;
|
|
797
|
+
const before = this.current !== null && st + .001 < this.current.timestamp && !this.queue.some((f) => f.timestamp <= st + .001);
|
|
798
|
+
if (this.gen === null && this.queue.length === 0 && this.current === null) {
|
|
799
|
+
if (wait) {
|
|
800
|
+
await this.primeAtSource(st);
|
|
801
|
+
return this.current;
|
|
802
|
+
}
|
|
803
|
+
this.primeAtSource(st);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
if (behind || before) {
|
|
807
|
+
if (wait) {
|
|
808
|
+
await this.primeAtSource(st);
|
|
809
|
+
return this.current;
|
|
810
|
+
}
|
|
811
|
+
this.primeAtSource(st);
|
|
812
|
+
return this.current;
|
|
813
|
+
}
|
|
814
|
+
if (!wait) {
|
|
815
|
+
const picked = this.forwardPick(st);
|
|
816
|
+
this.pump();
|
|
817
|
+
return picked ?? this.current;
|
|
818
|
+
}
|
|
819
|
+
const deadline = exact ? Number.POSITIVE_INFINITY : Date.now() + 250;
|
|
820
|
+
for (;;) {
|
|
821
|
+
const picked = this.forwardPick(st);
|
|
822
|
+
const next = this.queue[0];
|
|
823
|
+
if (next !== void 0 && next.timestamp > st + .001 || this.gen === null || picked !== null && picked.timestamp + picked.duration > st + .001 || Date.now() >= deadline || this.disposed) {
|
|
824
|
+
this.pump();
|
|
825
|
+
return picked ?? this.current;
|
|
826
|
+
}
|
|
827
|
+
await this.pumpOnce();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/** The newest queued frame at or before `st` becomes current; older ones are consumed. */
|
|
831
|
+
forwardPick(st) {
|
|
832
|
+
let best = null;
|
|
833
|
+
let index = -1;
|
|
834
|
+
for (let i = 0; i < this.queue.length; i++) if (this.queue[i].timestamp <= st + .001) {
|
|
835
|
+
best = this.queue[i];
|
|
836
|
+
index = i;
|
|
837
|
+
} else break;
|
|
838
|
+
if (!best) return null;
|
|
839
|
+
for (let i = 0; i < index; i++) this.queue[i].close();
|
|
840
|
+
this.queue.splice(0, index + 1);
|
|
841
|
+
this.setCurrent(best);
|
|
842
|
+
return best;
|
|
843
|
+
}
|
|
844
|
+
primeAtSource(st) {
|
|
845
|
+
this.cancelStream();
|
|
846
|
+
const gen = this.generation;
|
|
847
|
+
this.primed = this.source.frameAt(st).then((frame) => {
|
|
848
|
+
if (gen !== this.generation || this.disposed) {
|
|
849
|
+
frame?.close();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
this.setCurrent(frame);
|
|
853
|
+
this.startStream(st);
|
|
854
|
+
});
|
|
855
|
+
return this.primed;
|
|
856
|
+
}
|
|
857
|
+
startStream(from) {
|
|
858
|
+
this.streamEnd = Number.POSITIVE_INFINITY;
|
|
859
|
+
this.gen = this.source.stream(from, this.streamEnd);
|
|
860
|
+
this.pump();
|
|
861
|
+
}
|
|
862
|
+
pump() {
|
|
863
|
+
if (this.pumping || this.gen === null || this.queue.length >= 8 || this.disposed) return;
|
|
864
|
+
this.pumpOnce();
|
|
865
|
+
}
|
|
866
|
+
/** Pull frames until the queue is at target; a caller that finds a pump running awaits that one. */
|
|
867
|
+
pumpOnce() {
|
|
868
|
+
if (this.pumping) return this.pumping;
|
|
869
|
+
if (this.gen === null) return Promise.resolve();
|
|
870
|
+
const gen = this.gen, generation = this.generation;
|
|
871
|
+
const run = async () => {
|
|
872
|
+
try {
|
|
873
|
+
while (this.queue.length < 8 && this.gen === gen) {
|
|
874
|
+
let next;
|
|
875
|
+
try {
|
|
876
|
+
next = await gen.next();
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (generation !== this.generation || this.disposed) return;
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
if (generation !== this.generation || this.disposed) {
|
|
882
|
+
if (!next.done) next.value.close();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (next.done) {
|
|
886
|
+
this.gen = null;
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const frame = next.value;
|
|
890
|
+
const last = this.queue[this.queue.length - 1];
|
|
891
|
+
if (last && frame.timestamp <= last.timestamp) {
|
|
892
|
+
frame.close();
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
this.queue.push(frame);
|
|
896
|
+
while (this.queue.length > 10) this.queue.shift().close();
|
|
897
|
+
}
|
|
898
|
+
} finally {
|
|
899
|
+
this.pumping = null;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
this.pumping = run();
|
|
903
|
+
return this.pumping;
|
|
904
|
+
}
|
|
905
|
+
seekFrame(st, wait) {
|
|
906
|
+
if (this.current && this.current.timestamp <= st + .001 && st + .001 < this.current.timestamp + this.current.duration) return Promise.resolve(this.current);
|
|
907
|
+
if (!this.seekPending) {
|
|
908
|
+
const gen = this.generation;
|
|
909
|
+
this.seekPending = this.source.frameAt(st).then((frame) => {
|
|
910
|
+
this.seekPending = null;
|
|
911
|
+
if (gen !== this.generation || this.disposed) {
|
|
912
|
+
frame?.close();
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
this.setCurrent(frame);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
return wait ? this.seekPending.then(() => this.current) : Promise.resolve(this.current);
|
|
919
|
+
}
|
|
920
|
+
async reverseFrame(st, wait) {
|
|
921
|
+
const pick = () => {
|
|
922
|
+
let best = null;
|
|
923
|
+
let index = -1;
|
|
924
|
+
for (let i = 0; i < this.queue.length; i++) if (this.queue[i].timestamp <= st) {
|
|
925
|
+
best = this.queue[i];
|
|
926
|
+
index = i;
|
|
927
|
+
}
|
|
928
|
+
if (!best) return null;
|
|
929
|
+
for (let i = index + 1; i < this.queue.length; i++) {
|
|
930
|
+
const f = this.queue[i];
|
|
931
|
+
if (f === this.current) this.current = null;
|
|
932
|
+
f.close();
|
|
933
|
+
}
|
|
934
|
+
this.queue.length = index + 1;
|
|
935
|
+
this.setCurrent(best);
|
|
936
|
+
return best;
|
|
937
|
+
};
|
|
938
|
+
const have = pick();
|
|
939
|
+
const needEarlier = this.queue.length === 0 || this.queue[0].timestamp > st - 1e-6 || st < this.reverseLow;
|
|
940
|
+
const src = this.placement.source;
|
|
941
|
+
const low = Math.min(this.reverseLow, this.queue[0]?.timestamp ?? Number.POSITIVE_INFINITY);
|
|
942
|
+
if (needEarlier && src && low > src.in && !this.reverseLoading) this.reverseLoading = this.loadReverseWindow(Math.min(low, st + 1e-6), src.in);
|
|
943
|
+
if (have && (!needEarlier || !wait)) return have;
|
|
944
|
+
if (wait && this.reverseLoading) {
|
|
945
|
+
await this.reverseLoading;
|
|
946
|
+
return pick() ?? this.current;
|
|
947
|
+
}
|
|
948
|
+
return have ?? this.current;
|
|
949
|
+
}
|
|
950
|
+
async loadReverseWindow(to, floor) {
|
|
951
|
+
const gen = this.generation;
|
|
952
|
+
const from = Math.max(floor, to - 1);
|
|
953
|
+
const frames = [];
|
|
954
|
+
try {
|
|
955
|
+
for await (const f of this.source.stream(from, to)) {
|
|
956
|
+
if (gen !== this.generation || this.disposed) {
|
|
957
|
+
f.close();
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (f.timestamp >= to) {
|
|
961
|
+
f.close();
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
frames.push(f);
|
|
965
|
+
if (frames.length > 16) frames.shift().close();
|
|
966
|
+
}
|
|
967
|
+
if (gen !== this.generation || this.disposed) {
|
|
968
|
+
frames.forEach((f) => f.close());
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
this.queue = [...frames, ...this.queue];
|
|
972
|
+
this.reverseLow = from;
|
|
973
|
+
} finally {
|
|
974
|
+
this.reverseLoading = null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
activeFreeze(t) {
|
|
978
|
+
const rel = t - this.placement.start;
|
|
979
|
+
for (const c of this.children) {
|
|
980
|
+
if (c.kind !== "freeze") continue;
|
|
981
|
+
const fs = Number(c.props.start ?? 0), fd = Number(c.props.duration ?? 0);
|
|
982
|
+
if (rel >= fs && rel < fs + fd) return c;
|
|
983
|
+
}
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
freezeFrame(freeze, wait) {
|
|
987
|
+
let p = this.freezes.get(freeze.id);
|
|
988
|
+
if (!p) {
|
|
989
|
+
p = this.source.frameAt(Number(freeze.props.at ?? 0)).then((f) => {
|
|
990
|
+
if (!f) return null;
|
|
991
|
+
const held = f.retain();
|
|
992
|
+
if (held !== f) f.close();
|
|
993
|
+
return held;
|
|
994
|
+
});
|
|
995
|
+
this.freezes.set(freeze.id, p);
|
|
996
|
+
}
|
|
997
|
+
return wait ? p : Promise.race([p, Promise.resolve(this.current)]);
|
|
998
|
+
}
|
|
999
|
+
setCurrent(frame) {
|
|
1000
|
+
if (this.current && this.current !== frame && !this.queue.includes(this.current)) this.current.close();
|
|
1001
|
+
this.current = frame;
|
|
1002
|
+
}
|
|
1003
|
+
cancelStream() {
|
|
1004
|
+
this.generation++;
|
|
1005
|
+
const gen = this.gen;
|
|
1006
|
+
this.gen = null;
|
|
1007
|
+
if (gen) gen.return(void 0).catch(() => void 0);
|
|
1008
|
+
for (const f of this.queue) if (f !== this.current) f.close();
|
|
1009
|
+
this.queue = [];
|
|
1010
|
+
this.reverseLow = Number.POSITIVE_INFINITY;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
function sourceKeyOf(p) {
|
|
1014
|
+
const s = p.source;
|
|
1015
|
+
return s ? `${s.asset}|${p.props.reverse === true ? "r" : "f"}` : p.id;
|
|
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
|
+
}
|
|
1086
|
+
// src/media/decoder-pool.ts
|
|
1087
|
+
/**
|
|
1088
|
+
* Keeps a ClipDecoder for every media placement near the play head and drops
|
|
1089
|
+
* the rest. Asset sources are shared through `AssetSources`, so nested
|
|
1090
|
+
* compositors handed the same registry never open an asset twice.
|
|
1091
|
+
*/
|
|
1092
|
+
var DecoderPool = class {
|
|
1093
|
+
sources;
|
|
1094
|
+
decoders = /* @__PURE__ */ new Map();
|
|
1095
|
+
rate = 1;
|
|
1096
|
+
constructor(sources) {
|
|
1097
|
+
this.sources = sources;
|
|
1098
|
+
}
|
|
1099
|
+
setRate(rate) {
|
|
1100
|
+
this.rate = rate;
|
|
1101
|
+
}
|
|
1102
|
+
/** Mount decoders for placements within the window around `t`; unmount the rest. */
|
|
1103
|
+
sync(state, t) {
|
|
1104
|
+
const want = /* @__PURE__ */ new Map();
|
|
1105
|
+
for (const track of state.tracks) {
|
|
1106
|
+
if (track.hidden) continue;
|
|
1107
|
+
for (const p of trackItems(state, track)) {
|
|
1108
|
+
if (p.kind !== "clip" || !p.source) continue;
|
|
1109
|
+
if (!lookupAsset(state.manifest, p.source.asset)) continue;
|
|
1110
|
+
if (t < p.start - 2 || t >= p.end + .5) continue;
|
|
1111
|
+
want.set(p.id, {
|
|
1112
|
+
p,
|
|
1113
|
+
children: p.children.map((id) => state.items.get(id)).filter((c) => c !== void 0)
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
for (const [id, d] of this.decoders) {
|
|
1118
|
+
const w = want.get(id);
|
|
1119
|
+
if (!w || ClipDecoder.sourceKey(w.p) !== d.sourceKey) this.unmount(id);
|
|
1120
|
+
else if (w.p !== d.placement) d.update(w.p, w.children);
|
|
1121
|
+
}
|
|
1122
|
+
for (const [id, w] of want) {
|
|
1123
|
+
if (this.decoders.has(id)) continue;
|
|
1124
|
+
const { source } = this.sources.acquire(w.p.source.asset);
|
|
1125
|
+
const d = new ClipDecoder(source, w.p, w.children, () => w.p.source.speed * this.rate);
|
|
1126
|
+
this.decoders.set(id, d);
|
|
1127
|
+
d.prime(Math.max(t, w.p.start));
|
|
1128
|
+
}
|
|
1129
|
+
this.sources.sweep();
|
|
1130
|
+
}
|
|
1131
|
+
decoderFor(id) {
|
|
1132
|
+
return this.decoders.get(id);
|
|
1133
|
+
}
|
|
1134
|
+
get mounted() {
|
|
1135
|
+
return [...this.decoders.keys()];
|
|
1136
|
+
}
|
|
1137
|
+
/** After a seek: every decoder jumps to `t`. */
|
|
1138
|
+
seekAll(t) {
|
|
1139
|
+
return Promise.all([...this.decoders.values()].map((d) => d.prime(Math.max(t, d.placement.start)))).then(() => void 0);
|
|
1140
|
+
}
|
|
1141
|
+
/** Resolves when every decoder has its first frame. */
|
|
1142
|
+
settle() {
|
|
1143
|
+
return Promise.all([...this.decoders.values()].map((d) => d.ready)).then(() => void 0);
|
|
1144
|
+
}
|
|
1145
|
+
dispose() {
|
|
1146
|
+
for (const id of [...this.decoders.keys()]) this.unmount(id);
|
|
1147
|
+
}
|
|
1148
|
+
unmount(id) {
|
|
1149
|
+
const d = this.decoders.get(id);
|
|
1150
|
+
if (!d) return;
|
|
1151
|
+
d.dispose();
|
|
1152
|
+
this.decoders.delete(id);
|
|
1153
|
+
if (d.placement.source) this.sources.release(d.placement.source.asset);
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
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));
|
|
1180
|
+
}
|
|
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;
|
|
1200
|
+
}
|
|
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)));
|
|
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;
|
|
1245
|
+
}
|
|
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
|
+
};
|
|
1270
|
+
}
|
|
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;
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
function mediabunnyAudioSourceFactory(inputs) {
|
|
1296
|
+
return (assetId, handle, context) => new MediabunnyAudioSource(assetId, handle, inputs, context);
|
|
1297
|
+
}
|
|
1298
|
+
// src/media/mediabunny-frame-source.ts
|
|
1299
|
+
/** A decoded `VideoSample`: GPU-backed, drawn straight to the context, closed when consumed. */
|
|
1300
|
+
var SampleFrame = class {
|
|
1301
|
+
sample;
|
|
1302
|
+
createCanvas;
|
|
1303
|
+
timestamp;
|
|
1304
|
+
duration;
|
|
1305
|
+
width;
|
|
1306
|
+
height;
|
|
1307
|
+
constructor(sample, createCanvas) {
|
|
1308
|
+
this.sample = sample;
|
|
1309
|
+
this.createCanvas = createCanvas;
|
|
1310
|
+
this.timestamp = sample.timestamp;
|
|
1311
|
+
this.duration = sample.duration;
|
|
1312
|
+
this.width = sample.displayWidth;
|
|
1313
|
+
this.height = sample.displayHeight;
|
|
1314
|
+
}
|
|
1315
|
+
draw(ctx, dx, dy, dw, dh, crop) {
|
|
1316
|
+
if (crop) this.sample.draw(ctx, crop.x, crop.y, crop.w, crop.h, dx, dy, dw, dh);
|
|
1317
|
+
else this.sample.draw(ctx, dx, dy, dw, dh);
|
|
1318
|
+
}
|
|
1319
|
+
retain() {
|
|
1320
|
+
return copyFrame(this, this.createCanvas);
|
|
1321
|
+
}
|
|
1322
|
+
close() {
|
|
1323
|
+
this.sample.close();
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
/** Frames of one video asset through a shared `VideoSampleSink`. */
|
|
1327
|
+
var MediabunnyFrameSource = class {
|
|
1328
|
+
assetId;
|
|
1329
|
+
handle;
|
|
1330
|
+
inputs;
|
|
1331
|
+
createCanvas;
|
|
1332
|
+
input = null;
|
|
1333
|
+
track = null;
|
|
1334
|
+
sink = null;
|
|
1335
|
+
stillSink = null;
|
|
1336
|
+
constructor(assetId, handle, inputs, createCanvas) {
|
|
1337
|
+
this.assetId = assetId;
|
|
1338
|
+
this.handle = handle;
|
|
1339
|
+
this.inputs = inputs;
|
|
1340
|
+
this.createCanvas = createCanvas;
|
|
1341
|
+
}
|
|
1342
|
+
wrap(sample) {
|
|
1343
|
+
return new SampleFrame(sample, this.createCanvas);
|
|
1344
|
+
}
|
|
1345
|
+
async open() {
|
|
1346
|
+
this.input = this.inputs.acquire(this.assetId, this.handle);
|
|
1347
|
+
const track = await this.input.getPrimaryVideoTrack();
|
|
1348
|
+
if (!track) return {
|
|
1349
|
+
width: 0,
|
|
1350
|
+
height: 0,
|
|
1351
|
+
duration: 0,
|
|
1352
|
+
decodable: false
|
|
1353
|
+
};
|
|
1354
|
+
const decodable = await track.canDecode();
|
|
1355
|
+
const [width, height, duration] = await Promise.all([
|
|
1356
|
+
track.getDisplayWidth(),
|
|
1357
|
+
track.getDisplayHeight(),
|
|
1358
|
+
track.computeDuration()
|
|
1359
|
+
]);
|
|
1360
|
+
this.track = track;
|
|
1361
|
+
this.sink = new VideoSampleSink(track, {
|
|
1362
|
+
hardwareAcceleration: "no-preference",
|
|
1363
|
+
optimizeForLatency: true
|
|
1364
|
+
});
|
|
1365
|
+
return {
|
|
1366
|
+
width,
|
|
1367
|
+
height,
|
|
1368
|
+
duration,
|
|
1369
|
+
decodable
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
async frameAt(sourceTime) {
|
|
1373
|
+
if (!this.sink) return null;
|
|
1374
|
+
const sample = await this.sink.getSample(Math.max(0, sourceTime) + PICK_EPS);
|
|
1375
|
+
return sample ? this.wrap(sample) : null;
|
|
1376
|
+
}
|
|
1377
|
+
async *stream(from, to) {
|
|
1378
|
+
if (!this.sink) return;
|
|
1379
|
+
for await (const sample of this.sink.samples(Math.max(0, from), to)) yield await this.wrap(sample);
|
|
1380
|
+
}
|
|
1381
|
+
/** A canvas copy of the frame at `sourceTime`, for freeze frames; pooled canvases are copied right away. */
|
|
1382
|
+
async still(sourceTime) {
|
|
1383
|
+
if (!this.track) return null;
|
|
1384
|
+
this.stillSink ??= new CanvasSink(this.track, { poolSize: 2 });
|
|
1385
|
+
const wrapped = await this.stillSink.getCanvas(Math.max(0, sourceTime));
|
|
1386
|
+
if (!wrapped) return null;
|
|
1387
|
+
const canvas = this.createCanvas(wrapped.canvas.width, wrapped.canvas.height);
|
|
1388
|
+
canvas.getContext("2d")?.drawImage(wrapped.canvas, 0, 0);
|
|
1389
|
+
return copyFrame({
|
|
1390
|
+
timestamp: wrapped.timestamp,
|
|
1391
|
+
duration: wrapped.duration,
|
|
1392
|
+
width: canvas.width,
|
|
1393
|
+
height: canvas.height,
|
|
1394
|
+
draw: (ctx, dx, dy, dw, dh) => ctx.drawImage(canvas, dx, dy, dw, dh),
|
|
1395
|
+
retain: () => {
|
|
1396
|
+
throw new Error("unreachable");
|
|
1397
|
+
},
|
|
1398
|
+
close: () => void 0
|
|
1399
|
+
}, this.createCanvas);
|
|
1400
|
+
}
|
|
1401
|
+
close() {
|
|
1402
|
+
this.sink = null;
|
|
1403
|
+
this.stillSink = null;
|
|
1404
|
+
this.track = null;
|
|
1405
|
+
if (this.input) this.inputs.release(this.assetId);
|
|
1406
|
+
this.input = null;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
async function loadImage(handle) {
|
|
1410
|
+
if (handle.kind === "path") throw new Error(`a browser cannot read the path ${handle.path}; give it a URL`);
|
|
1411
|
+
const blob = handle.kind === "blob" ? handle.blob : await (await fetch(handle.url)).blob();
|
|
1412
|
+
return createImageBitmap(blob);
|
|
1413
|
+
}
|
|
1414
|
+
function mediabunnyFrameSourceFactory(inputs, createCanvas) {
|
|
1415
|
+
return (assetId, handle, kind) => kind === "image" ? new ImageFrameSource(() => loadImage(handle)) : new MediabunnyFrameSource(assetId, handle, inputs, createCanvas);
|
|
1416
|
+
}
|
|
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);
|
|
1434
|
+
}
|
|
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();
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
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
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
// src/render/masks.ts
|
|
2375
|
+
/**
|
|
2376
|
+
* Trace the mask outline onto the context's current path, in local element
|
|
2377
|
+
* pixels. Shape coordinates are fractions of the element box; bezier paths
|
|
2378
|
+
* are in pixels with handles. Returns false when the spec has no usable shape.
|
|
2379
|
+
*/
|
|
2380
|
+
function traceMask(ctx, mask, rect) {
|
|
2381
|
+
const w = rect.w, h = rect.h;
|
|
2382
|
+
const shape = mask.shape ?? {};
|
|
2383
|
+
if ("rect" in shape && shape.rect) {
|
|
2384
|
+
const r = shape.rect;
|
|
2385
|
+
const x = r.x * w, y = r.y * h, rw = r.width * w, rh = r.height * h;
|
|
2386
|
+
if (r.cornerRadius && r.cornerRadius > 0) roundedRect(ctx, x, y, rw, rh, r.cornerRadius);
|
|
2387
|
+
else ctx.rect(x, y, rw, rh);
|
|
2388
|
+
return true;
|
|
2389
|
+
}
|
|
2390
|
+
if ("ellipse" in shape && shape.ellipse) {
|
|
2391
|
+
const e = shape.ellipse;
|
|
2392
|
+
ctx.ellipse(e.cx * w, e.cy * h, e.rx * w, e.ry * h, 0, 0, Math.PI * 2);
|
|
2393
|
+
return true;
|
|
2394
|
+
}
|
|
2395
|
+
if ("polygon" in shape && Array.isArray(shape.polygon)) {
|
|
2396
|
+
shape.polygon.forEach((p, i) => i === 0 ? ctx.moveTo(p.x * w, p.y * h) : ctx.lineTo(p.x * w, p.y * h));
|
|
2397
|
+
ctx.closePath();
|
|
2398
|
+
return true;
|
|
2399
|
+
}
|
|
2400
|
+
if (mask.path && mask.path.points.length >= 2) {
|
|
2401
|
+
const pts = mask.path.points;
|
|
2402
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
2403
|
+
for (let i = 1; i <= (mask.path.closed ? pts.length : pts.length - 1); i++) {
|
|
2404
|
+
const a = pts[(i - 1) % pts.length], b = pts[i % pts.length];
|
|
2405
|
+
const c1 = a.handleOut ?? a, c2 = b.handleIn ?? b;
|
|
2406
|
+
ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, b.x, b.y);
|
|
2407
|
+
}
|
|
2408
|
+
if (mask.path.closed) ctx.closePath();
|
|
2409
|
+
return true;
|
|
2410
|
+
}
|
|
2411
|
+
return false;
|
|
2412
|
+
}
|
|
2413
|
+
function roundedRect(ctx, x, y, w, h, r) {
|
|
2414
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
2415
|
+
ctx.moveTo(x + rr, y);
|
|
2416
|
+
ctx.lineTo(x + w - rr, y);
|
|
2417
|
+
ctx.arcTo(x + w, y, x + w, y + rr, rr);
|
|
2418
|
+
ctx.lineTo(x + w, y + h - rr);
|
|
2419
|
+
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
|
|
2420
|
+
ctx.lineTo(x + rr, y + h);
|
|
2421
|
+
ctx.arcTo(x, y + h, x, y + h - rr, rr);
|
|
2422
|
+
ctx.lineTo(x, y + rr);
|
|
2423
|
+
ctx.arcTo(x, y, x + rr, y, rr);
|
|
2424
|
+
ctx.closePath();
|
|
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
|
+
};
|
|
2621
|
+
// src/render/painter.ts
|
|
2622
|
+
/**
|
|
2623
|
+
* Executes a FramePlan on a 2D context of composition size. Each op is
|
|
2624
|
+
* drawn under its own transform, alpha, blend and filter; masks clip; an
|
|
2625
|
+
* adjustment op re-filters everything drawn so far through a scratch canvas.
|
|
2626
|
+
*/
|
|
2627
|
+
var CanvasPainter = class {
|
|
2628
|
+
width;
|
|
2629
|
+
height;
|
|
2630
|
+
createCanvas;
|
|
2631
|
+
scratch = null;
|
|
2632
|
+
/** An element drawn on its own before compositing: a feathered mask, vignette, grain. */
|
|
2633
|
+
layer = null;
|
|
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;
|
|
2640
|
+
constructor(width, height, createCanvas) {
|
|
2641
|
+
this.width = width;
|
|
2642
|
+
this.height = height;
|
|
2643
|
+
this.createCanvas = createCanvas;
|
|
2644
|
+
}
|
|
2645
|
+
paint(ctx, plan, inputs) {
|
|
2646
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2647
|
+
ctx.globalAlpha = 1;
|
|
2648
|
+
ctx.globalCompositeOperation = "source-over";
|
|
2649
|
+
ctx.filter = "none";
|
|
2650
|
+
if (plan.clear) ctx.clearRect(0, 0, this.width, this.height);
|
|
2651
|
+
else paintBackground(ctx, plan, this.width, this.height);
|
|
2652
|
+
if (plan.backgroundBlur) this.blurBackground(ctx, plan, plan.backgroundBlur.id, inputs);
|
|
2653
|
+
this.outgoing = null;
|
|
2654
|
+
for (const op of plan.ops) if (op.kind === "adjustment") this.adjust(ctx, op);
|
|
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;
|
|
2659
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2660
|
+
}
|
|
2661
|
+
drawOp(ctx, op, inputs) {
|
|
2662
|
+
const frame = op.kind === "nested" ? null : inputs.frames.get(op.id) ?? null;
|
|
2663
|
+
const nested = op.kind === "nested" ? inputs.nested.get(op.id) ?? null : null;
|
|
2664
|
+
if (op.kind !== "nested" && !frame) return;
|
|
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);
|
|
2671
|
+
ctx.save();
|
|
2672
|
+
ctx.globalAlpha = op.opacity;
|
|
2673
|
+
ctx.globalCompositeOperation = op.blend;
|
|
2674
|
+
ctx.filter = op.filter || "none";
|
|
2675
|
+
clipFrame(ctx, op);
|
|
2676
|
+
setMatrix(ctx, op.matrix);
|
|
2677
|
+
const clipped = this.clip(ctx, op);
|
|
2678
|
+
const feathered = !!op.mask && op.mask.feather > 0 && clipped;
|
|
2679
|
+
if (feathered || op.post) {
|
|
2680
|
+
ctx.restore();
|
|
2681
|
+
this.drawLayered(ctx, op, frame, nested, feathered);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
this.blit(ctx, op, frame, nested);
|
|
2685
|
+
ctx.restore();
|
|
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
|
+
}
|
|
2729
|
+
/** The footage's picture, blurred and scaled to cover the frame, under everything; skipped when the footage covers the frame itself. */
|
|
2730
|
+
blurBackground(ctx, plan, id, inputs) {
|
|
2731
|
+
const op = plan.ops.find((o) => o.id === id);
|
|
2732
|
+
if (!op || coversCanvas(op, this.width, this.height)) return;
|
|
2733
|
+
const nested = op.kind === "nested" ? inputs.nested.get(id) ?? null : null;
|
|
2734
|
+
const frame = op.kind === "nested" ? null : inputs.frames.get(id) ?? null;
|
|
2735
|
+
const picture = nested ?? frame;
|
|
2736
|
+
if (!picture) return;
|
|
2737
|
+
const r = Math.round(.04 * this.height);
|
|
2738
|
+
const { x, y, w, h } = fitRect(this, picture, "cover");
|
|
2739
|
+
ctx.save();
|
|
2740
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2741
|
+
ctx.filter = `blur(${r}px)`;
|
|
2742
|
+
if (nested) ctx.drawImage(nested, x - 2 * r, y - 2 * r, w + 4 * r, h + 4 * r);
|
|
2743
|
+
else frame.draw(ctx, x - 2 * r, y - 2 * r, w + 4 * r, h + 4 * r);
|
|
2744
|
+
ctx.restore();
|
|
2745
|
+
}
|
|
2746
|
+
blit(ctx, op, frame, nested) {
|
|
2747
|
+
const { w, h } = op.rect;
|
|
2748
|
+
if (nested) ctx.drawImage(nested, 0, 0, w, h);
|
|
2749
|
+
else if (frame) {
|
|
2750
|
+
const reference = op.cropReference;
|
|
2751
|
+
const crop = op.crop && reference ? {
|
|
2752
|
+
x: op.crop.x * frame.width / reference.width,
|
|
2753
|
+
y: op.crop.y * frame.height / reference.height,
|
|
2754
|
+
w: op.crop.w * frame.width / reference.width,
|
|
2755
|
+
h: op.crop.h * frame.height / reference.height
|
|
2756
|
+
} : op.crop;
|
|
2757
|
+
frame.draw(ctx, 0, 0, w, h, crop);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
/** Applies the border radius and mask as a clip in local coordinates. Returns true when a mask clip was set. */
|
|
2761
|
+
clip(ctx, op) {
|
|
2762
|
+
if (op.borderRadius > 0) {
|
|
2763
|
+
ctx.beginPath();
|
|
2764
|
+
roundedRect(ctx, 0, 0, op.rect.w, op.rect.h, op.borderRadius);
|
|
2765
|
+
ctx.clip();
|
|
2766
|
+
}
|
|
2767
|
+
if (!op.mask) return false;
|
|
2768
|
+
ctx.beginPath();
|
|
2769
|
+
if (!traceMask(ctx, op.mask, op.rect)) return false;
|
|
2770
|
+
if (op.mask.inverted) {
|
|
2771
|
+
ctx.rect(0, 0, op.rect.w, op.rect.h);
|
|
2772
|
+
ctx.clip("evenodd");
|
|
2773
|
+
} else ctx.clip();
|
|
2774
|
+
return true;
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* The element alone on the layer canvas, then composited: a feathered mask punches it out with a
|
|
2778
|
+
* blurred alpha there, and vignette and grain darken or speckle only its own pixels.
|
|
2779
|
+
*/
|
|
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) {
|
|
2786
|
+
this.layer ??= this.createCanvas(this.width, this.height);
|
|
2787
|
+
const layer = this.layer;
|
|
2788
|
+
const lctx = layer.getContext("2d");
|
|
2789
|
+
if (!lctx) return null;
|
|
2790
|
+
lctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2791
|
+
lctx.clearRect(0, 0, this.width, this.height);
|
|
2792
|
+
lctx.save();
|
|
2793
|
+
setMatrix(lctx, op.matrix);
|
|
2794
|
+
if (!feathered) this.clip(lctx, op);
|
|
2795
|
+
this.blit(lctx, op, frame, nested);
|
|
2796
|
+
lctx.restore();
|
|
2797
|
+
if (feathered) {
|
|
2798
|
+
lctx.save();
|
|
2799
|
+
lctx.globalCompositeOperation = "destination-in";
|
|
2800
|
+
lctx.filter = `blur(${op.mask.feather}px)`;
|
|
2801
|
+
setMatrix(lctx, op.matrix);
|
|
2802
|
+
lctx.beginPath();
|
|
2803
|
+
traceMask(lctx, op.mask, op.rect);
|
|
2804
|
+
if (op.mask.inverted) {
|
|
2805
|
+
lctx.rect(0, 0, op.rect.w, op.rect.h);
|
|
2806
|
+
lctx.fill("evenodd");
|
|
2807
|
+
} else lctx.fill();
|
|
2808
|
+
lctx.restore();
|
|
2809
|
+
}
|
|
2810
|
+
for (const fx of op.post ?? []) {
|
|
2811
|
+
lctx.save();
|
|
2812
|
+
lctx.globalCompositeOperation = "source-atop";
|
|
2813
|
+
if (fx.type === "vignette") {
|
|
2814
|
+
setMatrix(lctx, op.matrix);
|
|
2815
|
+
const { w, h } = op.rect;
|
|
2816
|
+
const r = Math.hypot(w, h) / 2;
|
|
2817
|
+
const g = lctx.createRadialGradient(w / 2, h / 2, r * .4, w / 2, h / 2, r);
|
|
2818
|
+
g.addColorStop(0, "rgba(0,0,0,0)");
|
|
2819
|
+
g.addColorStop(1, `rgba(0,0,0,${fx.amount})`);
|
|
2820
|
+
lctx.fillStyle = g;
|
|
2821
|
+
lctx.fillRect(0, 0, w, h);
|
|
2822
|
+
} else {
|
|
2823
|
+
const pattern = lctx.createPattern(this.noise(fx.seed, fx.amount), "repeat");
|
|
2824
|
+
if (pattern) {
|
|
2825
|
+
lctx.fillStyle = pattern;
|
|
2826
|
+
lctx.fillRect(0, 0, this.width, this.height);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
lctx.restore();
|
|
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) {
|
|
2835
|
+
ctx.save();
|
|
2836
|
+
ctx.globalAlpha = op.opacity;
|
|
2837
|
+
ctx.globalCompositeOperation = op.blend;
|
|
2838
|
+
ctx.filter = op.filter || "none";
|
|
2839
|
+
clipFrame(ctx, op);
|
|
2840
|
+
if (op.borderRadius > 0) {
|
|
2841
|
+
setMatrix(ctx, op.matrix);
|
|
2842
|
+
ctx.beginPath();
|
|
2843
|
+
roundedRect(ctx, 0, 0, op.rect.w, op.rect.h, op.borderRadius);
|
|
2844
|
+
ctx.clip();
|
|
2845
|
+
}
|
|
2846
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2847
|
+
ctx.drawImage(layer, 0, 0);
|
|
2848
|
+
ctx.restore();
|
|
2849
|
+
}
|
|
2850
|
+
/** A tile of black and white specks, the same for the same seed; alpha up to `amount`. */
|
|
2851
|
+
noise(seed, amount) {
|
|
2852
|
+
this.grain ??= this.createCanvas(GRAIN_TILE, GRAIN_TILE);
|
|
2853
|
+
const gctx = this.grain.getContext("2d");
|
|
2854
|
+
if (!gctx) return this.grain;
|
|
2855
|
+
const img = gctx.createImageData(GRAIN_TILE, GRAIN_TILE);
|
|
2856
|
+
const rand = mulberry32(seed);
|
|
2857
|
+
for (let i = 0; i < img.data.length; i += 4) {
|
|
2858
|
+
const r = rand();
|
|
2859
|
+
const v = r < .5 ? 0 : 255;
|
|
2860
|
+
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
|
|
2861
|
+
img.data[i + 3] = Math.round(Math.abs(r - .5) * 2 * amount * 255);
|
|
2862
|
+
}
|
|
2863
|
+
gctx.putImageData(img, 0, 0);
|
|
2864
|
+
return this.grain;
|
|
2865
|
+
}
|
|
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
|
+
}
|
|
2874
|
+
if (!op.filter && op.opacity >= 1) return;
|
|
2875
|
+
this.scratch ??= this.createCanvas(this.width, this.height);
|
|
2876
|
+
const sctx = this.scratch.getContext("2d");
|
|
2877
|
+
if (!sctx) return;
|
|
2878
|
+
sctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2879
|
+
sctx.globalCompositeOperation = "copy";
|
|
2880
|
+
sctx.filter = "none";
|
|
2881
|
+
sctx.globalAlpha = 1;
|
|
2882
|
+
sctx.drawImage(canvasOf(ctx), 0, 0);
|
|
2883
|
+
ctx.save();
|
|
2884
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2885
|
+
ctx.globalCompositeOperation = "source-over";
|
|
2886
|
+
ctx.globalAlpha = op.opacity;
|
|
2887
|
+
ctx.filter = op.filter || "none";
|
|
2888
|
+
ctx.drawImage(this.scratch, 0, 0);
|
|
2889
|
+
ctx.restore();
|
|
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
|
+
}
|
|
2910
|
+
};
|
|
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
|
+
}
|
|
2930
|
+
/** A small seeded PRNG on 0..1. */
|
|
2931
|
+
function mulberry32(seed) {
|
|
2932
|
+
let a = seed >>> 0;
|
|
2933
|
+
return () => {
|
|
2934
|
+
a = a + 1831565813 >>> 0;
|
|
2935
|
+
let t = a;
|
|
2936
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
2937
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
2938
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
function setMatrix(ctx, m) {
|
|
2942
|
+
ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
|
|
2943
|
+
}
|
|
2944
|
+
/** Clips to the op's shown part in composition pixels (a wipe), leaving the transform at identity. */
|
|
2945
|
+
function clipFrame(ctx, op) {
|
|
2946
|
+
if (!op.clip) return;
|
|
2947
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2948
|
+
ctx.beginPath();
|
|
2949
|
+
for (const r of op.clip) ctx.rect(r.x, r.y, r.w, r.h);
|
|
2950
|
+
ctx.clip();
|
|
2951
|
+
}
|
|
2952
|
+
function canvasOf(ctx) {
|
|
2953
|
+
return ctx.canvas;
|
|
2954
|
+
}
|
|
2955
|
+
/** Whether a draw op puts its picture over the whole canvas as it is: no transform, crop, clip, fade, filter or blend. */
|
|
2956
|
+
function coversCanvas(op, width, height) {
|
|
2957
|
+
const m = op.matrix;
|
|
2958
|
+
return m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0 && op.rect.w === width && op.rect.h === height && !op.crop && op.opacity >= 1 && op.blend === "source-over" && !op.filter && op.borderRadius === 0 && !op.mask && !op.clip && !op.post;
|
|
2959
|
+
}
|
|
2960
|
+
/** The composition background (a colour or a gradient) over the whole frame. */
|
|
2961
|
+
function paintBackground(ctx, plan, w, h) {
|
|
2962
|
+
ctx.save();
|
|
2963
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
2964
|
+
ctx.globalAlpha = 1;
|
|
2965
|
+
ctx.globalCompositeOperation = "source-over";
|
|
2966
|
+
ctx.filter = "none";
|
|
2967
|
+
ctx.fillStyle = plan.backgroundGradient ? backgroundFill(ctx, plan.backgroundGradient, w, h) : plan.background;
|
|
2968
|
+
ctx.fillRect(0, 0, w, h);
|
|
2969
|
+
ctx.restore();
|
|
2970
|
+
}
|
|
2971
|
+
/** The same background as CSS, for the preview's ground under backdrop overlays. */
|
|
2972
|
+
function backgroundCss(plan) {
|
|
2973
|
+
const g = plan.backgroundGradient;
|
|
2974
|
+
if (!g) return plan.background;
|
|
2975
|
+
const stops = [...g.stops].sort((a, b) => a.offset - b.offset).map((s) => `${withOpacity(s.color, s.opacity)} ${Math.round(Math.max(0, Math.min(1, s.offset)) * 1e4) / 100}%`).join(", ");
|
|
2976
|
+
if (g.type === "radial") return `radial-gradient(farthest-corner at ${(g.center?.x ?? .5) * 100}% ${(g.center?.y ?? .5) * 100}%, ${stops})`;
|
|
2977
|
+
if (g.type === "conic") return `conic-gradient(from ${g.angle ?? 0}deg at ${(g.center?.x ?? .5) * 100}% ${(g.center?.y ?? .5) * 100}%, ${stops})`;
|
|
2978
|
+
return `linear-gradient(${g.angle ?? 180}deg, ${stops})`;
|
|
2979
|
+
}
|
|
2980
|
+
/** A canvas gradient for the frame, laid out the way CSS lays out the same gradient on a box of this size. */
|
|
2981
|
+
function backgroundFill(ctx, g, w, h) {
|
|
2982
|
+
const c = g.center ?? {
|
|
2983
|
+
x: .5,
|
|
2984
|
+
y: .5
|
|
2985
|
+
};
|
|
2986
|
+
const cx = c.x * w, cy = c.y * h;
|
|
2987
|
+
let grad;
|
|
2988
|
+
if (g.type === "radial") {
|
|
2989
|
+
const r = Math.max(Math.hypot(cx, cy), Math.hypot(w - cx, cy), Math.hypot(cx, h - cy), Math.hypot(w - cx, h - cy));
|
|
2990
|
+
grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
2991
|
+
} else if (g.type === "conic" && typeof ctx.createConicGradient === "function") grad = ctx.createConicGradient(((g.angle ?? 0) - 90) * Math.PI / 180, cx, cy);
|
|
2992
|
+
else {
|
|
2993
|
+
const rad = (g.angle ?? 180) * Math.PI / 180;
|
|
2994
|
+
const dx = Math.sin(rad), dy = -Math.cos(rad);
|
|
2995
|
+
const half = (Math.abs(w * dx) + Math.abs(h * dy)) / 2;
|
|
2996
|
+
grad = ctx.createLinearGradient(w / 2 - dx * half, h / 2 - dy * half, w / 2 + dx * half, h / 2 + dy * half);
|
|
2997
|
+
}
|
|
2998
|
+
for (const s of [...g.stops].sort((a, b) => a.offset - b.offset)) grad.addColorStop(Math.max(0, Math.min(1, s.offset)), withOpacity(s.color, s.opacity));
|
|
2999
|
+
return grad;
|
|
3000
|
+
}
|
|
3001
|
+
function withOpacity(color, opacity) {
|
|
3002
|
+
if (opacity === void 0 || opacity >= 1) return color;
|
|
3003
|
+
const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
3004
|
+
if (!hex) return color;
|
|
3005
|
+
const h = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
|
|
3006
|
+
return `rgba(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)}, ${Math.max(0, opacity)})`;
|
|
3007
|
+
}
|
|
3008
|
+
// src/render/compositor.ts
|
|
3009
|
+
/**
|
|
3010
|
+
* One tick: plan the frame, sync decoders, gather frames, paint. A tick that
|
|
3011
|
+
* arrives while another is in flight is coalesced into one re-run; a tick on
|
|
3012
|
+
* the same frame index as the last paint only keeps decoders prefetching.
|
|
3013
|
+
*/
|
|
3014
|
+
var Compositor = class Compositor {
|
|
3015
|
+
deps;
|
|
3016
|
+
pool;
|
|
3017
|
+
painter;
|
|
3018
|
+
ctx = null;
|
|
3019
|
+
state;
|
|
3020
|
+
inFlight = null;
|
|
3021
|
+
pending = null;
|
|
3022
|
+
lastFrame = -1;
|
|
3023
|
+
lastPlan = null;
|
|
3024
|
+
nested = /* @__PURE__ */ new Map();
|
|
3025
|
+
disposed = false;
|
|
3026
|
+
constructor(state, deps) {
|
|
3027
|
+
this.deps = deps;
|
|
3028
|
+
this.state = state;
|
|
3029
|
+
this.pool = new DecoderPool(deps.sources);
|
|
3030
|
+
this.painter = new CanvasPainter(state.composition.width, state.composition.height, deps.createCanvas);
|
|
3031
|
+
}
|
|
3032
|
+
attach(ctx) {
|
|
3033
|
+
this.ctx = ctx;
|
|
3034
|
+
this.lastFrame = -1;
|
|
3035
|
+
}
|
|
3036
|
+
get renderedFrame() {
|
|
3037
|
+
return this.lastFrame;
|
|
3038
|
+
}
|
|
3039
|
+
get attachedContext() {
|
|
3040
|
+
return this.ctx;
|
|
3041
|
+
}
|
|
3042
|
+
get lastPlanBuilt() {
|
|
3043
|
+
return this.lastPlan;
|
|
3044
|
+
}
|
|
3045
|
+
get currentState() {
|
|
3046
|
+
return this.state;
|
|
3047
|
+
}
|
|
3048
|
+
setState(state) {
|
|
3049
|
+
this.state = state;
|
|
3050
|
+
if (state.composition.width !== this.painter.width || state.composition.height !== this.painter.height) this.painter = new CanvasPainter(state.composition.width, state.composition.height, this.deps.createCanvas);
|
|
3051
|
+
this.lastFrame = -1;
|
|
3052
|
+
}
|
|
3053
|
+
setRate(rate) {
|
|
3054
|
+
this.pool.setRate(rate);
|
|
3055
|
+
for (const n of this.nested.values()) n.compositor.setRate(rate);
|
|
3056
|
+
}
|
|
3057
|
+
plan(t) {
|
|
3058
|
+
return buildFramePlan(this.state, t, this.deps.resolveNested);
|
|
3059
|
+
}
|
|
3060
|
+
/** Jump every decoder (nested ones too) to `t`. */
|
|
3061
|
+
seek(t) {
|
|
3062
|
+
const nested = this.plan(t).ops.filter((op) => op.nested).map((op) => this.nested.get(op.id)?.compositor.seek(op.nested.time) ?? Promise.resolve());
|
|
3063
|
+
return Promise.all([this.pool.seekAll(t), ...nested]).then(() => void 0);
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* The next frame of a sequential render (export): decoders keep streaming
|
|
3067
|
+
* from where they are instead of being re-primed, and every frame is waited
|
|
3068
|
+
* for. Paint order and output are the same as `seek` then `tick`, at a
|
|
3069
|
+
* fraction of the decode work.
|
|
3070
|
+
*/
|
|
3071
|
+
step(t) {
|
|
3072
|
+
return this.tick(t, {
|
|
3073
|
+
wait: true,
|
|
3074
|
+
exact: true
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
tick(t, opts) {
|
|
3078
|
+
if (this.disposed) return Promise.resolve();
|
|
3079
|
+
if (this.inFlight) {
|
|
3080
|
+
if (this.pending) {
|
|
3081
|
+
this.pending.t = t;
|
|
3082
|
+
this.pending.opts = {
|
|
3083
|
+
wait: opts.wait || this.pending.opts.wait,
|
|
3084
|
+
exact: Boolean(opts.exact || this.pending.opts.exact)
|
|
3085
|
+
};
|
|
3086
|
+
return this.pending.done;
|
|
3087
|
+
}
|
|
3088
|
+
let resolve = () => void 0;
|
|
3089
|
+
const done = new Promise((r) => {
|
|
3090
|
+
resolve = r;
|
|
3091
|
+
});
|
|
3092
|
+
this.pending = {
|
|
3093
|
+
t,
|
|
3094
|
+
opts,
|
|
3095
|
+
done,
|
|
3096
|
+
resolve
|
|
3097
|
+
};
|
|
3098
|
+
return done;
|
|
3099
|
+
}
|
|
3100
|
+
const run = this.run(t, opts).catch(() => void 0).then(() => {
|
|
3101
|
+
this.inFlight = null;
|
|
3102
|
+
const next = this.pending;
|
|
3103
|
+
this.pending = null;
|
|
3104
|
+
if (next) this.tick(next.t, next.opts).then(next.resolve, next.resolve);
|
|
3105
|
+
});
|
|
3106
|
+
this.inFlight = run;
|
|
3107
|
+
return run;
|
|
3108
|
+
}
|
|
3109
|
+
dispose() {
|
|
3110
|
+
this.disposed = true;
|
|
3111
|
+
this.pool.dispose();
|
|
3112
|
+
for (const n of this.nested.values()) n.compositor.dispose();
|
|
3113
|
+
this.nested.clear();
|
|
3114
|
+
}
|
|
3115
|
+
async run(t, opts) {
|
|
3116
|
+
const frame = frameIndex(t, this.state.composition.fps);
|
|
3117
|
+
this.pool.sync(this.state, t);
|
|
3118
|
+
if (frame === this.lastFrame && !opts.wait) return;
|
|
3119
|
+
const plan = this.plan(t);
|
|
3120
|
+
this.lastPlan = plan;
|
|
3121
|
+
this.syncNested(plan);
|
|
3122
|
+
if (opts.wait) await this.pool.settle();
|
|
3123
|
+
const frames = /* @__PURE__ */ new Map();
|
|
3124
|
+
const nestedCanvases = /* @__PURE__ */ new Map();
|
|
3125
|
+
await Promise.all(plan.ops.map(async (op) => {
|
|
3126
|
+
if (op.nested) {
|
|
3127
|
+
const n = this.nested.get(op.id);
|
|
3128
|
+
if (!n) return;
|
|
3129
|
+
await n.compositor.tick(op.nested.time, {
|
|
3130
|
+
wait: opts.wait,
|
|
3131
|
+
exact: Boolean(opts.exact)
|
|
3132
|
+
});
|
|
3133
|
+
nestedCanvases.set(op.id, n.canvas);
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
if (op.kind !== "video" && op.kind !== "image" && op.kind !== "error") return;
|
|
3137
|
+
const d = this.pool.decoderFor(op.id);
|
|
3138
|
+
frames.set(op.id, d ? await d.frameFor(t, opts.wait, opts.exact) : null);
|
|
3139
|
+
}));
|
|
3140
|
+
if (this.disposed) return;
|
|
3141
|
+
if (this.ctx) this.painter.paint(this.ctx, plan, {
|
|
3142
|
+
frames,
|
|
3143
|
+
nested: nestedCanvases
|
|
3144
|
+
});
|
|
3145
|
+
for (const op of plan.ops) this.pool.decoderFor(op.id)?.advance(t);
|
|
3146
|
+
this.lastFrame = frame;
|
|
3147
|
+
this.deps.onFrame?.(t, frame);
|
|
3148
|
+
}
|
|
3149
|
+
syncNested(plan) {
|
|
3150
|
+
const want = /* @__PURE__ */ new Set();
|
|
3151
|
+
for (const op of plan.ops) {
|
|
3152
|
+
if (!op.nested) continue;
|
|
3153
|
+
want.add(op.id);
|
|
3154
|
+
const existing = this.nested.get(op.id);
|
|
3155
|
+
if (existing && existing.compositor.currentState === op.nested.state) continue;
|
|
3156
|
+
existing?.compositor.dispose();
|
|
3157
|
+
const inner = op.nested.state;
|
|
3158
|
+
const canvas = this.deps.createCanvas(inner.composition.width, inner.composition.height);
|
|
3159
|
+
const compositor = new Compositor(inner, {
|
|
3160
|
+
...this.deps,
|
|
3161
|
+
onFrame: void 0
|
|
3162
|
+
});
|
|
3163
|
+
compositor.attach(canvas.getContext("2d"));
|
|
3164
|
+
this.nested.set(op.id, {
|
|
3165
|
+
compositor,
|
|
3166
|
+
canvas
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
for (const [id, n] of this.nested) {
|
|
3170
|
+
if (want.has(id)) continue;
|
|
3171
|
+
n.compositor.dispose();
|
|
3172
|
+
this.nested.delete(id);
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
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 };
|