@pixerate/editor 0.77.0 → 0.79.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.
@@ -17,6 +17,194 @@ var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
17
17
  var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
18
18
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
19
19
 
20
+ // src/video-editor/keyframes.ts
21
+ var VIDEO_EASE_NAMES = ["linear", "hold", "ease-in", "ease-out", "ease-in-out"];
22
+ var VIDEO_KEYFRAME_PROPERTIES = ["opacity", "volume", "x", "y", "scale", "rotation"];
23
+ var VIDEO_TRANSFORM_PROPERTIES = ["x", "y", "scale", "rotation"];
24
+ var VIDEO_KEYFRAME_LIMITS = {
25
+ opacity: { default: 1, min: 0, max: 1 },
26
+ volume: { default: 1, min: 0, max: 4 },
27
+ x: { default: 0 },
28
+ y: { default: 0 },
29
+ scale: { default: 1, min: 0 },
30
+ rotation: { default: 0 }
31
+ };
32
+ var NAMED = {
33
+ "ease-in": [0.42, 0, 1, 1],
34
+ "ease-out": [0, 0, 0.58, 1],
35
+ "ease-in-out": [0.42, 0, 0.58, 1]
36
+ };
37
+ var isVideoKeyframes = (v) => typeof v === "object" && v !== null && Array.isArray(v.keys);
38
+ function videoEaseBezier(ease) {
39
+ if (ease === void 0 || ease === "linear" || ease === "hold") return null;
40
+ return Array.isArray(ease) ? ease : NAMED[ease];
41
+ }
42
+ var bez = (a, b, s) => 3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;
43
+ var bezSlope = (a, b, s) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * (1 - s) * s + 3 * (1 - b) * s * s;
44
+ function bezierParam(x1, x2, x) {
45
+ let s = x;
46
+ for (let i = 0; i < 8; i++) {
47
+ const err = bez(x1, x2, s) - x;
48
+ if (Math.abs(err) < 1e-7) return s;
49
+ const d = bezSlope(x1, x2, s);
50
+ if (Math.abs(d) < 1e-6) break;
51
+ s -= err / d;
52
+ if (s < 0 || s > 1) break;
53
+ }
54
+ let lo = 0;
55
+ let hi = 1;
56
+ s = x;
57
+ for (let i = 0; i < 60; i++) {
58
+ const v = bez(x1, x2, s);
59
+ if (Math.abs(v - x) < 1e-9) break;
60
+ if (v < x) lo = s;
61
+ else hi = s;
62
+ s = (lo + hi) / 2;
63
+ }
64
+ return s;
65
+ }
66
+ function videoEaseProgress(ease, p) {
67
+ if (p <= 0) return 0;
68
+ if (p >= 1) return ease === "hold" ? 0 : 1;
69
+ if (ease === "hold") return 0;
70
+ const b = videoEaseBezier(ease);
71
+ if (!b) return p;
72
+ return bez(b[1], b[3], bezierParam(b[0], b[2], p));
73
+ }
74
+ function keyBefore(keys, t) {
75
+ let lo = 0;
76
+ let hi = keys.length - 1;
77
+ if (t < keys[0].t) return -1;
78
+ while (lo < hi) {
79
+ const mid = lo + hi + 1 >> 1;
80
+ if (keys[mid].t <= t) lo = mid;
81
+ else hi = mid - 1;
82
+ }
83
+ return lo;
84
+ }
85
+ function videoValueAt(prop, clipTime, fallback = 0) {
86
+ if (prop === void 0) return fallback;
87
+ if (typeof prop === "number") return prop;
88
+ const { keys } = prop;
89
+ if (!keys.length) return fallback;
90
+ const i = keyBefore(keys, clipTime);
91
+ if (i < 0) return keys[0].v;
92
+ if (i >= keys.length - 1) return keys[keys.length - 1].v;
93
+ const a = keys[i];
94
+ const b = keys[i + 1];
95
+ if (a.ease === "hold") return a.v;
96
+ return a.v + (b.v - a.v) * videoEaseProgress(a.ease, (clipTime - a.t) / (b.t - a.t));
97
+ }
98
+ var videoKeysOf = (prop) => isVideoKeyframes(prop) ? prop.keys : [];
99
+ function retimeVideoAnimatable(prop, dt, k = 1) {
100
+ if (!isVideoKeyframes(prop)) return prop;
101
+ return { keys: prop.keys.map((key) => ({ ...key, t: key.t * k + dt })) };
102
+ }
103
+ function splitEase(ease, p) {
104
+ if (ease === "hold") return ["hold", "hold"];
105
+ const b = videoEaseBezier(ease);
106
+ if (!b) return [ease, ease];
107
+ const s = bezierParam(b[0], b[2], p);
108
+ const lerp = (u, v, k) => [u[0] + (v[0] - u[0]) * k, u[1] + (v[1] - u[1]) * k];
109
+ const P0 = [0, 0];
110
+ const P1 = [b[0], b[1]];
111
+ const P2 = [b[2], b[3]];
112
+ const P3 = [1, 1];
113
+ const A = lerp(P0, P1, s);
114
+ const B = lerp(P1, P2, s);
115
+ const C = lerp(P2, P3, s);
116
+ const AB = lerp(A, B, s);
117
+ const BC = lerp(B, C, s);
118
+ const M = lerp(AB, BC, s);
119
+ const r = (v) => Math.round(v * 1e6) / 1e6;
120
+ const left = Math.abs(M[1]) < 1e-9 ? "linear" : [r(A[0] / M[0]), r(A[1] / M[1]), r(AB[0] / M[0]), r(AB[1] / M[1])];
121
+ const dx = 1 - M[0];
122
+ const dy = 1 - M[1];
123
+ const right = Math.abs(dy) < 1e-9 ? "linear" : [r((BC[0] - M[0]) / dx), r((BC[1] - M[1]) / dy), r((C[0] - M[0]) / dx), r((C[1] - M[1]) / dy)];
124
+ return [left, right];
125
+ }
126
+ function splitVideoAnimatable(prop, at) {
127
+ if (!isVideoKeyframes(prop)) return [prop, prop];
128
+ const keys = prop.keys;
129
+ const eps = 1e-6;
130
+ const v = videoValueAt(prop, at);
131
+ const i = keyBefore(keys, at);
132
+ const exact = i >= 0 && Math.abs(keys[i].t - at) < eps;
133
+ let leftEase;
134
+ let rightEase;
135
+ if (exact) rightEase = keys[i].ease;
136
+ else if (i >= 0 && i < keys.length - 1) {
137
+ const a = keys[i];
138
+ [leftEase, rightEase] = splitEase(a.ease, (at - a.t) / (keys[i + 1].t - a.t));
139
+ }
140
+ const before = keys.slice(0, exact ? i : i + 1).map((k) => ({ ...k }));
141
+ if (before.length && leftEase !== void 0) before[before.length - 1] = withEase(before[before.length - 1], leftEase);
142
+ const left = [...before, { t: at, v }];
143
+ const right = [withEase({ t: 0, v }, rightEase), ...keys.slice(i + 1).map((k) => ({ ...k, t: k.t - at }))];
144
+ return [{ keys: left }, { keys: right }];
145
+ }
146
+ var withEase = (key, ease) => {
147
+ const { ease: _old, ...rest } = key;
148
+ return ease === void 0 || ease === "linear" ? rest : { ...rest, ease };
149
+ };
150
+ function sortVideoKeys(keys) {
151
+ const sorted = [...keys].sort((a, b) => a.t - b.t);
152
+ for (let i = 1; i < sorted.length; i++) if (Math.abs(sorted[i].t - sorted[i - 1].t) < 1e-6) throw new Error(`Two keys are at ${sorted[i].t} ms; give each key its own time.`);
153
+ return sorted;
154
+ }
155
+ function setVideoKey(prop, key, fallback) {
156
+ const keys = isVideoKeyframes(prop) ? prop.keys.filter((k) => Math.abs(k.t - key.t) >= 1e-6) : [];
157
+ if (!isVideoKeyframes(prop)) {
158
+ const v = _nullishCoalesce(prop, () => ( fallback));
159
+ if (key.t !== 0) keys.push({ t: 0, v });
160
+ }
161
+ return { keys: sortVideoKeys([...keys, key]) };
162
+ }
163
+ function removeVideoKey(prop, t) {
164
+ const keys = prop.keys.filter((k) => Math.abs(k.t - t) >= 1e-6);
165
+ return keys.length ? { keys } : prop.keys[0].v;
166
+ }
167
+ var fmt = (v) => {
168
+ const r = Math.round(v * 1e4) / 1e4;
169
+ return Object.is(r, -0) ? "0" : String(r);
170
+ };
171
+ var signed = (v) => fmt(v).startsWith("-") ? fmt(v) : `+${fmt(v)}`;
172
+ function videoKeyframeBreakpoints(prop, step) {
173
+ const { keys } = prop;
174
+ const points = [];
175
+ const holds = /* @__PURE__ */ new Set();
176
+ for (let i = 0; i < keys.length; i++) {
177
+ const a = keys[i];
178
+ points.push([a.t, a.v]);
179
+ const b = keys[i + 1];
180
+ if (!b) break;
181
+ if (a.ease === "hold") {
182
+ holds.add(points.length - 1);
183
+ continue;
184
+ }
185
+ if (!videoEaseBezier(a.ease)) continue;
186
+ const n2 = Math.max(1, Math.ceil((b.t - a.t) / step));
187
+ for (let k = 1; k < n2; k++) {
188
+ const t = a.t + (b.t - a.t) * k / n2;
189
+ points.push([t, videoValueAt(prop, t)]);
190
+ }
191
+ }
192
+ return { points, holds };
193
+ }
194
+ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
195
+ if (!isVideoKeyframes(prop)) return fmt(_nullishCoalesce(prop, () => ( fallback)));
196
+ const { points, holds } = videoKeyframeBreakpoints(prop, step);
197
+ if (points.length === 1 || points.every(([, v]) => v === points[0][1])) return fmt(points[0][1]);
198
+ let expr = fmt(points[points.length - 1][1]);
199
+ for (let i = points.length - 2; i >= 0; i--) {
200
+ const [t0, v0] = points[i];
201
+ const [t1, v1] = points[i + 1];
202
+ const seg = holds.has(i) || v0 === v1 ? fmt(v0) : `${fmt(v0)}${signed((v1 - v0) / (t1 - t0))}*(${time}${signed(-t0)})`;
203
+ expr = `if(lt(${time},${fmt(t1)}),${seg},${expr})`;
204
+ }
205
+ return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
206
+ }
207
+
20
208
  // src/video-editor/parse.ts
21
209
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
22
210
  var fail = (path, what) => {
@@ -46,11 +234,44 @@ var oneOf = (v, options, path, optional = true) => {
46
234
  return v;
47
235
  };
48
236
  var compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
237
+ function parseVideoEase(v, path) {
238
+ if (v === void 0) return void 0;
239
+ if (Array.isArray(v)) {
240
+ if (v.length !== 4 || !v.every((n2) => typeof n2 === "number" && Number.isFinite(n2))) fail(path, "must be four numbers [x1, y1, x2, y2] (CSS cubic-bezier)");
241
+ if (v[0] < 0 || v[0] > 1 || v[2] < 0 || v[2] > 1) fail(path, "must have x1 and x2 between 0 and 1");
242
+ return [v[0], v[1], v[2], v[3]];
243
+ }
244
+ return oneOf(v, VIDEO_EASE_NAMES, path);
245
+ }
246
+ function parseVideoKeyframes(v, path, { min, max } = {}) {
247
+ if (!isObject(v)) return fail(path, "must be a number or keyframes { keys: [{ t, v, ease }] }");
248
+ for (const key of Object.keys(v)) if (key !== "keys") fail(`${path}.${key}`, "is not a keyframes field (keys)");
249
+ if (!Array.isArray(v.keys) || !v.keys.length) fail(`${path}.keys`, "must be an array of at least one key { t, v, ease }");
250
+ const keys = v.keys.map((k, i) => {
251
+ const at = `${path}.keys[${i}]`;
252
+ if (!isObject(k)) return fail(at, "must be an object { t, v, ease }");
253
+ for (const key of Object.keys(k)) if (!["t", "v", "ease"].includes(key)) fail(`${at}.${key}`, "is not a key field (t, v, ease)");
254
+ return compact({ t: num(k.t, `${at}.t`), v: num(k.v, `${at}.v`, { min, max }), ease: parseVideoEase(k.ease, `${at}.ease`) });
255
+ });
256
+ for (let i = 1; i < keys.length; i++) if (!(keys[i].t > keys[i - 1].t)) fail(`${path}.keys[${i}].t`, `must be later than the key before (${keys[i - 1].t} ms); keys go in time order, one per time`);
257
+ return { keys };
258
+ }
259
+ function parseVideoAnimatable(v, path, range = {}) {
260
+ if (v === void 0 && range.optional) return void 0;
261
+ const { min, max } = range;
262
+ if (isObject(v)) return parseVideoKeyframes(v, path, { min, max });
263
+ return num(v, path, { min, max });
264
+ }
49
265
  function parseVideoTransform(v, path) {
50
266
  if (v === void 0) return void 0;
51
267
  if (!isObject(v)) return fail(path, "must be an object { x, y, scale, rotation }");
52
268
  for (const key of Object.keys(v)) if (!["x", "y", "scale", "rotation"].includes(key)) fail(`${path}.${key}`, "is not a transform field (x, y, scale, rotation)");
53
- return compact({ x: num(v.x, `${path}.x`, { optional: true }), y: num(v.y, `${path}.y`, { optional: true }), scale: num(v.scale, `${path}.scale`, { optional: true, min: 0 }), rotation: num(v.rotation, `${path}.rotation`, { optional: true }) });
269
+ return compact({
270
+ x: parseVideoAnimatable(v.x, `${path}.x`, { optional: true }),
271
+ y: parseVideoAnimatable(v.y, `${path}.y`, { optional: true }),
272
+ scale: parseVideoAnimatable(v.scale, `${path}.scale`, { optional: true, min: 0 }),
273
+ rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
274
+ });
54
275
  }
55
276
  function parseVideoTransition(v, path) {
56
277
  if (v === void 0) return void 0;
@@ -87,7 +308,7 @@ function parseVideoClip(v, path = "clip") {
87
308
  name: str(v.name, `${path}.name`, true),
88
309
  start: num(v.start, `${path}.start`, { min: 0 }),
89
310
  duration: num(v.duration, `${path}.duration`, { positive: true }),
90
- opacity: num(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
311
+ opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
91
312
  transform: parseVideoTransform(v.transform, `${path}.transform`),
92
313
  fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
93
314
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
@@ -101,7 +322,7 @@ function parseVideoClip(v, path = "clip") {
101
322
  asset: str(v.asset, `${path}.asset`),
102
323
  in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
103
324
  speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
104
- volume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
325
+ volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
105
326
  muted: bool(v.muted, `${path}.muted`)
106
327
  });
107
328
  if (type === "text")
@@ -311,6 +532,14 @@ function videoClipsAt(project, time) {
311
532
  }
312
533
 
313
534
  // src/video-editor/check.ts
535
+ function videoClipKeyframes(clip) {
536
+ const out = [
537
+ { property: "opacity", keys: videoKeysOf(clip.opacity) },
538
+ { property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
539
+ ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _3 => _3.transform, 'optionalAccess', _4 => _4[p]])) }))
540
+ ];
541
+ return out.filter((o) => o.keys.length);
542
+ }
314
543
  var VIDEO_ISSUE_CODES = [
315
544
  "missing-asset",
316
545
  "wrong-track",
@@ -320,6 +549,7 @@ var VIDEO_ISSUE_CODES = [
320
549
  "transition-too-long",
321
550
  "transition-handles",
322
551
  "fades-too-long",
552
+ "keyframes-outside-clip",
323
553
  "unknown-duration",
324
554
  "unused-asset",
325
555
  "marker-past-end",
@@ -355,6 +585,16 @@ function checkVideoProject(project) {
355
585
  issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}" from ${formatVideoSeconds(next.start)}; move one of them.` });
356
586
  if ((_nullishCoalesce(clip.fadeIn, () => ( 0))) + (_nullishCoalesce(clip.fadeOut, () => ( 0))) > clip.duration + VIDEO_TIME_EPSILON)
357
587
  issues.push({ code: "fades-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s fades (${_nullishCoalesce(clip.fadeIn, () => ( 0))} + ${_nullishCoalesce(clip.fadeOut, () => ( 0))} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
588
+ for (const { property, keys } of videoClipKeyframes(clip)) {
589
+ const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
590
+ if (outside.length)
591
+ issues.push({
592
+ code: "keyframes-outside-clip",
593
+ severity: "warning",
594
+ ...at,
595
+ message: `Clip "${clip.id}" has ${property} key${outside.length > 1 ? "s" : ""} at ${outside.map((k) => `${Math.round(k.t)} ms`).join(", ")}, outside the clip (0\u2013${Math.round(clip.duration)} ms in clip time), so ${outside.length > 1 ? "they never play" : "it never plays"} directly; move ${outside.length > 1 ? "them" : "it"} inside or remove ${outside.length > 1 ? "them" : "it"} (set_video_keyframes).`
596
+ });
597
+ }
358
598
  if (clip.transition) {
359
599
  const d = clip.transition.duration;
360
600
  const before = videoClipBefore(track, clip);
@@ -408,6 +648,7 @@ var VIDEO_EDIT_OPS = [
408
648
  "delete_clips",
409
649
  "update_clip",
410
650
  "set_transition",
651
+ "set_keyframes",
411
652
  "close_gaps",
412
653
  "add_marker",
413
654
  "update_marker",
@@ -426,6 +667,21 @@ function freshId(prefix, taken) {
426
667
  for (let n2 = 1; ; n2++) if (!used.has(`${prefix}${n2}`)) return `${prefix}${n2}`;
427
668
  }
428
669
  var slug = (s) => s.replace(/^.*[\\/]/, "").replace(/\.[a-z0-9]+(\?.*)?$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
670
+ function clipAnimatables(clip) {
671
+ const c = clip;
672
+ const out = [
673
+ { get: () => c.opacity, set: (v) => c.opacity = v },
674
+ { get: () => c.volume, set: (v) => c.volume = v }
675
+ ];
676
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _5 => _5.transform, 'optionalAccess', _6 => _6[p]]), set: (v) => c.transform && (c.transform[p] = v) });
677
+ return out;
678
+ }
679
+ function retimeClipKeys(clip, fn) {
680
+ for (const a of clipAnimatables(clip)) {
681
+ const v = a.get();
682
+ if (isVideoKeyframes(v)) a.set(fn(v));
683
+ }
684
+ }
429
685
  var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
430
686
  var Editor = (_class = class {
431
687
  constructor(p) {;_class.prototype.__init.call(this);
@@ -464,7 +720,7 @@ var Editor = (_class = class {
464
720
  kindFits(track, clip) {
465
721
  const asset = clip.type === "media" ? this.asset(clip.asset) : null;
466
722
  if (track.kind === "audio" && (clip.type !== "media" || asset.kind === "image")) refuse(`Track "${track.id}" is an audio track; ${clip.type === "media" ? "images" : `${clip.type} clips`} go on a video track.`);
467
- if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _3 => _3.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
723
+ if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _7 => _7.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
468
724
  }
469
725
  /** Moves every clip on `track` starting at or after `from` by `delta`. */
470
726
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
@@ -596,6 +852,7 @@ var Editor = (_class = class {
596
852
  clip.in = Math.max(0, sourceIn);
597
853
  if (clip.in === 0) delete clip.in;
598
854
  }
855
+ retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
599
856
  if (e.ripple) {
600
857
  clip.duration -= delta;
601
858
  this.shift(track, end, -delta, /* @__PURE__ */ new Set([clip.id]));
@@ -611,7 +868,7 @@ var Editor = (_class = class {
611
868
  if (clip.type === "media") {
612
869
  const asset = assets.get(clip.asset);
613
870
  const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
614
- if (_optionalChain([asset, 'optionalAccess', _4 => _4.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
871
+ if (_optionalChain([asset, 'optionalAccess', _8 => _8.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
615
872
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
616
873
  }
617
874
  if (e.ripple) this.shift(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
@@ -633,6 +890,12 @@ var Editor = (_class = class {
633
890
  const right = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };
634
891
  delete right.transition;
635
892
  delete right.fadeIn;
893
+ const leftValues = clipAnimatables(clip);
894
+ clipAnimatables(right).forEach((r, i) => {
895
+ const [l, rr] = splitVideoAnimatable(leftValues[i].get(), left);
896
+ if (isVideoKeyframes(l)) leftValues[i].set(l);
897
+ if (isVideoKeyframes(rr)) r.set(rr);
898
+ });
636
899
  if (clip.type === "media") {
637
900
  right.in = (_nullishCoalesce(clip.in, () => ( 0))) + left * videoClipSpeed(clip);
638
901
  }
@@ -659,12 +922,17 @@ var Editor = (_class = class {
659
922
  for (const k of ["id", "type"]) if (k in set) refuse(`A clip's ${k} cannot be changed; delete it and add a new one.`);
660
923
  for (const k of _nullishCoalesce(e.remove, () => ( []))) if (!OPTIONAL_CLIP_FIELDS.includes(k)) refuse(`"${k}" cannot be removed; removable fields are ${OPTIONAL_CLIP_FIELDS.join(", ")}.`);
661
924
  if ("speed" in set && (typeof set.speed !== "number" || !(set.speed > 0))) refuse('"speed" must be a number greater than 0 (1 = normal speed).');
925
+ let stretch = 1;
662
926
  if (at.clip.type === "media" && !("duration" in set)) {
663
927
  const was = videoClipSpeed(at.clip);
664
- const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _5 => _5.remove, 'optionalAccess', _6 => _6.includes, 'call', _7 => _7("speed")]) ? 1 : was;
665
- if (now !== was) set.duration = at.clip.duration * was / now;
928
+ const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _9 => _9.remove, 'optionalAccess', _10 => _10.includes, 'call', _11 => _11("speed")]) ? 1 : was;
929
+ if (now !== was) {
930
+ set.duration = at.clip.duration * was / now;
931
+ stretch = was / now;
932
+ }
666
933
  }
667
- const next = { ...at.clip, ...set };
934
+ const next = { ...structuredClone(at.clip), ...set };
935
+ if (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));
668
936
  for (const k of _nullishCoalesce(e.remove, () => ( []))) delete next[k];
669
937
  const clip = parseVideoClip(next, "set");
670
938
  if (clip.type === "media" && clip.asset !== at.clip.asset) this.kindFits(at.track, clip);
@@ -680,6 +948,37 @@ var Editor = (_class = class {
680
948
  else clip.transition = parseVideoTransition(e.transition, "transition");
681
949
  return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
682
950
  }
951
+ case "set_keyframes": {
952
+ const { track, clip } = this.clip(e.clip);
953
+ this.unlocked(track);
954
+ if (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`"${e.property}" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(", ")}.`);
955
+ if (e.property === "volume" && clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type} clip and has no volume; volume keys go on media clips.`);
956
+ const limits = VIDEO_KEYFRAME_LIMITS[e.property];
957
+ const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
958
+ const c = clip;
959
+ const current = transform ? _optionalChain([c, 'access', _12 => _12.transform, 'optionalAccess', _13 => _13[e.property]]) : c[e.property];
960
+ let value;
961
+ if (e.keys === null) {
962
+ const v = videoValueAt(current, 0, limits.default);
963
+ value = isVideoKeyframes(current) ? v === limits.default ? void 0 : v : current;
964
+ } else {
965
+ if (!Array.isArray(e.keys) || !e.keys.length) refuse('"keys" must list at least one key { t, v }; pass null to remove the keyframes.');
966
+ let sorted = [];
967
+ try {
968
+ sorted = sortVideoKeys(e.keys);
969
+ } catch (error) {
970
+ refuse(error.message);
971
+ }
972
+ value = parseVideoAnimatable({ keys: sorted }, e.property, limits);
973
+ }
974
+ if (transform) {
975
+ c.transform = { ...c.transform, [e.property]: value };
976
+ if (value === void 0) delete c.transform[e.property];
977
+ if (!Object.keys(c.transform).length) delete c.transform;
978
+ } else if (value === void 0) delete c[e.property];
979
+ else c[e.property] = value;
980
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
981
+ }
683
982
  case "close_gaps": {
684
983
  const track = this.track(e.track);
685
984
  this.unlocked(track);
@@ -739,7 +1038,7 @@ function applyVideoEdits(project, edits) {
739
1038
  editor.run(edit);
740
1039
  } catch (error) {
741
1040
  const message = error.message;
742
- return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _8 => _8.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1041
+ return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _14 => _14.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
743
1042
  }
744
1043
  }
745
1044
  let next;
@@ -757,7 +1056,10 @@ function applyVideoEdits(project, edits) {
757
1056
  }
758
1057
 
759
1058
  // src/video-editor/evaluate.ts
760
- function placeVideoRect(frame, size, fit = "contain", transform = {}) {
1059
+ function videoTransformAt(transform, clipTime = 0) {
1060
+ return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _15 => _15.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _16 => _16.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _17 => _17.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _18 => _18.rotation]), clipTime, 0) };
1061
+ }
1062
+ function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
761
1063
  let w = size.width;
762
1064
  let h = size.height;
763
1065
  if (fit === "fill") {
@@ -768,18 +1070,18 @@ function placeVideoRect(frame, size, fit = "contain", transform = {}) {
768
1070
  w *= k;
769
1071
  h *= k;
770
1072
  }
771
- const scale = _nullishCoalesce(_optionalChain([transform, 'optionalAccess', _9 => _9.scale]), () => ( 1));
772
- w *= scale;
773
- h *= scale;
774
- const cx = frame.width / 2 + (_nullishCoalesce(_optionalChain([transform, 'optionalAccess', _10 => _10.x]), () => ( 0)));
775
- const cy = frame.height / 2 + (_nullishCoalesce(_optionalChain([transform, 'optionalAccess', _11 => _11.y]), () => ( 0)));
1073
+ const t = videoTransformAt(transform, clipTime);
1074
+ w *= t.scale;
1075
+ h *= t.scale;
1076
+ const cx = frame.width / 2 + t.x;
1077
+ const cy = frame.height / 2 + t.y;
776
1078
  return { x: cx - w / 2, y: cy - h / 2, width: w, height: h };
777
1079
  }
778
1080
  function videoTextStyle(clip) {
779
1081
  return { text: clip.text, color: _nullishCoalesce(clip.color, () => ( "#ffffff")), fontSize: _nullishCoalesce(clip.fontSize, () => ( 64)), fontFamily: _nullishCoalesce(clip.fontFamily, () => ( "sans-serif")), fontWeight: _nullishCoalesce(clip.fontWeight, () => ( 700)), ...clip.background ? { background: clip.background } : {} };
780
1082
  }
781
- function videoTitleFrame(project, clip) {
782
- const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform), rotation: _nullishCoalesce(_optionalChain([clip, 'access', _12 => _12.transform, 'optionalAccess', _13 => _13.rotation]), () => ( 0)), text: videoTextStyle(clip) };
1083
+ function videoTitleFrame(project, clip, clipTime = 0) {
1084
+ const layer = { clip: clip.id, track: "", type: "text", opacity: 1, rect: placeVideoRect(project, project, "fill", clip.transform, clipTime), rotation: videoTransformAt(clip.transform, clipTime).rotation, text: videoTextStyle(clip) };
783
1085
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
784
1086
  }
785
1087
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
@@ -795,7 +1097,7 @@ function activeOnTrack(track, time) {
795
1097
  const before = clip.transition ? videoClipBefore(track, clip) : null;
796
1098
  const lead = before ? clip.transition.duration / 2 : 0;
797
1099
  const next = videoClipAfter(track, clip);
798
- const tail = _optionalChain([next, 'optionalAccess', _14 => _14.transition]) ? next.transition.duration / 2 : 0;
1100
+ const tail = _optionalChain([next, 'optionalAccess', _19 => _19.transition]) ? next.transition.duration / 2 : 0;
799
1101
  if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
800
1102
  const a = { clip };
801
1103
  if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
@@ -812,21 +1114,22 @@ function videoFrameAt(project, time) {
812
1114
  for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
813
1115
  const track = project.tracks[ti];
814
1116
  for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
815
- if (_optionalChain([incoming, 'optionalAccess', _15 => _15.type]) === "dip" && incoming.progress < 0.5) continue;
816
- if (_optionalChain([outgoing, 'optionalAccess', _16 => _16.type]) === "dip" && outgoing.progress >= 0.5) continue;
1117
+ if (_optionalChain([incoming, 'optionalAccess', _20 => _20.type]) === "dip" && incoming.progress < 0.5) continue;
1118
+ if (_optionalChain([outgoing, 'optionalAccess', _21 => _21.type]) === "dip" && outgoing.progress >= 0.5) continue;
817
1119
  const fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));
818
1120
  const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
819
1121
  const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
820
1122
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
821
1123
  const sourceTime = sourceTimeAt(clip, time);
1124
+ const clipTime = time - clip.start;
822
1125
  const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
823
1126
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
824
- const gain = (_nullishCoalesce(clip.volume, () => ( 1))) * (_nullishCoalesce(track.volume, () => ( 1))) * fade * mix;
1127
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) * fade * mix;
825
1128
  audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
826
1129
  }
827
1130
  if (track.kind !== "video" || track.hidden) continue;
828
1131
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
829
- let opacity = (_nullishCoalesce(clip.opacity, () => ( 1))) * fade;
1132
+ let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
830
1133
  let wipe;
831
1134
  let dip = null;
832
1135
  if (incoming) {
@@ -834,11 +1137,11 @@ function videoFrameAt(project, time) {
834
1137
  else if (incoming.type === "wipe") wipe = { direction: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
835
1138
  else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
836
1139
  }
837
- if (_optionalChain([outgoing, 'optionalAccess', _17 => _17.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1140
+ if (_optionalChain([outgoing, 'optionalAccess', _22 => _22.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
838
1141
  const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
839
1142
  const fit = _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill")));
840
- const rect = clip.type === "text" ? placeVideoRect(project, size, "fill", clip.transform) : placeVideoRect(project, size, fit, clip.transform);
841
- const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: _nullishCoalesce(_optionalChain([clip, 'access', _18 => _18.transform, 'optionalAccess', _19 => _19.rotation]), () => ( 0)) };
1143
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
1144
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(_optionalChain([clip, 'access', _23 => _23.transform, 'optionalAccess', _24 => _24.rotation]), clipTime, 0) };
842
1145
  if (wipe) layer.wipe = wipe;
843
1146
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
844
1147
  if (clip.type === "text") layer.text = videoTextStyle(clip);
@@ -983,7 +1286,7 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
983
1286
  }
984
1287
 
985
1288
  // src/video-editor/export.ts
986
- var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _20 => _20.name]), () => ( _optionalChain([asset, 'optionalAccess', _21 => _21.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
1289
+ var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _25 => _25.name]), () => ( _optionalChain([asset, 'optionalAccess', _26 => _26.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
987
1290
  function toVideoEdl(project, options = {}) {
988
1291
  const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
989
1292
  if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
@@ -1016,7 +1319,7 @@ function toVideoEdl(project, options = {}) {
1016
1319
  lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1017
1320
  } else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1018
1321
  if (clip.type === "media" && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);
1019
- if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _22 => _22.name]), () => ( _optionalChain([asset, 'optionalAccess', _23 => _23.src]))), () => ( clip.asset))}`);
1322
+ if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _27 => _27.name]), () => ( _optionalChain([asset, 'optionalAccess', _28 => _28.src]))), () => ( clip.asset))}`);
1020
1323
  if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
1021
1324
  if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
1022
1325
  lines.push("");
@@ -1060,7 +1363,13 @@ function toVideoFfmpeg(project, options = {}) {
1060
1363
  const audio = [];
1061
1364
  let cur = "base";
1062
1365
  let usesDrawtext = false;
1366
+ const animated = /* @__PURE__ */ new Set();
1063
1367
  let k = 0;
1368
+ const step = 2e3 / fps;
1369
+ const expr = (prop, clipTime, fallback, what) => {
1370
+ if (isVideoKeyframes(prop)) animated.add(what);
1371
+ return videoKeyframeFfmpegExpr(prop, clipTime, fallback, step);
1372
+ };
1064
1373
  const input = (clip, asset, lead, tail) => {
1065
1374
  const speed = videoClipSpeed(clip);
1066
1375
  const src = options.resolve ? options.resolve(asset) : asset.src;
@@ -1085,13 +1394,14 @@ function toVideoFfmpeg(project, options = {}) {
1085
1394
  const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
1086
1395
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1087
1396
  const ai = _nullishCoalesce(index, () => ( input(clip, asset, lead, tail)));
1088
- const gain = (_nullishCoalesce(clip.volume, () => ( 1))) * (_nullishCoalesce(track.volume, () => ( 1)));
1089
- const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), `volume=${gain}`];
1397
+ const trackGain = _nullishCoalesce(track.volume, () => ( 1));
1398
+ const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(_nullishCoalesce(clip.volume, () => ( 1))) * trackGain}`;
1399
+ const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
1090
1400
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
1091
1401
  if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1092
1402
  if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
1093
1403
  if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
1094
- const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && _optionalChain([c, 'access', _24 => _24.transition, 'optionalAccess', _25 => _25.type]) === "dip");
1404
+ const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && _optionalChain([c, 'access', _29 => _29.transition, 'optionalAccess', _30 => _30.type]) === "dip");
1095
1405
  if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
1096
1406
  if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);
1097
1407
  if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);
@@ -1102,33 +1412,43 @@ function toVideoFfmpeg(project, options = {}) {
1102
1412
  if (track.kind !== "video" || track.hidden) continue;
1103
1413
  const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
1104
1414
  const next = `v${k++}`;
1415
+ const onTimeline = `(t*1000-${Math.round(clip.start)})`;
1416
+ const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
1417
+ const tf = clip.transform;
1418
+ const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _31 => _31[p]])));
1105
1419
  if (clip.type === "text" && options.titleImage) {
1106
- inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(clip, { width: project.width, height: project.height })]);
1420
+ inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
1107
1421
  index = inputs.length - 1;
1108
1422
  } else if (clip.type === "text") {
1109
1423
  usesDrawtext = true;
1110
- const op = _nullishCoalesce(clip.opacity, () => ( 1));
1111
- const alpha = [String(op), clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
1112
- const x = _nullishCoalesce(_optionalChain([clip, 'access', _26 => _26.transform, 'optionalAccess', _27 => _27.x]), () => ( 0));
1113
- const y = _nullishCoalesce(_optionalChain([clip, 'access', _28 => _28.transform, 'optionalAccess', _29 => _29.y]), () => ( 0));
1424
+ const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
1425
+ const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
1426
+ const coord = (axis) => {
1427
+ const v = _optionalChain([tf, 'optionalAccess', _32 => _32[axis]]);
1428
+ const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
1429
+ return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
1430
+ };
1431
+ const fontSize = _nullishCoalesce(clip.fontSize, () => ( 64));
1114
1432
  const opts = [
1115
1433
  `text=${ffmpegFilterValue(clip.text)}`,
1116
1434
  options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(_nullishCoalesce(clip.fontFamily, () => ( "sans-serif")))}`,
1117
- `fontsize=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) * (_nullishCoalesce(_optionalChain([clip, 'access', _30 => _30.transform, 'optionalAccess', _31 => _31.scale]), () => ( 1))))}`,
1435
+ isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _33 => _33.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _34 => _34.scale]), () => ( 1))))}`,
1118
1436
  `fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
1119
- `x=(w-text_w)/2+${x}`,
1120
- `y=(h-text_h)/2+${y}`,
1437
+ coord("x"),
1438
+ coord("y"),
1121
1439
  `alpha='${alpha}'`,
1122
1440
  ...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
1123
1441
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1124
1442
  ];
1125
- if (_optionalChain([clip, 'access', _32 => _32.transform, 'optionalAccess', _33 => _33.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1443
+ if (_optionalChain([tf, 'optionalAccess', _35 => _35.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1126
1444
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
1127
1445
  cur = next;
1128
1446
  continue;
1129
1447
  }
1130
1448
  const size = asset ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
1131
- const rect = clip.type === "text" ? { x: 0, y: 0, width: project.width, height: project.height } : placeVideoRect(project, size, _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill"))), clip.transform);
1449
+ const placed = clip.type === "text" && !moving ? void 0 : tf;
1450
+ const still = Object.fromEntries(Object.entries(_nullishCoalesce(placed, () => ( {}))).filter(([, v]) => !isVideoKeyframes(v)));
1451
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill"))), still);
1132
1452
  const chain = [];
1133
1453
  let source;
1134
1454
  if (clip.type === "color") source = `color=c=${color(clip.color)}:s=${even(rect.width)}x${even(rect.height)}:r=${fps}:d=${sec(span)}`;
@@ -1136,8 +1456,13 @@ function toVideoFfmpeg(project, options = {}) {
1136
1456
  source = `[${index}:v]`;
1137
1457
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
1138
1458
  }
1459
+ if (isVideoKeyframes(_optionalChain([placed, 'optionalAccess', _36 => _36.scale]))) {
1460
+ const s = expr(placed.scale, onChain("t"), 1, "scale");
1461
+ chain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);
1462
+ }
1139
1463
  chain.push("format=yuva420p");
1140
- if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
1464
+ if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
1465
+ else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
1141
1466
  if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
1142
1467
  if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
1143
1468
  if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
@@ -1148,12 +1473,18 @@ function toVideoFfmpeg(project, options = {}) {
1148
1473
  const test = dir === "right" ? `lt(X,W*${p})` : dir === "left" ? `gt(X,W*(1-${p}))` : dir === "down" ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;
1149
1474
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
1150
1475
  }
1151
- if (_optionalChain([clip, 'access', _34 => _34.transform, 'optionalAccess', _35 => _35.rotation]) && clip.type !== "text") chain.push(`rotate=${clip.transform.rotation}*PI/180:c=none:ow=rotw(${clip.transform.rotation}*PI/180):oh=roth(${clip.transform.rotation}*PI/180)`);
1476
+ const rotation = _optionalChain([placed, 'optionalAccess', _37 => _37.rotation]);
1477
+ if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
1478
+ else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
1152
1479
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
1153
1480
  const cx = rect.x + rect.width / 2;
1154
1481
  const cy = rect.y + rect.height / 2;
1482
+ const at = (axis, c) => {
1483
+ const v = _optionalChain([placed, 'optionalAccess', _38 => _38[axis]]);
1484
+ return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2'`;
1485
+ };
1155
1486
  graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
1156
- graph.push(`[${cur}][l${next}]overlay=x='${Math.round(cx)}-w/2':y='${Math.round(cy)}-h/2':eof_action=pass:${enable}[${next}]`);
1487
+ graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
1157
1488
  cur = next;
1158
1489
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1159
1490
  if (before && clip.transition.type === "dip") {
@@ -1167,8 +1498,10 @@ function toVideoFfmpeg(project, options = {}) {
1167
1498
  }
1168
1499
  graph.push(`[${cur}]format=yuv420p[vout]`);
1169
1500
  if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);
1170
- if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _36 => _36.get, 'call', _37 => _37(c.asset), 'optionalAccess', _38 => _38.kind]) === "video" && _optionalChain([assets, 'access', _39 => _39.get, 'call', _40 => _40(c.asset), 'optionalAccess', _41 => _41.hasAudio]) === void 0)))
1501
+ if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _39 => _39.get, 'call', _40 => _40(c.asset), 'optionalAccess', _41 => _41.kind]) === "video" && _optionalChain([assets, 'access', _42 => _42.get, 'call', _43 => _43(c.asset), 'optionalAccess', _44 => _44.hasAudio]) === void 0)))
1171
1502
  notes.push('Video assets are assumed to have sound; set "hasAudio": false on any that do not, or ffmpeg will stop at a missing audio stream.');
1503
+ if (animated.size)
1504
+ notes.push(`Keyframed ${[...animated].sort().join(", ")} ${animated.size > 1 ? "are" : "is"} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has("scale") ? "; animated scale needs ffmpeg 5 or later (scale with eval=frame)" : ""}.`);
1172
1505
  if (usesDrawtext) notes.push("Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew's is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.");
1173
1506
  const filterComplex = graph.join(";");
1174
1507
  const args = [
@@ -1199,7 +1532,7 @@ var VideoPreviewEngine = (_class2 = class {
1199
1532
  this.source = source;
1200
1533
  this.options = options;
1201
1534
  this.canvas = canvas;
1202
- this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _42 => _42.getContext, 'optionalCall', _43 => _43("2d")]), () => ( null));
1535
+ this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _45 => _45.getContext, 'optionalCall', _46 => _46("2d")]), () => ( null));
1203
1536
  this.unsubscribe = source.subscribe(() => this.update());
1204
1537
  this.update();
1205
1538
  }
@@ -1247,8 +1580,8 @@ var VideoPreviewEngine = (_class2 = class {
1247
1580
  entry.media.pause();
1248
1581
  entry.media.removeEventListener("seeked", entry.onReady);
1249
1582
  entry.media.removeEventListener("loadeddata", entry.onReady);
1250
- _optionalChain([entry, 'access', _44 => _44.media, 'access', _45 => _45.removeAttribute, 'optionalCall', _46 => _46("src")]);
1251
- _optionalChain([entry, 'access', _47 => _47.media, 'access', _48 => _48.load, 'optionalCall', _49 => _49()]);
1583
+ _optionalChain([entry, 'access', _47 => _47.media, 'access', _48 => _48.removeAttribute, 'optionalCall', _49 => _49("src")]);
1584
+ _optionalChain([entry, 'access', _50 => _50.media, 'access', _51 => _51.load, 'optionalCall', _52 => _52()]);
1252
1585
  this.media.delete(entry.clip);
1253
1586
  }
1254
1587
  /** Brings the media elements in step with the editor and draws the frame. */
@@ -1281,7 +1614,7 @@ var VideoPreviewEngine = (_class2 = class {
1281
1614
  for (const layer of frame.layers) {
1282
1615
  if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
1283
1616
  const sound = sounding.get(layer.clip);
1284
- sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _50 => _50.speed]), () => ( 1)), sound ? sound.gain : null);
1617
+ sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _53 => _53.speed]), () => ( 1)), sound ? sound.gain : null);
1285
1618
  sounding.delete(layer.clip);
1286
1619
  }
1287
1620
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
@@ -1314,7 +1647,7 @@ var VideoPreviewEngine = (_class2 = class {
1314
1647
  if (this.canvas.width !== w) this.canvas.width = w;
1315
1648
  if (this.canvas.height !== h) this.canvas.height = h;
1316
1649
  drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
1317
- _optionalChain([this, 'access', _51 => _51.options, 'access', _52 => _52.onFrame, 'optionalCall', _53 => _53(frame)]);
1650
+ _optionalChain([this, 'access', _54 => _54.options, 'access', _55 => _55.onFrame, 'optionalCall', _56 => _56(frame)]);
1318
1651
  }
1319
1652
  /** Stops following the editor and releases every media element. */
1320
1653
  destroy() {
@@ -1421,7 +1754,7 @@ var VideoEditorController = (_class3 = class {
1421
1754
  this.applying = false;
1422
1755
  }
1423
1756
  this.notify();
1424
- _optionalChain([this, 'access', _54 => _54.onChange, 'optionalCall', _55 => _55(project)]);
1757
+ _optionalChain([this, 'access', _57 => _57.onChange, 'optionalCall', _58 => _58(project)]);
1425
1758
  }
1426
1759
  commit(label, next) {
1427
1760
  const before = this.project;
@@ -1485,7 +1818,7 @@ var VideoEditorController = (_class3 = class {
1485
1818
  }
1486
1819
  /** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
1487
1820
  select(clips, options = {}) {
1488
- const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _56 => _56.filter, 'call', _57 => _57((id) => findVideoClip(this.project, id))]), () => ( []));
1821
+ const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _59 => _59.filter, 'call', _60 => _60((id) => findVideoClip(this.project, id))]), () => ( []));
1489
1822
  let next;
1490
1823
  if (options.add) {
1491
1824
  next = [...this.selection];
@@ -1604,7 +1937,7 @@ var TRANSITION = object(
1604
1937
  );
1605
1938
  var CLIP_COMMON = {
1606
1939
  name: { type: "string", description: "A label for the clip." },
1607
- opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1)." },
1940
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
1608
1941
  transform: TRANSFORM,
1609
1942
  fit: { type: "string", enum: [...VIDEO_FITS], description: "How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours)." },
1610
1943
  fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
@@ -1613,7 +1946,7 @@ var CLIP_COMMON = {
1613
1946
  var MEDIA_FIELDS = {
1614
1947
  in: MS("Where in the source it starts (default 0)"),
1615
1948
  speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
1616
- volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1)." },
1949
+ volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
1617
1950
  muted: { type: "boolean", description: "Silence this clip (default false)." }
1618
1951
  };
1619
1952
  var TEXT_FIELDS = {
@@ -1660,6 +1993,32 @@ var ASSET_FIELDS = {
1660
1993
  hasAudio: { type: "boolean", description: "For video: whether it has sound (default true)." },
1661
1994
  poster: { type: "string", description: "For video: a still (URL) to show in rendered frames." }
1662
1995
  };
1996
+ var KEY_PROPERTY = {
1997
+ type: "string",
1998
+ enum: [...VIDEO_KEYFRAME_PROPERTIES],
1999
+ description: "Which property: opacity (0\u20131), volume (media only, 0\u20134 gain), x / y (pixels from the frame's centre, right / down), scale (1 = fitted size) or rotation (degrees clockwise)."
2000
+ };
2001
+ var KEYS = {
2002
+ type: "array",
2003
+ minItems: 1,
2004
+ maxItems: 500,
2005
+ description: "The keys, replacing any the property had (any order, one per time). Before the first key the property holds the first value, after the last the last value.",
2006
+ items: object(
2007
+ {
2008
+ t: { type: "number", description: "Time in ms from the clip's start (clip time, not timeline time: 0 is the clip's first frame, its duration the end). Keys stay with the clip when it moves, is trimmed or split." },
2009
+ v: { type: "number", description: "The value at that time, in the property's units." },
2010
+ ease: {
2011
+ description: "How the value moves from this key to the next (default linear): linear, hold (stays, then jumps at the next key), ease-in, ease-out, ease-in-out, or CSS cubic-bezier points [x1, y1, x2, y2] with x1 and x2 in 0\u20131.",
2012
+ anyOf: [
2013
+ { type: "string", enum: [...VIDEO_EASE_NAMES] },
2014
+ { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4 }
2015
+ ]
2016
+ }
2017
+ },
2018
+ ["t", "v"],
2019
+ "One keyframe."
2020
+ )
2021
+ };
1663
2022
  var issueSummary = (issues) => ({
1664
2023
  errors: issues.filter((i) => i.severity === "error").length,
1665
2024
  warnings: issues.filter((i) => i.severity === "warning").length,
@@ -1711,6 +2070,11 @@ function createVideoEditorToolProvider(controller, options = {}) {
1711
2070
  }
1712
2071
  return null;
1713
2072
  };
2073
+ const keysOut = (p, clip, property) => {
2074
+ const c = clipOut(p, clip);
2075
+ const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _61 => _61.transform, 'optionalAccess', _62 => _62[property]]) : _optionalChain([c, 'optionalAccess', _63 => _63[property]]);
2076
+ return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
2077
+ };
1714
2078
  const getProject = {
1715
2079
  name: "get_video_project",
1716
2080
  title: "Read the video project",
@@ -1748,7 +2112,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
1748
2112
  const checkProject = {
1749
2113
  name: "check_video_project",
1750
2114
  title: "Check the video project",
1751
- description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades longer than the clip, assets with no duration, unused assets.",
2115
+ description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades longer than the clip, keyframes outside their clip, assets with no duration, unused assets.",
1752
2116
  inputSchema: object({}),
1753
2117
  annotations: { title: "Check the video project", ...READ },
1754
2118
  handler: () => {
@@ -1973,6 +2337,22 @@ function createVideoEditorToolProvider(controller, options = {}) {
1973
2337
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
1974
2338
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
1975
2339
  };
2340
+ const setKeyframes = {
2341
+ name: "set_video_keyframes",
2342
+ title: "Animate a clip property",
2343
+ description: "Animates one property of a clip with keyframes (one undo step), replacing any keys it had: opacity, volume, or the transform's x, y, scale and rotation. Times are clip time (ms from the clip's start), so a key at 0 is the clip's first frame; get_video_project gives each clip's duration. Plays in the preview, renders and the ffmpeg export. Returns the property's keys, sorted.",
2344
+ inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY, keys: KEYS }, ["clip", "property", "keys"]),
2345
+ annotations: { title: "Animate a clip property", ...WRITE_IDEMPOTENT },
2346
+ handler: ({ clip, property, keys }) => apply({ op: "set_keyframes", clip, property, keys }, `Keyframe ${property}`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
2347
+ };
2348
+ const removeKeyframes = {
2349
+ name: "remove_video_keyframes",
2350
+ title: "Stop animating a clip property",
2351
+ description: "Removes all of a property's keyframes (one undo step), leaving it at the value it had at the clip's start. Returns the remaining keys (none) and that value (null for the default).",
2352
+ inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY }, ["clip", "property"]),
2353
+ annotations: { title: "Stop animating a clip property", ...WRITE_IDEMPOTENT },
2354
+ handler: ({ clip, property }) => apply({ op: "set_keyframes", clip, property, keys: null }, `Remove ${property} keyframes`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
2355
+ };
1976
2356
  const closeGaps = {
1977
2357
  name: "close_video_gaps",
1978
2358
  title: "Close gaps",
@@ -1999,7 +2379,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
1999
2379
  };
2000
2380
  const EDIT_OP = {
2001
2381
  type: "object",
2002
- description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, ripple }, move_clip { clip, start, track }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
2382
+ description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, ripple }, move_clip { clip, start, track }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
2003
2383
  properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
2004
2384
  required: ["op"]
2005
2385
  };
@@ -2040,7 +2420,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2040
2420
  }
2041
2421
  };
2042
2422
  const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];
2043
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
2423
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
2044
2424
  return {
2045
2425
  id: _nullishCoalesce(options.id, () => ( "video")),
2046
2426
  namespace: options.namespace,
@@ -2082,7 +2462,7 @@ function videoClipLabel(clip, asset) {
2082
2462
  if (clip.name) return clip.name;
2083
2463
  if (clip.type === "text") return clip.text.split("\n")[0];
2084
2464
  if (clip.type === "color") return clip.color;
2085
- const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _58 => _58.name]), () => ( _optionalChain([asset, 'optionalAccess', _59 => _59.src, 'access', _60 => _60.replace, 'call', _61 => _61(/^.*[\\/]/, "")]))), () => ( clip.asset));
2465
+ const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _64 => _64.name]), () => ( _optionalChain([asset, 'optionalAccess', _65 => _65.src, 'access', _66 => _66.replace, 'call', _67 => _67(/^.*[\\/]/, "")]))), () => ( clip.asset));
2086
2466
  const speed = videoClipSpeed(clip);
2087
2467
  return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
2088
2468
  }
@@ -2121,7 +2501,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
2121
2501
  const asset = videoAssetMap(project).get(assetId);
2122
2502
  if (!asset) return null;
2123
2503
  const kind = asset.kind === "audio" ? "audio" : "video";
2124
- const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _62 => _62.tracks, 'access', _63 => _63.find, 'call', _64 => _64((t) => t.kind === kind && !t.locked), 'optionalAccess', _65 => _65.id])));
2504
+ const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _68 => _68.tracks, 'access', _69 => _69.find, 'call', _70 => _70((t) => t.kind === kind && !t.locked), 'optionalAccess', _71 => _71.id])));
2125
2505
  if (!track) return null;
2126
2506
  return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {} };
2127
2507
  }
@@ -2162,6 +2542,96 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
2162
2542
  el.src = src;
2163
2543
  });
2164
2544
  }
2545
+ var KEY_LABELS = {
2546
+ opacity: { label: "Opacity", step: 0.05 },
2547
+ volume: { label: "Volume", step: 0.05 },
2548
+ x: { label: "X", step: 1 },
2549
+ y: { label: "Y", step: 1 },
2550
+ scale: { label: "Scale", step: 0.05 },
2551
+ rotation: { label: "Rotation", step: 1 }
2552
+ };
2553
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _72 => _72.transform, 'optionalAccess', _73 => _73[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
2554
+ var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
2555
+ function videoKeyframeRows(project, clipId, time) {
2556
+ const at = findVideoClip(project, clipId);
2557
+ if (!at) return [];
2558
+ const { clip } = at;
2559
+ const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
2560
+ const visual = !(_optionalChain([asset, 'optionalAccess', _74 => _74.kind]) === "audio" || at.track.kind === "audio");
2561
+ const audible = clip.type === "media" && (_optionalChain([asset, 'optionalAccess', _75 => _75.kind]) === "audio" || _optionalChain([asset, 'optionalAccess', _76 => _76.kind]) === "video" && asset.hasAudio !== false);
2562
+ const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
2563
+ const clipTime = playheadInClip(clip, time);
2564
+ return properties.map((property) => {
2565
+ const prop = animatableOf(clip, property);
2566
+ const keys = isVideoKeyframes(prop) ? prop.keys : [];
2567
+ const limits = VIDEO_KEYFRAME_LIMITS[property];
2568
+ return {
2569
+ property,
2570
+ ...KEY_LABELS[property],
2571
+ value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
2572
+ keyed: keys.length > 0,
2573
+ keys: keys.length,
2574
+ onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
2575
+ clipTime,
2576
+ ...limits.min !== void 0 ? { min: limits.min } : {},
2577
+ ...limits.max !== void 0 ? { max: limits.max } : {}
2578
+ };
2579
+ });
2580
+ }
2581
+ function plainValueEdit(clip, property, value) {
2582
+ if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
2583
+ return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
2584
+ const transform = { ...clip.transform, [property]: value };
2585
+ if (value === void 0) delete transform[property];
2586
+ return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
2587
+ }
2588
+ function videoKeyframeValueEdit(project, clipId, property, time, value) {
2589
+ const at = findVideoClip(project, clipId);
2590
+ if (!at) return null;
2591
+ const prop = animatableOf(at.clip, property);
2592
+ if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
2593
+ if (value === void 0) return null;
2594
+ return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
2595
+ }
2596
+ function videoKeyframeToggleEdit(project, clipId, property, time) {
2597
+ const at = findVideoClip(project, clipId);
2598
+ if (!at) return null;
2599
+ const prop = animatableOf(at.clip, property);
2600
+ const t = playheadInClip(at.clip, time);
2601
+ const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
2602
+ if (isVideoKeyframes(prop)) {
2603
+ const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
2604
+ if (key) {
2605
+ const rest = removeVideoKey(prop, key.t);
2606
+ return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
2607
+ }
2608
+ }
2609
+ const v = videoValueAt(prop, t, fallback);
2610
+ return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
2611
+ }
2612
+
2613
+
2614
+
2615
+
2616
+
2617
+
2618
+
2619
+
2620
+
2621
+
2622
+
2623
+
2624
+
2625
+
2626
+
2627
+
2628
+
2629
+
2630
+
2631
+
2632
+
2633
+
2634
+
2165
2635
 
2166
2636
 
2167
2637
 
@@ -2230,5 +2700,5 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
2230
2700
 
2231
2701
 
2232
2702
 
2233
- exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortedVideoClips = sortedVideoClips; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDropTracks = videoDropTracks; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoIssueKey = videoIssueKey; exports.videoOverlap = videoOverlap; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTitleFrame = videoTitleFrame; exports.videoWipeRect = videoWipeRect;
2703
+ exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTitleFrame = videoTitleFrame; exports.videoTransformAt = videoTransformAt; exports.videoValueAt = videoValueAt; exports.videoWipeRect = videoWipeRect;
2234
2704
  //# sourceMappingURL=index.cjs.map