@cutcraft/player 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1074 +0,0 @@
1
- import { EASING_FN, RENDERED_MASK_TYPES, RENDERED_TRANSITIONS, capabilitiesOf, freezeHoldsOf, freezeShift, lookupAsset, rampAdvance, speedKeysOf } from "@cutcraft/kit";
2
- // src/constants.ts
3
- /** Seconds before a clip starts at which its decoder is mounted. */
4
- const MOUNT_AHEAD_S = 2;
5
- /** Seconds after a clip ends for which its decoder is kept. */
6
- const UNMOUNT_BEHIND_S = .5;
7
- /** Seconds an unreferenced mediabunny Input stays open before it is disposed. */
8
- const INPUT_IDLE_CLOSE_S = 10;
9
- /** Hard cap on open Inputs; idle ones are evicted LRU first. */
10
- const MAX_OPEN_INPUTS = 6;
11
- /** Decoded frames a clip decoder keeps queued ahead of the play head. */
12
- const QUEUE_TARGET = 8;
13
- const QUEUE_MAX = 10;
14
- /** Effective speed (clip speed × rate) above which frames are fetched by seek per tick instead of streamed. */
15
- const SEEK_MODE_SPEED = 4;
16
- /** Reverse playback pre-decodes this window behind the cursor. */
17
- const REVERSE_WINDOW_S = 1;
18
- const REVERSE_WINDOW_FRAMES = 16;
19
- /** Longest wait for a frame in `wait` mode (seek, export) before drawing without it. */
20
- const FRAME_WAIT_MS = 250;
21
- /** Audio scheduler period and horizon. */
22
- const SCHEDULER_MS = 100;
23
- const LOOKAHEAD_S = .2;
24
- /** Source ranges longer than twice this are decoded in chunks of this length. */
25
- const AUDIO_CHUNK_S = 30;
26
- /** LRU cap, in decoded seconds, on cached PCM. */
27
- const AUDIO_CACHE_S = 600;
28
- /** Pre-decode window on play, and how long play() waits for it. */
29
- const AUDIO_PRIME_S = .5;
30
- const PLAY_PRIME_TIMEOUT_MS = 500;
31
- /** Ducking defaults. */
32
- const DUCK_ATTACK_S = .1;
33
- const DUCK_RELEASE_S = .3;
34
- /** Sampling step for multiplied gain ramps. */
35
- const ENVELOPE_STEP_S = .05;
36
- /** Playback rate clamp. */
37
- const MIN_RATE = .25;
38
- const MAX_RATE = 4;
39
- /** Quiet time after the last setState before a paused preview fetches its exact frame. */
40
- const SETTLE_MS = 120;
41
- /** Tolerance when rounding a time to a frame index. */
42
- const FRAME_EPS = 1e-6;
43
- /**
44
- * Container timestamps are often rounded to the millisecond, so the frame for
45
- * the exact time n/fps can sit a fraction of a millisecond after it. A frame
46
- * is picked when its timestamp is at most this far past the wanted time.
47
- */
48
- const PICK_EPS = .001;
49
- // src/warn.ts
50
- const seen = /* @__PURE__ */ new Set();
51
- let sink = null;
52
- /** Warn once per key. Tests capture warnings with `captureWarnings`. */
53
- function devWarn(key, message) {
54
- if (seen.has(key)) return;
55
- seen.add(key);
56
- if (sink) sink(message);
57
- else console.warn(`[cutcut/player] ${message}`);
58
- }
59
- function captureWarnings(fn) {
60
- sink = fn;
61
- seen.clear();
62
- }
63
- // src/model/time.ts
64
- function frameIndex(t, fps) {
65
- return Math.max(0, Math.floor(t * fps + FRAME_EPS));
66
- }
67
- function frameTime(frame, fps) {
68
- return frame / fps;
69
- }
70
- /** `mm:ss.ff` for a composition time, what the controls show and the contact sheet labels. */
71
- function timecode(t, fps) {
72
- const total = Math.max(0, t);
73
- const m = Math.floor(total / 60), s = Math.floor(total % 60), f = Math.floor((total - Math.floor(total)) * fps + 1e-6);
74
- return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(f).padStart(2, "0")}`;
75
- }
76
- /** 0..1 progress through a placement, what `useT()` returns inside a Layer. */
77
- function localT(p, t) {
78
- if (p.duration <= 0) return 0;
79
- const u = (t - p.start) / p.duration;
80
- return u < 0 ? 0 : u > 1 ? 1 : u;
81
- }
82
- /** What `useLayer()` returns inside a Layer: progress plus real seconds. */
83
- function layerClock(p, t, fps) {
84
- const duration = Math.max(0, p.duration);
85
- const elapsed = Math.min(duration, Math.max(0, t - p.start));
86
- return {
87
- t: localT(p, t),
88
- elapsed,
89
- remaining: duration - elapsed,
90
- duration,
91
- time: t,
92
- fps
93
- };
94
- }
95
- /**
96
- * Source seconds a media placement shows at composition time `t`: in/out
97
- * trim, constant speed or a speed-key ramp, reverse, and freeze holds.
98
- * A freeze is an insert hold: it shows `at` over its window, then the clip
99
- * resumes from where it stopped; the result is clamped to the trimmed range.
100
- */
101
- function placementSourceTime(p, children, t) {
102
- const src = p.source;
103
- if (!src) return Math.max(0, t - p.start);
104
- const shifted = freezeShift(freezeHoldsOf(children), Math.max(0, t - p.start));
105
- if ("held" in shifted) return clampSource(shifted.held, src);
106
- const keys = speedKeysOf(children);
107
- const advance = keys.length > 0 ? rampAdvance(keys, shifted.rel) : shifted.rel * src.speed;
108
- return clampSource(p.props.reverse === true ? src.out - advance : src.in + advance, src);
109
- }
110
- function nestedSourceTime(p, t) {
111
- const src = p.source;
112
- if (!src) return Math.max(0, t - p.start);
113
- return clampSource(src.in + Math.max(0, t - p.start) * src.speed, src);
114
- }
115
- function clampSource(v, src) {
116
- const end = src.out > src.in ? src.out : src.in;
117
- return v < src.in ? src.in : v > end ? end : v;
118
- }
119
- // src/model/keyframes.ts
120
- const clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
121
- function ease(name, x) {
122
- const fn = EASING_FN[name];
123
- if (!fn) {
124
- if (name !== void 0) devWarn(`easing:${name}`, `unknown easing "${name}", using linear`);
125
- return clamp01(x);
126
- }
127
- return fn(clamp01(x));
128
- }
129
- function keysFor(children, property) {
130
- const keys = [];
131
- for (const c of children) {
132
- if (c.kind !== "keyframe" || c.props.property !== property) continue;
133
- keys.push({
134
- at: Number(c.props.at ?? 0),
135
- value: c.props.value,
136
- easing: c.props.easing
137
- });
138
- }
139
- return keys.sort((a, b) => a.at - b.at);
140
- }
141
- /**
142
- * The keyframed value of `property` at element-relative time `rel`. Holds
143
- * before the first and after the last key; the segment between two keys uses
144
- * the easing of the key it leaves. Non-numeric values step.
145
- */
146
- function keyframedValue(children, property, rel, fallback) {
147
- const keys = keysFor(children, property);
148
- const first = keys[0];
149
- if (!first) return fallback;
150
- const last = keys[keys.length - 1];
151
- if (rel <= first.at) return first.value;
152
- if (rel >= last.at) return last.value;
153
- for (let i = 0; i < keys.length - 1; i++) {
154
- const a = keys[i], b = keys[i + 1];
155
- if (rel < a.at || rel >= b.at) continue;
156
- if (typeof a.value !== "number" || typeof b.value !== "number") return a.value;
157
- const span = b.at - a.at;
158
- const u = span <= 0 ? 1 : ease(a.easing, (rel - a.at) / span);
159
- return a.value + (b.value - a.value) * u;
160
- }
161
- return last.value;
162
- }
163
- function hasKeyframes(children, property) {
164
- return children.some((c) => c.kind === "keyframe" && c.props.property === property);
165
- }
166
- // src/model/visible.ts
167
- /** Visual, timed placements that show at `t`: tracks in document order (the first track is the bottom), items in track order. */
168
- function visiblePlacements(state, t) {
169
- const out = [];
170
- const tracks = [...state.tracks].sort((a, b) => a.order - b.order);
171
- for (const track of tracks) {
172
- if (track.hidden) continue;
173
- const roles = transitionRoles(state, track, t);
174
- for (const placement of trackItems(state, track)) {
175
- const caps = capabilitiesOf(placement.kind);
176
- if (!caps.visual || !caps.timed || placement.kind === "transition") continue;
177
- if (!isVisibleAt(placement, t, state.composition.duration)) continue;
178
- const role = roles.get(placement.id);
179
- out.push({
180
- placement,
181
- track,
182
- children: placement.children.map((id) => state.items.get(id)).filter((c) => c !== void 0),
183
- ...role ? { transition: role } : {}
184
- });
185
- }
186
- }
187
- return out;
188
- }
189
- function isVisibleAt(p, t, compositionDuration) {
190
- if (t >= p.start && t < p.end) return true;
191
- return t === compositionDuration && p.end === t && p.end > p.start;
192
- }
193
- /** Placements that sit on a track, in track order; a multicam's generated clips follow their parent. */
194
- function trackItems(state, track) {
195
- const out = [];
196
- for (const id of track.items) {
197
- const p = state.items.get(id);
198
- if (!p) continue;
199
- if (p.kind === "multicam") {
200
- const generated = [...state.items.values()].filter((q) => q.parent === id && q.kind === "clip").sort((a, b) => a.start - b.start);
201
- out.push(...generated);
202
- continue;
203
- }
204
- out.push(p);
205
- }
206
- return out;
207
- }
208
- function transitionRoles(state, track, t) {
209
- const roles = /* @__PURE__ */ new Map();
210
- for (const id of track.items) {
211
- const x = state.items.get(id);
212
- if (!x || x.kind !== "transition" || x.duration <= 0) continue;
213
- if (t < x.start || t >= x.end) continue;
214
- const params = x.props.params ?? {};
215
- const progress = ease(typeof params.curve === "string" ? params.curve : "linear", (t - x.start) / x.duration);
216
- if (x.between) {
217
- roles.set(x.between[0], {
218
- placement: x,
219
- role: "out",
220
- progress
221
- });
222
- roles.set(x.between[1], {
223
- placement: x,
224
- role: "in",
225
- progress
226
- });
227
- } else if (typeof x.props.on === "string") roles.set(x.props.on, {
228
- placement: x,
229
- role: x.props.edge === "in" ? "in" : "out",
230
- progress
231
- });
232
- }
233
- return roles;
234
- }
235
- // src/model/transform.ts
236
- const IDENTITY = {
237
- a: 1,
238
- b: 0,
239
- c: 0,
240
- d: 1,
241
- e: 0,
242
- f: 0
243
- };
244
- function multiply(m, n) {
245
- return {
246
- a: m.a * n.a + m.c * n.b,
247
- b: m.b * n.a + m.d * n.b,
248
- c: m.a * n.c + m.c * n.d,
249
- d: m.b * n.c + m.d * n.d,
250
- e: m.a * n.e + m.c * n.f + m.e,
251
- f: m.b * n.e + m.d * n.f + m.f
252
- };
253
- }
254
- const translate = (x, y) => ({
255
- ...IDENTITY,
256
- e: x,
257
- f: y
258
- });
259
- const scaleM = (sx, sy) => ({
260
- ...IDENTITY,
261
- a: sx,
262
- d: sy
263
- });
264
- function rotate(deg) {
265
- const r = deg * Math.PI / 180;
266
- const cos = Math.cos(r), sin = Math.sin(r);
267
- return {
268
- a: cos,
269
- b: sin,
270
- c: -sin,
271
- d: cos,
272
- e: 0,
273
- f: 0
274
- };
275
- }
276
- function matrixToCss(m) {
277
- const n = (v) => Math.abs(v) < 1e-9 ? "0" : String(Math.round(v * 1e6) / 1e6);
278
- return `matrix(${n(m.a)}, ${n(m.b)}, ${n(m.c)}, ${n(m.d)}, ${n(m.e)}, ${n(m.f)})`;
279
- }
280
- /** The fitted rectangle of a source inside the composition, centred. */
281
- function fitRect(comp, src, fit) {
282
- const W = comp.width, H = comp.height;
283
- const w = src.width > 0 ? src.width : W, h = src.height > 0 ? src.height : H;
284
- if (fit === "stretch") return {
285
- x: 0,
286
- y: 0,
287
- w: W,
288
- h: H
289
- };
290
- let s = 1;
291
- if (fit === "contain") s = Math.min(W / w, H / h);
292
- else if (fit === "cover") s = Math.max(W / w, H / h);
293
- const dw = w * s, dh = h * s;
294
- return {
295
- x: (W - dw) / 2,
296
- y: (H - dh) / 2,
297
- w: dw,
298
- h: dh
299
- };
300
- }
301
- /**
302
- * Lay out a source of `src` pixels in the composition: crop, fit, then the
303
- * element transform (position offset, scale, rotation about the anchor).
304
- */
305
- function layoutRect(comp, src, fit, transform) {
306
- const t = transform ?? {};
307
- let crop;
308
- let effective = src;
309
- if (t.crop && t.crop.width > 0 && t.crop.height > 0) {
310
- crop = {
311
- x: t.crop.x * src.width,
312
- y: t.crop.y * src.height,
313
- w: t.crop.width * src.width,
314
- h: t.crop.height * src.height
315
- };
316
- effective = {
317
- width: crop.w,
318
- height: crop.h
319
- };
320
- }
321
- const rect = fitRect(comp, effective, fit);
322
- return {
323
- rect,
324
- matrix: transformMatrix(rect, t),
325
- ...crop ? { crop } : {},
326
- opacity: t.opacity ?? 1
327
- };
328
- }
329
- /** The matrix mapping an element's local pixels to the composition, given its untransformed rect. */
330
- function transformMatrix(rect, t) {
331
- const tr = t ?? {};
332
- const anchor = tr.anchor ?? {
333
- x: .5,
334
- y: .5
335
- };
336
- const ox = rect.x + anchor.x * rect.w, oy = rect.y + anchor.y * rect.h;
337
- const sx = typeof tr.scale === "number" ? tr.scale : tr.scale?.x ?? 1;
338
- const sy = typeof tr.scale === "number" ? tr.scale : tr.scale?.y ?? 1;
339
- const px = tr.position?.x ?? 0, py = tr.position?.y ?? 0;
340
- let m = translate(px + ox, py + oy);
341
- if (tr.rotation) m = multiply(m, rotate(tr.rotation));
342
- if (sx !== 1 || sy !== 1) m = multiply(m, scaleM(sx, sy));
343
- m = multiply(m, translate(-ox, -oy));
344
- return multiply(m, translate(rect.x, rect.y));
345
- }
346
- /** The upright box around `rect` after `m`: where a rectangle of an element's local pixels lands in the composition. */
347
- function boundsOf(rect, m) {
348
- const xs = [
349
- rect.x,
350
- rect.x + rect.w,
351
- rect.x,
352
- rect.x + rect.w
353
- ], ys = [
354
- rect.y,
355
- rect.y,
356
- rect.y + rect.h,
357
- rect.y + rect.h
358
- ];
359
- 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);
360
- const x = Math.min(...px), y = Math.min(...py);
361
- return {
362
- x,
363
- y,
364
- w: Math.max(...px) - x,
365
- h: Math.max(...py) - y
366
- };
367
- }
368
- /** The transform with its keyframed properties evaluated at element-relative time `rel`. */
369
- function animatedTransform(base, children, rel) {
370
- if (!children.some((c) => c.kind === "keyframe")) return base ?? {};
371
- const t = { ...base ?? {} };
372
- const num = (prop, fallback) => Number(keyframedValue(children, prop, rel, fallback));
373
- const pos = base?.position ?? {
374
- x: 0,
375
- y: 0
376
- };
377
- const scl = typeof base?.scale === "number" ? {
378
- x: base.scale,
379
- y: base.scale
380
- } : base?.scale ?? {
381
- x: 1,
382
- y: 1
383
- };
384
- const anc = base?.anchor ?? {
385
- x: .5,
386
- y: .5
387
- };
388
- const crop = base?.crop ?? {
389
- x: 0,
390
- y: 0,
391
- width: 1,
392
- height: 1
393
- };
394
- t.position = {
395
- x: num("transform.position.x", pos.x),
396
- y: num("transform.position.y", pos.y)
397
- };
398
- t.scale = {
399
- x: num("transform.scale.x", scl.x),
400
- y: num("transform.scale.y", scl.y)
401
- };
402
- t.rotation = num("transform.rotation", base?.rotation ?? 0);
403
- t.opacity = num("transform.opacity", base?.opacity ?? 1);
404
- t.anchor = {
405
- x: num("transform.anchor.x", anc.x),
406
- y: num("transform.anchor.y", anc.y)
407
- };
408
- t.borderRadius = num("transform.borderRadius", base?.borderRadius ?? 0);
409
- if (base?.crop || [
410
- "x",
411
- "y",
412
- "width",
413
- "height"
414
- ].some((k) => children.some((c) => c.kind === "keyframe" && c.props.property === `transform.crop.${k}`))) t.crop = {
415
- x: num("transform.crop.x", crop.x),
416
- y: num("transform.crop.y", crop.y),
417
- width: num("transform.crop.width", crop.width),
418
- height: num("transform.crop.height", crop.height)
419
- };
420
- return t;
421
- }
422
- /** CSS for a DOM element laid out like `layoutRect` does: it sits at the origin with `transform-origin: 0 0`. */
423
- function cssTransform(layout) {
424
- return matrixToCss(layout.matrix);
425
- }
426
- // src/model/effects.ts
427
- function num(params, keys, fallback) {
428
- for (const k of keys) {
429
- const v = params[k];
430
- if (typeof v === "number" && Number.isFinite(v)) return v;
431
- }
432
- return fallback;
433
- }
434
- /**
435
- * Levels as `brightness(k) contrast(c)`: out = (in − black) / (white − black), with c = 1 + 2·black·s
436
- * and k = s/c for s = 1/(white − black). Gamma has no filter form; the lint reports it.
437
- */
438
- function levels(p) {
439
- const black = Math.max(0, Math.min(1, num(p, ["black"], 0))), white = Math.max(0, Math.min(1, num(p, ["white"], 1)));
440
- if (white <= black) return "";
441
- const s = 1 / (white - black), c = 1 + 2 * black * s;
442
- return `brightness(${s / c}) contrast(${c})`;
443
- }
444
- /** Effects the painter draws over the element after it (`postEffects`), not as filters. */
445
- const POST_TYPES = ["vignette", "grain"];
446
- /** One filter per rendered effect type: kit's RENDERED_EFFECTS, which the `not-rendered` lint reads, less the post effects. */
447
- const CANVAS_FILTERABLE = {
448
- brightness: (p) => `brightness(${1 + num(p, ["amount", "value"], 0) / 100})`,
449
- contrast: (p) => `contrast(${1 + num(p, ["amount", "value"], 0) / 100})`,
450
- saturation: (p) => `saturate(${1 + num(p, ["amount", "value"], 0) / 100})`,
451
- hue: (p) => `hue-rotate(${num(p, [
452
- "angle",
453
- "amount",
454
- "value"
455
- ], 0)}deg)`,
456
- blur: (p) => `blur(${num(p, [
457
- "radius",
458
- "amount",
459
- "value"
460
- ], 4)}px)`,
461
- grayscale: (p) => `grayscale(${num(p, ["amount", "value"], 100) / 100})`,
462
- sepia: (p) => `sepia(${num(p, ["amount", "value"], 100) / 100})`,
463
- invert: (p) => `invert(${num(p, ["amount", "value"], 100) / 100})`,
464
- temperature: (p) => colorGradeFilters({ temperature: num(p, ["amount", "value"], 0) }).join(" "),
465
- tint: (p) => colorGradeFilters({ tint: num(p, ["amount", "value"], 0) }).join(" "),
466
- levels,
467
- 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)"})`
468
- };
469
- /** An effect's params with its keyframed ones (`effect.<id>.<param>` keyframes on the element) evaluated at element-relative `rel`. */
470
- function paramsAt(children, effect, rel) {
471
- const base = effect.props.params ?? {};
472
- const prefix = `effect.${effect.id}.`;
473
- let out = null;
474
- for (const k of children) {
475
- const property = k.kind === "keyframe" ? String(k.props.property) : "";
476
- if (!property.startsWith(prefix)) continue;
477
- const name = property.slice(prefix.length);
478
- out ??= { ...base };
479
- out[name] = keyframedValue(children, property, rel, base[name]);
480
- }
481
- return out ?? base;
482
- }
483
- /** A single canvas/CSS `filter` string for an element's effect and colour-grade children at element-relative `rel`; unsupported types are listed. */
484
- function filterString(children, elementId, rel) {
485
- const parts = [];
486
- const unsupported = [];
487
- for (const c of children) if (c.kind === "effect") {
488
- if (c.props.enabled === false) continue;
489
- const type = String(c.props.type);
490
- if (POST_TYPES.includes(type)) continue;
491
- const make = CANVAS_FILTERABLE[type];
492
- if (make) {
493
- const f = make(paramsAt(children, c, rel));
494
- if (f) parts.push(f);
495
- } else {
496
- unsupported.push(type);
497
- devWarn(`effect:${elementId}:${type}`, `effect "${type}" on "${elementId}" is not rendered by the browser player`);
498
- }
499
- } else if (c.kind === "colorGrade") parts.push(...colorGradeFilters({
500
- temperature: keyframedValue(children, "colorGrade.temperature", rel, c.props.temperature),
501
- tint: keyframedValue(children, "colorGrade.tint", rel, c.props.tint)
502
- }));
503
- return {
504
- filter: parts.join(" "),
505
- unsupported
506
- };
507
- }
508
- /** Vignette and grain, in order, with `amount` on 0..1; grain is seeded by the frame, so a frame always looks the same. */
509
- function postEffects(children, frame, rel) {
510
- const out = [];
511
- for (const c of children) {
512
- if (c.kind !== "effect" || c.props.enabled === false) continue;
513
- const amount = Math.max(0, Math.min(1, num(paramsAt(children, c, rel), [
514
- "amount",
515
- "intensity",
516
- "value"
517
- ], c.props.type === "grain" ? 20 : 50) / 100));
518
- if (amount <= 0) continue;
519
- if (c.props.type === "vignette") out.push({
520
- type: "vignette",
521
- amount
522
- });
523
- else if (c.props.type === "grain") out.push({
524
- type: "grain",
525
- amount,
526
- seed: frame
527
- });
528
- }
529
- return out.length ? out : void 0;
530
- }
531
- /**
532
- * Temperature and tint approximated with CSS filters: warm pulls toward
533
- * sepia, cool toward a slight hue rotation; tint rotates hue. Wheels, curves,
534
- * LUTs and HSL bands are not rendered by the browser player.
535
- */
536
- function colorGradeFilters(props) {
537
- const out = [];
538
- const temperature = typeof props.temperature === "number" ? props.temperature : 0;
539
- const tint = typeof props.tint === "number" ? props.tint : 0;
540
- if (temperature > 0) out.push(`sepia(${temperature / 100 * .5})`);
541
- else if (temperature < 0) out.push(`hue-rotate(${-temperature / 10}deg) saturate(${1 + -temperature / 200})`);
542
- if (tint !== 0) out.push(`hue-rotate(${tint / 4}deg)`);
543
- return out;
544
- }
545
- // src/model/motion.ts
546
- const STILL = {
547
- opacity: 1,
548
- matrix: IDENTITY,
549
- clip: null,
550
- draw: null
551
- };
552
- const DEFAULT_DURATION = .5;
553
- /** How far a slide travels: a quarter of the element's size, at most this many pixels (a title's box is the frame). */
554
- const SLIDE = .25;
555
- const SLIDE_MAX = 160;
556
- /**
557
- * The entrance and exit of an element `rel` seconds into its `duration`, for a
558
- * `width` by `height` box. Both can play at once on a short element; their
559
- * effects multiply. Pop, bounce and elastic carry their own curve; the others
560
- * follow the animation's easing (default ease-out).
561
- */
562
- function motionAt(enter, exit, rel, duration, width, height) {
563
- if (!enter && !exit) return STILL;
564
- let m = STILL;
565
- if (enter && enter.type !== "none") {
566
- const len = enter.duration ?? DEFAULT_DURATION;
567
- m = combine(m, effect(enter, len > 0 ? rel / len : 1, width, height));
568
- }
569
- if (exit && exit.type !== "none") {
570
- const len = exit.duration ?? DEFAULT_DURATION;
571
- m = combine(m, effect(exit, len > 0 ? (duration - rel) / len : 1, width, height));
572
- }
573
- return m;
574
- }
575
- /** One animation at raw progress `x` (0 hidden, 1 fully shown). */
576
- function effect(anim, x, w, h) {
577
- const raw = Math.max(0, Math.min(1, x));
578
- if (raw >= 1) return STILL;
579
- const k = ease(anim.easing ?? "ease-out", raw);
580
- const about = (m) => multiply(multiply(translate(w / 2, h / 2), m), translate(-w / 2, -h / 2));
581
- const fadeIn = Math.min(1, raw * 3);
582
- switch (anim.type) {
583
- case "fade": return {
584
- ...STILL,
585
- opacity: k
586
- };
587
- case "slide-left": return {
588
- ...STILL,
589
- opacity: k,
590
- matrix: translate(Math.min(w * SLIDE, SLIDE_MAX) * (1 - k), 0)
591
- };
592
- case "slide-right": return {
593
- ...STILL,
594
- opacity: k,
595
- matrix: translate(-Math.min(w * SLIDE, SLIDE_MAX) * (1 - k), 0)
596
- };
597
- case "slide-up": return {
598
- ...STILL,
599
- opacity: k,
600
- matrix: translate(0, Math.min(h * SLIDE, SLIDE_MAX) * (1 - k))
601
- };
602
- case "slide-down": return {
603
- ...STILL,
604
- opacity: k,
605
- matrix: translate(0, -Math.min(h * SLIDE, SLIDE_MAX) * (1 - k))
606
- };
607
- case "scale": return {
608
- ...STILL,
609
- opacity: k,
610
- matrix: about(scaleM(.6 + .4 * k, .6 + .4 * k))
611
- };
612
- case "rotate": return {
613
- ...STILL,
614
- opacity: k,
615
- matrix: about(multiply(rotate(-90 * (1 - k)), scaleM(.8 + .2 * k, .8 + .2 * k)))
616
- };
617
- case "pop": {
618
- const s = ease("easeOutBack", raw);
619
- return {
620
- ...STILL,
621
- opacity: fadeIn,
622
- matrix: about(scaleM(s, s))
623
- };
624
- }
625
- case "bounce": {
626
- const s = ease("easeOutBounce", raw);
627
- return {
628
- ...STILL,
629
- opacity: fadeIn,
630
- matrix: about(scaleM(s, s))
631
- };
632
- }
633
- case "elastic": {
634
- const s = ease("easeOutElastic", raw);
635
- return {
636
- ...STILL,
637
- opacity: fadeIn,
638
- matrix: about(scaleM(s, s))
639
- };
640
- }
641
- case "flip-horizontal": return {
642
- ...STILL,
643
- matrix: about(scaleM(Math.max(.001, Math.sin(k * Math.PI / 2)), 1))
644
- };
645
- case "flip-vertical": return {
646
- ...STILL,
647
- matrix: about(scaleM(1, Math.max(.001, Math.sin(k * Math.PI / 2))))
648
- };
649
- case "wipe-right": return {
650
- ...STILL,
651
- clip: [{
652
- x: 0,
653
- y: 0,
654
- w: w * k,
655
- h
656
- }]
657
- };
658
- case "wipe-left": return {
659
- ...STILL,
660
- clip: [{
661
- x: w * (1 - k),
662
- y: 0,
663
- w: w * k,
664
- h
665
- }]
666
- };
667
- case "wipe-down": return {
668
- ...STILL,
669
- clip: [{
670
- x: 0,
671
- y: 0,
672
- w,
673
- h: h * k
674
- }]
675
- };
676
- case "wipe-up": return {
677
- ...STILL,
678
- clip: [{
679
- x: 0,
680
- y: h * (1 - k),
681
- w,
682
- h: h * k
683
- }]
684
- };
685
- case "reveal-center": return {
686
- ...STILL,
687
- clip: [{
688
- x: w * (1 - k) / 2,
689
- y: h * (1 - k) / 2,
690
- w: w * k,
691
- h: h * k
692
- }]
693
- };
694
- case "reveal-edges": return {
695
- ...STILL,
696
- clip: [{
697
- x: 0,
698
- y: 0,
699
- w: w * k / 2,
700
- h
701
- }, {
702
- x: w - w * k / 2,
703
- y: 0,
704
- w: w * k / 2,
705
- h
706
- }]
707
- };
708
- case "draw": return {
709
- ...STILL,
710
- draw: k
711
- };
712
- default: return {
713
- ...STILL,
714
- opacity: k
715
- };
716
- }
717
- }
718
- /** Both effects at once: opacities and draw amounts multiply, matrices compose, clips intersect. */
719
- function combine(a, b) {
720
- if (b === STILL) return a;
721
- if (a === STILL) return b;
722
- return {
723
- opacity: a.opacity * b.opacity,
724
- matrix: multiply(a.matrix, b.matrix),
725
- clip: a.clip && b.clip ? intersect(a.clip, b.clip) : a.clip ?? b.clip,
726
- draw: a.draw !== null && b.draw !== null ? a.draw * b.draw : a.draw ?? b.draw
727
- };
728
- }
729
- function intersect(a, b) {
730
- const out = [];
731
- for (const r of a) for (const s of b) {
732
- const x = Math.max(r.x, s.x), y = Math.max(r.y, s.y);
733
- const x2 = Math.min(r.x + r.w, s.x + s.w), y2 = Math.min(r.y + r.h, s.y + s.h);
734
- if (x2 > x && y2 > y) out.push({
735
- x,
736
- y,
737
- w: x2 - x,
738
- h: y2 - y
739
- });
740
- }
741
- return out;
742
- }
743
- // src/model/transition.ts
744
- const DIRECTION = {
745
- left: [-1, 0],
746
- right: [1, 0],
747
- up: [0, -1],
748
- down: [0, 1]
749
- };
750
- /**
751
- * One side of a transition at its eased progress. Between two clips the
752
- * incoming one goes over the outgoing one, which stays put unless the
753
- * transition moves it; an edge transition plays its side against what is beneath.
754
- * Types without a look of their own draw as a crossfade.
755
- */
756
- function transitionLook(t, W, H) {
757
- const x = t.placement, p = t.progress, role = t.role, between = x.between !== void 0;
758
- const type = String(x.props.type ?? "crossfade");
759
- const params = x.props.params ?? {};
760
- const dir = DIRECTION[String(params.direction)] ?? DIRECTION.left;
761
- switch (type) {
762
- case "dipToBlack":
763
- case "dipToWhite": {
764
- if (!between) return {
765
- opacity: 1,
766
- dip: role === "in" ? 1 - p : p
767
- };
768
- if (role === "out") return { opacity: 1 };
769
- const ramp = (1 - (x.duration > 0 ? Math.max(0, Math.min(1, Number(params.holdDuration ?? .1) / x.duration)) : 1)) / 2;
770
- const dip = ramp <= 0 ? 1 : Math.min(1, p / ramp, (1 - p) / ramp);
771
- return {
772
- opacity: p >= .5 ? 1 : 0,
773
- dip
774
- };
775
- }
776
- case "wipe":
777
- if (role === "out" && between) return { opacity: 1 };
778
- return {
779
- opacity: 1,
780
- clip: effect({
781
- type: `wipe-${params.direction === "diagonal" || params.direction === void 0 ? "left" : String(params.direction)}`,
782
- easing: "linear"
783
- }, role === "in" ? p : 1 - p, W, H).clip ?? [{
784
- x: 0,
785
- y: 0,
786
- w: W,
787
- h: H
788
- }]
789
- };
790
- case "slide":
791
- case "push":
792
- if (role === "in") return {
793
- opacity: 1,
794
- offset: {
795
- x: -dir[0] * W * (1 - p),
796
- y: -dir[1] * H * (1 - p)
797
- }
798
- };
799
- if (between && type === "slide" && params.pushOut !== true) return { opacity: 1 };
800
- return {
801
- opacity: 1,
802
- offset: {
803
- x: dir[0] * W * p,
804
- y: dir[1] * H * p
805
- }
806
- };
807
- default: return { opacity: role === "in" ? p : between ? 1 : 1 - p };
808
- }
809
- }
810
- // src/model/backdrop.ts
811
- /** Kinds the overlay plane draws (as DOM in the preview, through Satori and skia in the export) rather than the canvas. */
812
- const OVERLAY_KINDS = /* @__PURE__ */ new Set([
813
- "title",
814
- "shape",
815
- "svg",
816
- "sticker",
817
- "graphic",
818
- "layer",
819
- "adjustment"
820
- ]);
821
- /** Kinds that put footage on the canvas. */
822
- const FOOTAGE_KINDS = /* @__PURE__ */ new Set([
823
- "clip",
824
- "nested",
825
- "multicam"
826
- ]);
827
- const memo = /* @__PURE__ */ new WeakMap();
828
- /**
829
- * The tracks whose overlays draw beneath the footage: those below every
830
- * visible track that holds footage, when one of them holds an overlay (a
831
- * background graphic under the cut, say). Empty otherwise, and then every
832
- * overlay draws above the footage as before. When it is not empty, the canvas
833
- * holds only the footage over a transparent ground, and the composition
834
- * background is painted beneath the backdrop instead.
835
- */
836
- function backdropTracks(state) {
837
- const hit = memo.get(state);
838
- if (hit) return hit;
839
- const footage = state.tracks.filter((t) => !t.hidden && t.items.some((id) => FOOTAGE_KINDS.has(state.items.get(id)?.kind ?? "")));
840
- const lowest = footage.length ? Math.min(...footage.map((t) => t.order)) : -1;
841
- const drawn = (id) => {
842
- const k = state.items.get(id)?.kind ?? "";
843
- return k !== "adjustment" && OVERLAY_KINDS.has(k);
844
- };
845
- const below = state.tracks.filter((t) => t.order < lowest && !t.hidden && t.items.some(drawn));
846
- const set = new Set(below.map((t) => t.id));
847
- memo.set(state, set);
848
- return set;
849
- }
850
- // src/model/frame-plan.ts
851
- const BLEND_TO_CANVAS = {
852
- normal: "source-over",
853
- multiply: "multiply",
854
- screen: "screen",
855
- overlay: "overlay",
856
- darken: "darken",
857
- lighten: "lighten",
858
- "color-dodge": "color-dodge",
859
- "color-burn": "color-burn",
860
- "hard-light": "hard-light",
861
- "soft-light": "soft-light",
862
- difference: "difference",
863
- exclusion: "exclusion",
864
- hue: "hue",
865
- saturation: "saturation",
866
- color: "color",
867
- luminosity: "luminosity",
868
- add: "lighter",
869
- "linear-dodge": "lighter"
870
- };
871
- function blendOf(value) {
872
- return BLEND_TO_CANVAS[value] ?? "source-over";
873
- }
874
- /**
875
- * Everything the painter needs to draw composition time `t`, in draw order.
876
- * Pure: the same state and time give a deep-equal plan. DOM-rendered kinds
877
- * (title, shape, svg, sticker, layer, captions) are not in it; the DOM layer
878
- * reads `visiblePlacements` itself.
879
- */
880
- function buildFramePlan(state, t, resolveNested) {
881
- const comp = state.composition;
882
- const warnings = [];
883
- const ops = [];
884
- const { background, backgroundGradient: gradient, blur } = compositionBackground(state);
885
- const clear = backdropTracks(state).size > 0;
886
- if (blur && clear) warnings.push("background mode blur is drawn as a colour under backdrop overlays");
887
- for (const item of visiblePlacements(state, t)) {
888
- const p = item.placement;
889
- if (p.kind === "clip" || p.kind === "nested") {
890
- const op = mediaOp(state, item, t, resolveNested, warnings);
891
- if (op) ops.push(op);
892
- const dip = item.transition && transitionLook(item.transition, comp.width, comp.height).dip;
893
- if (op && dip) ops.push(dipOp(item.transition.placement, dip, comp.width, comp.height));
894
- } else if (p.kind === "adjustment") {
895
- if (p.props.enabled === false) continue;
896
- const { filter } = filterString(item.children, p.id, t - p.start);
897
- if (ops.length === 0) continue;
898
- ops.push({
899
- id: p.id,
900
- kind: "adjustment",
901
- rect: {
902
- x: 0,
903
- y: 0,
904
- w: comp.width,
905
- h: comp.height
906
- },
907
- matrix: {
908
- a: 1,
909
- b: 0,
910
- c: 0,
911
- d: 1,
912
- e: 0,
913
- f: 0
914
- },
915
- fit: "stretch",
916
- opacity: Number(p.props.opacity ?? 1),
917
- blend: blendOf(p.props.blend),
918
- filter,
919
- borderRadius: 0
920
- });
921
- }
922
- }
923
- const footage = blur && !clear ? ops.find((o) => o.kind === "video" || o.kind === "image" || o.kind === "nested") : void 0;
924
- return {
925
- time: t,
926
- frame: frameIndex(t, comp.fps),
927
- background,
928
- ...gradient ? { backgroundGradient: gradient } : {},
929
- ...clear ? { clear: true } : {},
930
- ...footage ? { backgroundBlur: { id: footage.id } } : {},
931
- ops,
932
- warnings
933
- };
934
- }
935
- /** The composition's background as the painter draws it: a colour, or a gradient (whose first colour is `background`). */
936
- function compositionBackground(state) {
937
- const bg = state.items.get(state.composition.id)?.props.background;
938
- const gradient = bg?.mode === "gradient" && bg.gradient && bg.gradient.stops.length >= 2 ? bg.gradient : void 0;
939
- return {
940
- background: bg?.color ?? gradient?.stops[0]?.color ?? "#000000",
941
- ...gradient ? { backgroundGradient: gradient } : {},
942
- blur: bg?.mode === "blur"
943
- };
944
- }
945
- function mediaOp(state, item, t, resolveNested, warnings) {
946
- const p = item.placement;
947
- const comp = state.composition;
948
- const rel = t - p.start;
949
- const transform = animatedTransform(p.props.transform, item.children, rel);
950
- const fit = p.props.fit ?? "contain";
951
- let opacity = (transform.opacity ?? 1) * (typeof p.props.blendOpacity === "number" ? p.props.blendOpacity : 1);
952
- let look;
953
- if (item.transition) {
954
- const x = item.transition.placement;
955
- const type = String(x.props.type ?? "crossfade");
956
- if (!RENDERED_TRANSITIONS.includes(type)) {
957
- devWarn(`transition:${x.id}:${type}`, `transition "${type}" on "${x.id}" is drawn as a crossfade`);
958
- warnings.push(`transition ${x.id}: ${type} drawn as crossfade`);
959
- }
960
- look = transitionLook(item.transition, comp.width, comp.height);
961
- opacity *= look.opacity;
962
- }
963
- const { filter } = filterString(item.children, p.id, rel);
964
- const mask = maskOf(item.children, warnings);
965
- const post = postEffects(item.children, frameIndex(t, comp.fps), rel);
966
- const common = {
967
- id: p.id,
968
- fit,
969
- opacity,
970
- blend: blendOf(p.props.blend),
971
- filter,
972
- borderRadius: transform.borderRadius ?? 0,
973
- ...mask ? { mask } : {},
974
- ...look?.clip ? { clip: look.clip } : {},
975
- ...post ? { post } : {}
976
- };
977
- const shift = (m) => look?.offset ? multiply(translate(look.offset.x, look.offset.y), m) : m;
978
- if (p.kind === "nested") {
979
- const nested = resolveNested?.(p) ?? null;
980
- if (!nested) {
981
- warnings.push(`nested ${p.id}: no resolver`);
982
- const layout = layoutRect(comp, comp, fit, transform);
983
- return {
984
- ...common,
985
- kind: "error",
986
- rect: layout.rect,
987
- matrix: shift(layout.matrix)
988
- };
989
- }
990
- const layout = layoutRect(comp, nested.composition, fit, transform);
991
- return {
992
- ...common,
993
- kind: "nested",
994
- nested: {
995
- state: nested,
996
- time: nestedSourceTime(p, t)
997
- },
998
- rect: layout.rect,
999
- matrix: shift(layout.matrix),
1000
- ...layout.crop ? { crop: layout.crop } : {}
1001
- };
1002
- }
1003
- const src = p.source;
1004
- const asset = src ? lookupAsset(state.manifest, src.asset) : null;
1005
- if (!src || !asset) {
1006
- const layout = layoutRect(comp, comp, "contain", transform);
1007
- return {
1008
- ...common,
1009
- kind: "error",
1010
- ...src ? { asset: src.asset } : {},
1011
- rect: layout.rect,
1012
- matrix: shift(layout.matrix)
1013
- };
1014
- }
1015
- const size = {
1016
- width: asset.width ?? comp.width,
1017
- height: asset.height ?? comp.height
1018
- };
1019
- const layout = layoutRect(comp, size, fit, transform);
1020
- return {
1021
- ...common,
1022
- kind: asset.kind === "image" ? "image" : "video",
1023
- asset: asset.id,
1024
- sourceTime: placementSourceTime(p, item.children, t),
1025
- rect: layout.rect,
1026
- matrix: shift(layout.matrix),
1027
- ...layout.crop ? {
1028
- crop: layout.crop,
1029
- cropReference: size
1030
- } : {}
1031
- };
1032
- }
1033
- /** A dip to black or white over everything drawn so far: an adjustment that maps the picture to the colour, `amount` of the way. */
1034
- function dipOp(x, amount, width, height) {
1035
- return {
1036
- id: x.id,
1037
- kind: "adjustment",
1038
- rect: {
1039
- x: 0,
1040
- y: 0,
1041
- w: width,
1042
- h: height
1043
- },
1044
- matrix: {
1045
- a: 1,
1046
- b: 0,
1047
- c: 0,
1048
- d: 1,
1049
- e: 0,
1050
- f: 0
1051
- },
1052
- fit: "stretch",
1053
- opacity: amount,
1054
- blend: "source-over",
1055
- filter: x.props.type === "dipToWhite" ? "brightness(0) invert(1)" : "brightness(0)",
1056
- borderRadius: 0
1057
- };
1058
- }
1059
- function maskOf(children, warnings) {
1060
- const m = children.find((c) => c.kind === "mask");
1061
- if (!m) return void 0;
1062
- if (!RENDERED_MASK_TYPES.includes(m.props.type ?? "shape") || m.props.matte) {
1063
- warnings.push(`mask ${m.id}: matte masks are not rendered`);
1064
- return;
1065
- }
1066
- return {
1067
- shape: m.props.shape,
1068
- path: m.props.path,
1069
- feather: Number(m.props.feather ?? 0),
1070
- inverted: m.props.inverted === true,
1071
- opacity: Number(m.props.opacity ?? 1)
1072
- };
1073
- }
1074
- export { MIN_RATE as $, hasKeyframes as A, devWarn as B, transformMatrix as C, visiblePlacements as D, trackItems as E, localT as F, DUCK_RELEASE_S as G, AUDIO_CHUNK_S as H, nestedSourceTime as I, FRAME_WAIT_MS as J, ENVELOPE_STEP_S as K, placementSourceTime as L, frameIndex as M, frameTime as N, EASING_FN as O, layerClock as P, MAX_RATE as Q, timecode as R, scaleM as S, isVisibleAt as T, AUDIO_PRIME_S as U, AUDIO_CACHE_S as V, DUCK_ATTACK_S as W, LOOKAHEAD_S as X, INPUT_IDLE_CLOSE_S as Y, MAX_OPEN_INPUTS as Z, fitRect as _, backdropTracks as a, REVERSE_WINDOW_FRAMES as at, multiply as b, combine as c, SEEK_MODE_SPEED as ct, filterString as d, MOUNT_AHEAD_S as et, postEffects as f, cssTransform as g, boundsOf as h, OVERLAY_KINDS as i, QUEUE_TARGET as it, keyframedValue as j, ease as k, effect as l, SETTLE_MS as lt, animatedTransform as m, buildFramePlan as n, PLAY_PRIME_TIMEOUT_MS as nt, transitionLook as o, REVERSE_WINDOW_S as ot, IDENTITY as p, FRAME_EPS as q, compositionBackground as r, QUEUE_MAX as rt, STILL as s, SCHEDULER_MS as st, blendOf as t, PICK_EPS as tt, motionAt as u, UNMOUNT_BEHIND_S as ut, layoutRect as v, translate as w, rotate as x, matrixToCss as y, captureWarnings as z };