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