@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 = 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(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(clip.transform?.[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 ((clip.fadeIn ?? 0) + (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 (${clip.fadeIn ?? 0} + ${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: () => c.transform?.[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 {
431
687
  constructor(p) {
@@ -596,6 +852,7 @@ var Editor = 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]));
@@ -633,6 +890,12 @@ var Editor = 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 = (clip.in ?? 0) + left * videoClipSpeed(clip);
638
901
  }
@@ -659,12 +922,17 @@ var Editor = 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 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
928
  const now = "speed" in set ? set.speed : e.remove?.includes("speed") ? 1 : was;
665
- if (now !== was) set.duration = at.clip.duration * was / now;
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 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 {
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 ? c.transform?.[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);
@@ -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(transform?.x, clipTime, 0), y: videoValueAt(transform?.y, clipTime, 0), scale: videoValueAt(transform?.scale, clipTime, 1), rotation: videoValueAt(transform?.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 = transform?.scale ?? 1;
772
- w *= scale;
773
- h *= scale;
774
- const cx = frame.width / 2 + (transform?.x ?? 0);
775
- const cy = frame.height / 2 + (transform?.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: clip.color ?? "#ffffff", fontSize: clip.fontSize ?? 64, fontFamily: clip.fontFamily ?? "sans-serif", fontWeight: 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: clip.transform?.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));
@@ -819,14 +1121,15 @@ function videoFrameAt(project, time) {
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 = (clip.volume ?? 1) * (track.volume ?? 1) * fade * mix;
1127
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (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 = (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) {
@@ -837,8 +1140,8 @@ function videoFrameAt(project, time) {
837
1140
  if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
838
1141
  const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
839
1142
  const fit = 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: clip.transform?.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(clip.transform?.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);
@@ -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,8 +1394,9 @@ 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 = index ?? input(clip, asset, lead, tail);
1088
- const gain = (clip.volume ?? 1) * (track.volume ?? 1);
1089
- const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), `volume=${gain}`];
1397
+ const trackGain = 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=${(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)}`);
@@ -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(tf?.[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 = 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 = clip.transform?.x ?? 0;
1113
- const y = clip.transform?.y ?? 0;
1424
+ const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(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 = tf?.[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}+${v ?? 0}`;
1430
+ };
1431
+ const fontSize = clip.fontSize ?? 64;
1114
1432
  const opts = [
1115
1433
  `text=${ffmpegFilterValue(clip.text)}`,
1116
1434
  options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? "sans-serif")}`,
1117
- `fontsize=${Math.round((clip.fontSize ?? 64) * (clip.transform?.scale ?? 1))}`,
1435
+ isVideoKeyframes(tf?.scale) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (tf?.scale ?? 1))}`,
1118
1436
  `fontcolor=${color(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((clip.fontSize ?? 64) / 2)}`] : [],
1123
1441
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1124
1442
  ];
1125
- if (clip.transform?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1443
+ if (tf?.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: asset.width ?? project.width, height: 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, 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(placed ?? {}).filter(([, v]) => !isVideoKeyframes(v)));
1451
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : 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(placed?.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 (clip.transform?.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 = placed?.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 = placed?.[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") {
@@ -1169,6 +1500,8 @@ function toVideoFfmpeg(project, options = {}) {
1169
1500
  if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);
1170
1501
  if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.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 = [
@@ -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) ? c?.transform?.[property] : c?.[property];
2076
+ return typeof value === "object" ? { keys: value.keys } : { keys: [], value: 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: options.id ?? "video",
2046
2426
  namespace: options.namespace,
@@ -2162,14 +2542,85 @@ 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) ? clip.transform?.[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 = !(asset?.kind === "audio" || at.track.kind === "audio");
2561
+ const audible = clip.type === "media" && (asset?.kind === "audio" || asset?.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
+ }
2165
2612
  export {
2166
2613
  VIDEO_CLIP_TYPES,
2167
2614
  VIDEO_DEFAULT_STILL_DURATION,
2168
2615
  VIDEO_DEFAULT_TITLE_DURATION,
2616
+ VIDEO_EASE_NAMES,
2169
2617
  VIDEO_EDIT_OPS,
2170
2618
  VIDEO_FITS,
2171
2619
  VIDEO_ISSUE_CODES,
2620
+ VIDEO_KEYFRAME_LIMITS,
2621
+ VIDEO_KEYFRAME_PROPERTIES,
2172
2622
  VIDEO_TIME_EPSILON,
2623
+ VIDEO_TRANSFORM_PROPERTIES,
2173
2624
  VIDEO_TRANSITION_TYPES,
2174
2625
  VIDEO_WIPE_DIRECTIONS,
2175
2626
  VideoEditorController,
@@ -2186,9 +2637,12 @@ export {
2186
2637
  findVideoGaps,
2187
2638
  formatVideoSeconds,
2188
2639
  formatVideoTimecode,
2640
+ isVideoKeyframes,
2189
2641
  nextFreeVideoSlot,
2642
+ parseVideoAnimatable,
2190
2643
  parseVideoAsset,
2191
2644
  parseVideoClip,
2645
+ parseVideoKeyframes,
2192
2646
  parseVideoMarker,
2193
2647
  parseVideoProject,
2194
2648
  parseVideoTrack,
@@ -2197,9 +2651,14 @@ export {
2197
2651
  placeVideoRect,
2198
2652
  planVideoClipDrag,
2199
2653
  probeVideoMedia,
2654
+ removeVideoKey,
2655
+ retimeVideoAnimatable,
2656
+ setVideoKey,
2200
2657
  snapToVideoFrame,
2201
2658
  snapVideoTime,
2659
+ sortVideoKeys,
2202
2660
  sortedVideoClips,
2661
+ splitVideoAnimatable,
2203
2662
  toVideoEdl,
2204
2663
  toVideoFfmpeg,
2205
2664
  videoAssetDropEdit,
@@ -2209,6 +2668,7 @@ export {
2209
2668
  videoClipBefore,
2210
2669
  videoClipEnd,
2211
2670
  videoClipHandles,
2671
+ videoClipKeyframes,
2212
2672
  videoClipLabel,
2213
2673
  videoClipOverhang,
2214
2674
  videoClipSourceRange,
@@ -2217,11 +2677,19 @@ export {
2217
2677
  videoClipsAt,
2218
2678
  videoContactSheetSvg,
2219
2679
  videoDropTracks,
2680
+ videoEaseBezier,
2681
+ videoEaseProgress,
2220
2682
  videoFrameAt,
2221
2683
  videoFrameDuration,
2222
2684
  videoFrameToSvg,
2223
2685
  videoFrames,
2224
2686
  videoIssueKey,
2687
+ videoKeyframeBreakpoints,
2688
+ videoKeyframeFfmpegExpr,
2689
+ videoKeyframeRows,
2690
+ videoKeyframeToggleEdit,
2691
+ videoKeyframeValueEdit,
2692
+ videoKeysOf,
2225
2693
  videoOverlap,
2226
2694
  videoProjectDuration,
2227
2695
  videoProjectSvg,
@@ -2229,6 +2697,8 @@ export {
2229
2697
  videoSnapPoints,
2230
2698
  videoTextStyle,
2231
2699
  videoTitleFrame,
2700
+ videoTransformAt,
2701
+ videoValueAt,
2232
2702
  videoWipeRect
2233
2703
  };
2234
2704
  //# sourceMappingURL=index.js.map