@pixerate/editor 0.78.0 → 0.80.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.
@@ -15,8 +15,197 @@ import "../chunk-AGR4P4UZ.js";
15
15
  var VIDEO_FITS = ["contain", "cover", "fill", "none"];
16
16
  var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
17
17
  var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
18
+ var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
18
19
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
19
20
 
21
+ // src/video-editor/keyframes.ts
22
+ var VIDEO_EASE_NAMES = ["linear", "hold", "ease-in", "ease-out", "ease-in-out"];
23
+ var VIDEO_KEYFRAME_PROPERTIES = ["opacity", "volume", "x", "y", "scale", "rotation"];
24
+ var VIDEO_TRANSFORM_PROPERTIES = ["x", "y", "scale", "rotation"];
25
+ var VIDEO_KEYFRAME_LIMITS = {
26
+ opacity: { default: 1, min: 0, max: 1 },
27
+ volume: { default: 1, min: 0, max: 4 },
28
+ x: { default: 0 },
29
+ y: { default: 0 },
30
+ scale: { default: 1, min: 0 },
31
+ rotation: { default: 0 }
32
+ };
33
+ var NAMED = {
34
+ "ease-in": [0.42, 0, 1, 1],
35
+ "ease-out": [0, 0, 0.58, 1],
36
+ "ease-in-out": [0.42, 0, 0.58, 1]
37
+ };
38
+ var isVideoKeyframes = (v) => typeof v === "object" && v !== null && Array.isArray(v.keys);
39
+ function videoEaseBezier(ease) {
40
+ if (ease === void 0 || ease === "linear" || ease === "hold") return null;
41
+ return Array.isArray(ease) ? ease : NAMED[ease];
42
+ }
43
+ var bez = (a, b, s) => 3 * a * (1 - s) * (1 - s) * s + 3 * b * (1 - s) * s * s + s * s * s;
44
+ var bezSlope = (a, b, s) => 3 * a * (1 - s) * (1 - s) + 6 * (b - a) * (1 - s) * s + 3 * (1 - b) * s * s;
45
+ function bezierParam(x1, x2, x) {
46
+ let s = x;
47
+ for (let i = 0; i < 8; i++) {
48
+ const err = bez(x1, x2, s) - x;
49
+ if (Math.abs(err) < 1e-7) return s;
50
+ const d = bezSlope(x1, x2, s);
51
+ if (Math.abs(d) < 1e-6) break;
52
+ s -= err / d;
53
+ if (s < 0 || s > 1) break;
54
+ }
55
+ let lo = 0;
56
+ let hi = 1;
57
+ s = x;
58
+ for (let i = 0; i < 60; i++) {
59
+ const v = bez(x1, x2, s);
60
+ if (Math.abs(v - x) < 1e-9) break;
61
+ if (v < x) lo = s;
62
+ else hi = s;
63
+ s = (lo + hi) / 2;
64
+ }
65
+ return s;
66
+ }
67
+ function videoEaseProgress(ease, p) {
68
+ if (p <= 0) return 0;
69
+ if (p >= 1) return ease === "hold" ? 0 : 1;
70
+ if (ease === "hold") return 0;
71
+ const b = videoEaseBezier(ease);
72
+ if (!b) return p;
73
+ return bez(b[1], b[3], bezierParam(b[0], b[2], p));
74
+ }
75
+ function keyBefore(keys, t) {
76
+ let lo = 0;
77
+ let hi = keys.length - 1;
78
+ if (t < keys[0].t) return -1;
79
+ while (lo < hi) {
80
+ const mid = lo + hi + 1 >> 1;
81
+ if (keys[mid].t <= t) lo = mid;
82
+ else hi = mid - 1;
83
+ }
84
+ return lo;
85
+ }
86
+ function videoValueAt(prop, clipTime, fallback = 0) {
87
+ if (prop === void 0) return fallback;
88
+ if (typeof prop === "number") return prop;
89
+ const { keys } = prop;
90
+ if (!keys.length) return fallback;
91
+ const i = keyBefore(keys, clipTime);
92
+ if (i < 0) return keys[0].v;
93
+ if (i >= keys.length - 1) return keys[keys.length - 1].v;
94
+ const a = keys[i];
95
+ const b = keys[i + 1];
96
+ if (a.ease === "hold") return a.v;
97
+ return a.v + (b.v - a.v) * videoEaseProgress(a.ease, (clipTime - a.t) / (b.t - a.t));
98
+ }
99
+ var videoKeysOf = (prop) => isVideoKeyframes(prop) ? prop.keys : [];
100
+ function retimeVideoAnimatable(prop, dt, k = 1) {
101
+ if (!isVideoKeyframes(prop)) return prop;
102
+ return { keys: prop.keys.map((key) => ({ ...key, t: key.t * k + dt })) };
103
+ }
104
+ function splitEase(ease, p) {
105
+ if (ease === "hold") return ["hold", "hold"];
106
+ const b = videoEaseBezier(ease);
107
+ if (!b) return [ease, ease];
108
+ const s = bezierParam(b[0], b[2], p);
109
+ const lerp = (u, v, k) => [u[0] + (v[0] - u[0]) * k, u[1] + (v[1] - u[1]) * k];
110
+ const P0 = [0, 0];
111
+ const P1 = [b[0], b[1]];
112
+ const P2 = [b[2], b[3]];
113
+ const P3 = [1, 1];
114
+ const A = lerp(P0, P1, s);
115
+ const B = lerp(P1, P2, s);
116
+ const C = lerp(P2, P3, s);
117
+ const AB = lerp(A, B, s);
118
+ const BC = lerp(B, C, s);
119
+ const M = lerp(AB, BC, s);
120
+ const r = (v) => Math.round(v * 1e6) / 1e6;
121
+ 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])];
122
+ const dx = 1 - M[0];
123
+ const dy = 1 - M[1];
124
+ 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)];
125
+ return [left, right];
126
+ }
127
+ function splitVideoAnimatable(prop, at) {
128
+ if (!isVideoKeyframes(prop)) return [prop, prop];
129
+ const keys = prop.keys;
130
+ const eps = 1e-6;
131
+ const v = videoValueAt(prop, at);
132
+ const i = keyBefore(keys, at);
133
+ const exact = i >= 0 && Math.abs(keys[i].t - at) < eps;
134
+ let leftEase;
135
+ let rightEase;
136
+ if (exact) rightEase = keys[i].ease;
137
+ else if (i >= 0 && i < keys.length - 1) {
138
+ const a = keys[i];
139
+ [leftEase, rightEase] = splitEase(a.ease, (at - a.t) / (keys[i + 1].t - a.t));
140
+ }
141
+ const before = keys.slice(0, exact ? i : i + 1).map((k) => ({ ...k }));
142
+ if (before.length && leftEase !== void 0) before[before.length - 1] = withEase(before[before.length - 1], leftEase);
143
+ const left = [...before, { t: at, v }];
144
+ const right = [withEase({ t: 0, v }, rightEase), ...keys.slice(i + 1).map((k) => ({ ...k, t: k.t - at }))];
145
+ return [{ keys: left }, { keys: right }];
146
+ }
147
+ var withEase = (key, ease) => {
148
+ const { ease: _old, ...rest } = key;
149
+ return ease === void 0 || ease === "linear" ? rest : { ...rest, ease };
150
+ };
151
+ function sortVideoKeys(keys) {
152
+ const sorted = [...keys].sort((a, b) => a.t - b.t);
153
+ 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.`);
154
+ return sorted;
155
+ }
156
+ function setVideoKey(prop, key, fallback) {
157
+ const keys = isVideoKeyframes(prop) ? prop.keys.filter((k) => Math.abs(k.t - key.t) >= 1e-6) : [];
158
+ if (!isVideoKeyframes(prop)) {
159
+ const v = prop ?? fallback;
160
+ if (key.t !== 0) keys.push({ t: 0, v });
161
+ }
162
+ return { keys: sortVideoKeys([...keys, key]) };
163
+ }
164
+ function removeVideoKey(prop, t) {
165
+ const keys = prop.keys.filter((k) => Math.abs(k.t - t) >= 1e-6);
166
+ return keys.length ? { keys } : prop.keys[0].v;
167
+ }
168
+ var fmt = (v) => {
169
+ const r = Math.round(v * 1e4) / 1e4;
170
+ return Object.is(r, -0) ? "0" : String(r);
171
+ };
172
+ var signed = (v) => fmt(v).startsWith("-") ? fmt(v) : `+${fmt(v)}`;
173
+ function videoKeyframeBreakpoints(prop, step) {
174
+ const { keys } = prop;
175
+ const points = [];
176
+ const holds = /* @__PURE__ */ new Set();
177
+ for (let i = 0; i < keys.length; i++) {
178
+ const a = keys[i];
179
+ points.push([a.t, a.v]);
180
+ const b = keys[i + 1];
181
+ if (!b) break;
182
+ if (a.ease === "hold") {
183
+ holds.add(points.length - 1);
184
+ continue;
185
+ }
186
+ if (!videoEaseBezier(a.ease)) continue;
187
+ const n2 = Math.max(1, Math.ceil((b.t - a.t) / step));
188
+ for (let k = 1; k < n2; k++) {
189
+ const t = a.t + (b.t - a.t) * k / n2;
190
+ points.push([t, videoValueAt(prop, t)]);
191
+ }
192
+ }
193
+ return { points, holds };
194
+ }
195
+ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
196
+ if (!isVideoKeyframes(prop)) return fmt(prop ?? fallback);
197
+ const { points, holds } = videoKeyframeBreakpoints(prop, step);
198
+ if (points.length === 1 || points.every(([, v]) => v === points[0][1])) return fmt(points[0][1]);
199
+ let expr = fmt(points[points.length - 1][1]);
200
+ for (let i = points.length - 2; i >= 0; i--) {
201
+ const [t0, v0] = points[i];
202
+ const [t1, v1] = points[i + 1];
203
+ const seg = holds.has(i) || v0 === v1 ? fmt(v0) : `${fmt(v0)}${signed((v1 - v0) / (t1 - t0))}*(${time}${signed(-t0)})`;
204
+ expr = `if(lt(${time},${fmt(t1)}),${seg},${expr})`;
205
+ }
206
+ return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
207
+ }
208
+
20
209
  // src/video-editor/parse.ts
21
210
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
22
211
  var fail = (path, what) => {
@@ -46,11 +235,44 @@ var oneOf = (v, options, path, optional = true) => {
46
235
  return v;
47
236
  };
48
237
  var compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
238
+ function parseVideoEase(v, path) {
239
+ if (v === void 0) return void 0;
240
+ if (Array.isArray(v)) {
241
+ 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)");
242
+ if (v[0] < 0 || v[0] > 1 || v[2] < 0 || v[2] > 1) fail(path, "must have x1 and x2 between 0 and 1");
243
+ return [v[0], v[1], v[2], v[3]];
244
+ }
245
+ return oneOf(v, VIDEO_EASE_NAMES, path);
246
+ }
247
+ function parseVideoKeyframes(v, path, { min, max } = {}) {
248
+ if (!isObject(v)) return fail(path, "must be a number or keyframes { keys: [{ t, v, ease }] }");
249
+ for (const key of Object.keys(v)) if (key !== "keys") fail(`${path}.${key}`, "is not a keyframes field (keys)");
250
+ if (!Array.isArray(v.keys) || !v.keys.length) fail(`${path}.keys`, "must be an array of at least one key { t, v, ease }");
251
+ const keys = v.keys.map((k, i) => {
252
+ const at = `${path}.keys[${i}]`;
253
+ if (!isObject(k)) return fail(at, "must be an object { t, v, ease }");
254
+ for (const key of Object.keys(k)) if (!["t", "v", "ease"].includes(key)) fail(`${at}.${key}`, "is not a key field (t, v, ease)");
255
+ return compact({ t: num(k.t, `${at}.t`), v: num(k.v, `${at}.v`, { min, max }), ease: parseVideoEase(k.ease, `${at}.ease`) });
256
+ });
257
+ 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`);
258
+ return { keys };
259
+ }
260
+ function parseVideoAnimatable(v, path, range = {}) {
261
+ if (v === void 0 && range.optional) return void 0;
262
+ const { min, max } = range;
263
+ if (isObject(v)) return parseVideoKeyframes(v, path, { min, max });
264
+ return num(v, path, { min, max });
265
+ }
49
266
  function parseVideoTransform(v, path) {
50
267
  if (v === void 0) return void 0;
51
268
  if (!isObject(v)) return fail(path, "must be an object { x, y, scale, rotation }");
52
269
  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 }) });
270
+ return compact({
271
+ x: parseVideoAnimatable(v.x, `${path}.x`, { optional: true }),
272
+ y: parseVideoAnimatable(v.y, `${path}.y`, { optional: true }),
273
+ scale: parseVideoAnimatable(v.scale, `${path}.scale`, { optional: true, min: 0 }),
274
+ rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
275
+ });
54
276
  }
55
277
  function parseVideoTransition(v, path) {
56
278
  if (v === void 0) return void 0;
@@ -87,7 +309,7 @@ function parseVideoClip(v, path = "clip") {
87
309
  name: str(v.name, `${path}.name`, true),
88
310
  start: num(v.start, `${path}.start`, { min: 0 }),
89
311
  duration: num(v.duration, `${path}.duration`, { positive: true }),
90
- opacity: num(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
312
+ opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
91
313
  transform: parseVideoTransform(v.transform, `${path}.transform`),
92
314
  fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
93
315
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
@@ -101,7 +323,7 @@ function parseVideoClip(v, path = "clip") {
101
323
  asset: str(v.asset, `${path}.asset`),
102
324
  in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
103
325
  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 }),
326
+ volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
105
327
  muted: bool(v.muted, `${path}.muted`)
106
328
  });
107
329
  if (type === "text")
@@ -127,6 +349,7 @@ function parseVideoTrack(v, path = "track") {
127
349
  muted: bool(v.muted, `${path}.muted`),
128
350
  hidden: bool(v.hidden, `${path}.hidden`),
129
351
  locked: bool(v.locked, `${path}.locked`),
352
+ syncLocked: bool(v.syncLocked, `${path}.syncLocked`),
130
353
  volume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
131
354
  clips: (v.clips ?? []).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))
132
355
  });
@@ -190,6 +413,7 @@ function createVideoProject(options = {}) {
190
413
  // src/video-editor/timeline.ts
191
414
  var VIDEO_TIME_EPSILON = 0.5;
192
415
  var videoClipEnd = (clip) => clip.start + clip.duration;
416
+ var videoTrackSyncLocked = (track) => track.syncLocked !== false;
193
417
  var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
194
418
  function videoClipSourceRange(clip) {
195
419
  if (clip.type !== "media") return null;
@@ -311,6 +535,14 @@ function videoClipsAt(project, time) {
311
535
  }
312
536
 
313
537
  // src/video-editor/check.ts
538
+ function videoClipKeyframes(clip) {
539
+ const out = [
540
+ { property: "opacity", keys: videoKeysOf(clip.opacity) },
541
+ { property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
542
+ ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(clip.transform?.[p]) }))
543
+ ];
544
+ return out.filter((o) => o.keys.length);
545
+ }
314
546
  var VIDEO_ISSUE_CODES = [
315
547
  "missing-asset",
316
548
  "wrong-track",
@@ -320,6 +552,7 @@ var VIDEO_ISSUE_CODES = [
320
552
  "transition-too-long",
321
553
  "transition-handles",
322
554
  "fades-too-long",
555
+ "keyframes-outside-clip",
323
556
  "unknown-duration",
324
557
  "unused-asset",
325
558
  "marker-past-end",
@@ -355,6 +588,16 @@ function checkVideoProject(project) {
355
588
  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
589
  if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
357
590
  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.` });
591
+ for (const { property, keys } of videoClipKeyframes(clip)) {
592
+ const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
593
+ if (outside.length)
594
+ issues.push({
595
+ code: "keyframes-outside-clip",
596
+ severity: "warning",
597
+ ...at,
598
+ 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).`
599
+ });
600
+ }
358
601
  if (clip.transition) {
359
602
  const d = clip.transition.duration;
360
603
  const before = videoClipBefore(track, clip);
@@ -392,6 +635,7 @@ function checkVideoProject(project) {
392
635
  }
393
636
 
394
637
  // src/video-editor/edit.ts
638
+ var TRACK_FIELD_KEYS = ["name", "muted", "hidden", "locked", "syncLocked", "volume"];
395
639
  var VIDEO_EDIT_OPS = [
396
640
  "set_settings",
397
641
  "add_asset",
@@ -408,6 +652,13 @@ var VIDEO_EDIT_OPS = [
408
652
  "delete_clips",
409
653
  "update_clip",
410
654
  "set_transition",
655
+ "slip_clip",
656
+ "roll_edit",
657
+ "slide_clip",
658
+ "lift_range",
659
+ "extract_range",
660
+ "split_at",
661
+ "set_keyframes",
411
662
  "close_gaps",
412
663
  "add_marker",
413
664
  "update_marker",
@@ -426,7 +677,32 @@ function freshId(prefix, taken) {
426
677
  for (let n2 = 1; ; n2++) if (!used.has(`${prefix}${n2}`)) return `${prefix}${n2}`;
427
678
  }
428
679
  var slug = (s) => s.replace(/^.*[\\/]/, "").replace(/\.[a-z0-9]+(\?.*)?$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
680
+ function mergeSpans(spans) {
681
+ const out = [];
682
+ for (const [from, to] of [...spans].sort((a, b) => a[0] - b[0])) {
683
+ const last = out[out.length - 1];
684
+ if (last && from <= last[1] + VIDEO_TIME_EPSILON) last[1] = Math.max(last[1], to);
685
+ else out.push([from, to]);
686
+ }
687
+ return out;
688
+ }
689
+ function clipAnimatables(clip) {
690
+ const c = clip;
691
+ const out = [
692
+ { get: () => c.opacity, set: (v) => c.opacity = v },
693
+ { get: () => c.volume, set: (v) => c.volume = v }
694
+ ];
695
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => c.transform?.[p], set: (v) => c.transform && (c.transform[p] = v) });
696
+ return out;
697
+ }
698
+ function retimeClipKeys(clip, fn) {
699
+ for (const a of clipAnimatables(clip)) {
700
+ const v = a.get();
701
+ if (isVideoKeyframes(v)) a.set(fn(v));
702
+ }
703
+ }
429
704
  var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
705
+ var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
430
706
  var Editor = class {
431
707
  constructor(p) {
432
708
  this.p = p;
@@ -458,7 +734,7 @@ var Editor = class {
458
734
  const hit = videoOverlap(track, start, start + duration, ignore);
459
735
  if (hit) {
460
736
  const free = nextFreeVideoSlot(track, start, duration, ignore);
461
- refuse(`${what} at ${formatVideoSeconds(start)}\u2013${formatVideoSeconds(start + duration)} would overlap clip "${hit.id}" (${formatVideoSeconds(hit.start)}\u2013${formatVideoSeconds(videoClipEnd(hit))}) on track "${track.id}"; the next free place is ${formatVideoSeconds(free)}, or pass ripple to push later clips along.`);
737
+ refuse(`${what} at ${formatVideoSeconds(start)}\u2013${formatVideoSeconds(start + duration)} would overlap clip "${hit.id}" (${formatVideoSeconds(hit.start)}\u2013${formatVideoSeconds(videoClipEnd(hit))}) on track "${track.id}"; the next free place is ${formatVideoSeconds(free)}, or use mode "insert" to push later clips along or "overwrite" to replace what is there.`);
462
738
  }
463
739
  }
464
740
  kindFits(track, clip) {
@@ -470,8 +746,160 @@ var Editor = class {
470
746
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
471
747
  if (!delta) return;
472
748
  for (const c of track.clips) if (!except.has(c.id) && c.start >= from - VIDEO_TIME_EPSILON) c.start = Math.max(0, c.start + delta);
749
+ track.clips.sort((a, b) => a.start - b.start);
750
+ }
751
+ get frame() {
752
+ return 1e3 / this.p.fps;
753
+ }
754
+ ids() {
755
+ return this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
756
+ }
757
+ /**
758
+ * The tracks a ripple on `sources` moves, top to bottom: the sources, and every unlocked
759
+ * sync-locked track when one of the sources is sync-locked.
760
+ */
761
+ rippleTracks(sources) {
762
+ const set = new Set(sources);
763
+ if ([...set].some(videoTrackSyncLocked)) {
764
+ for (const t of this.p.tracks) if (videoTrackSyncLocked(t) && !t.locked) set.add(t);
765
+ }
766
+ return this.p.tracks.filter((t) => set.has(t));
767
+ }
768
+ /** The tracks a range edit works on: the named ones (unlocked), or every unlocked track. */
769
+ rangeTracks(ids) {
770
+ if (ids) {
771
+ if (!ids.length) refuse('"tracks" is empty; name at least one track, or leave it out for every unlocked track.');
772
+ const tracks = [...new Set(ids)].map((id) => this.track(id));
773
+ tracks.forEach((t) => this.unlocked(t));
774
+ return this.p.tracks.filter((t) => tracks.includes(t));
775
+ }
776
+ const open = this.p.tracks.filter((t) => !t.locked);
777
+ if (!open.length) refuse("Every track is locked; unlock one (update_track with locked false) first.");
778
+ return open;
779
+ }
780
+ range(from, to) {
781
+ if (!(to - from > VIDEO_TIME_EPSILON)) refuse(`"to" (${formatVideoSeconds(to)}) must be after "from" (${formatVideoSeconds(from)}).`);
782
+ }
783
+ /** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
784
+ moveIn(clip, delta) {
785
+ if (clip.type !== "media" || videoAssetMap(this.p).get(clip.asset)?.kind === "image") return;
786
+ clip.in = Math.max(0, (clip.in ?? 0) + delta * videoClipSpeed(clip));
787
+ if (clip.in < 1e-9) delete clip.in;
788
+ }
789
+ /** Moves a clip's head `delta` later in time (start, in point and keys); the caller sets start and duration. */
790
+ trimHead(clip, delta) {
791
+ this.moveIn(clip, delta);
792
+ retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
793
+ }
794
+ /** Cuts `clip` at `at` (inside it): the left part keeps the id, the right part is returned. */
795
+ cut(track, clip, at, id) {
796
+ const end = videoClipEnd(clip);
797
+ const right = { ...structuredClone(clip), id: id ?? freshId(`${clip.id}-`, this.ids()), start: at, duration: end - at };
798
+ delete right.transition;
799
+ delete right.fadeIn;
800
+ const leftValues = clipAnimatables(clip);
801
+ clipAnimatables(right).forEach((r, i) => {
802
+ const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
803
+ if (isVideoKeyframes(l)) leftValues[i].set(l);
804
+ if (isVideoKeyframes(rr)) r.set(rr);
805
+ });
806
+ this.moveIn(right, at - clip.start);
807
+ clip.duration = at - clip.start;
808
+ delete clip.fadeOut;
809
+ track.clips.push(right);
810
+ track.clips.sort((a, b) => a.start - b.start);
811
+ return right;
812
+ }
813
+ /** Cuts every clip on `tracks` that runs across `at`; returns the new right-hand parts. */
814
+ cutAcross(tracks, at) {
815
+ const made = [];
816
+ for (const t of tracks) for (const c of [...t.clips]) if (c.start < at - VIDEO_TIME_EPSILON && videoClipEnd(c) > at + VIDEO_TIME_EPSILON) made.push(this.cut(t, c, at));
817
+ return made;
818
+ }
819
+ /**
820
+ * Opens `amount` of empty time at `at` on `tracks`: clips across `at` are cut there and everything
821
+ * from `at` on moves later. Returns the clips made by cutting.
822
+ */
823
+ open(tracks, at, amount, except = /* @__PURE__ */ new Set()) {
824
+ const made = this.cutAcross(tracks, at).map((c) => c.id);
825
+ for (const t of tracks) this.shift(t, at, amount, except);
826
+ return made;
827
+ }
828
+ /**
829
+ * Closes `[from, to)` on `tracks`, pulling everything after it earlier. Refuses, before moving
830
+ * anything, when a track still has a clip in that stretch (it would be pulled out of sync).
831
+ */
832
+ close(tracks, from, to) {
833
+ for (const t of tracks) {
834
+ const hit = videoOverlap(t, from, to);
835
+ if (hit)
836
+ refuse(
837
+ `Rippling out ${formatVideoSeconds(from)}\u2013${formatVideoSeconds(to)} would pull track "${t.id}" out of sync: clip "${hit.id}" (${formatVideoSeconds(hit.start)}\u2013${formatVideoSeconds(videoClipEnd(hit))}) is in that stretch. Extract the range across "${t.id}" too (extract_range with its id in "tracks"), turn its sync lock off (update_track with syncLocked false) or lock it, or lift instead of rippling.`
838
+ );
839
+ }
840
+ for (const t of tracks) this.shift(t, to, from - to);
841
+ }
842
+ /**
843
+ * Empties `[from, to)` on one track, leaving a gap: clips inside go, clips across an edge are
844
+ * trimmed back to it, and a clip across both is cut in two around the gap.
845
+ */
846
+ clear(track, from, to, report) {
847
+ for (const c of [...track.clips]) {
848
+ const start = c.start;
849
+ const end = videoClipEnd(c);
850
+ if (end <= from + VIDEO_TIME_EPSILON || start >= to - VIDEO_TIME_EPSILON) continue;
851
+ const coversHead = start >= from - VIDEO_TIME_EPSILON;
852
+ const coversTail = end <= to + VIDEO_TIME_EPSILON;
853
+ if (coversHead && coversTail) {
854
+ track.clips = track.clips.filter((x) => x !== c);
855
+ report.removed.push(c.id);
856
+ continue;
857
+ }
858
+ if (!coversHead && !coversTail) report.created.push(this.cut(track, c, to).id);
859
+ else if (coversHead) {
860
+ this.trimHead(c, to - start);
861
+ c.start = to;
862
+ c.duration = end - to;
863
+ delete c.transition;
864
+ report.trimmed.push(c.id);
865
+ continue;
866
+ }
867
+ c.duration = from - start;
868
+ if (!report.trimmed.includes(c.id)) report.trimmed.push(c.id);
869
+ }
870
+ track.clips.sort((a, b) => a.start - b.start);
871
+ }
872
+ /** Clamps a slip, roll or slide to `[lo, hi]`, refusing when it cannot move that way at all. */
873
+ clamp(delta, lo, hi, why) {
874
+ const applied = Math.min(Math.max(delta, Math.min(0, lo)), Math.max(0, hi));
875
+ if (Math.abs(delta) > VIDEO_TIME_EPSILON && Math.abs(applied) <= VIDEO_TIME_EPSILON) refuse(why());
876
+ return Math.abs(applied) <= VIDEO_TIME_EPSILON ? 0 : applied;
877
+ }
878
+ /** Places a clip on a track by `mode`, returning what it cut, trimmed, removed and rippled. */
879
+ place(track, clip, mode, outcome) {
880
+ if (mode === "insert") {
881
+ const tracks = this.rippleTracks([track]);
882
+ outcome.created = this.open(tracks, clip.start, clip.duration);
883
+ outcome.rippled = tracks.map((t) => t.id);
884
+ } else if (mode === "overwrite") {
885
+ const report = { removed: [], trimmed: [], created: [] };
886
+ this.clear(track, clip.start, videoClipEnd(clip), report);
887
+ Object.assign(outcome, report);
888
+ } else this.fits(track, clip.start, clip.duration, /* @__PURE__ */ new Set([clip.id]), `Clip "${clip.id}"`);
889
+ track.clips.push(clip);
890
+ track.clips.sort((a, b) => a.start - b.start);
891
+ }
892
+ placement(mode, ripple) {
893
+ if (mode !== void 0 && !VIDEO_PLACEMENT_MODES.includes(mode)) refuse(`"mode" must be ${VIDEO_PLACEMENT_MODES.map((m) => `"${m}"`).join(", ")}.`);
894
+ if (ripple && mode !== void 0 && mode !== "insert") refuse(`"ripple" means mode "insert", so it cannot go with mode "${mode}"; pass one or the other.`);
895
+ return mode ?? (ripple ? "insert" : "refuse");
473
896
  }
474
897
  run(e) {
898
+ for (const k of TIME_FIELDS[e.op] ?? []) {
899
+ const v = e[k];
900
+ if (typeof v !== "number" || !Number.isFinite(v)) refuse(`"${k}" must be a number of milliseconds.`);
901
+ if (k !== "delta" && v < 0) refuse(`"${k}" must be a time of at least 0.`);
902
+ }
475
903
  switch (e.op) {
476
904
  case "set_settings": {
477
905
  const { op: _op, ...settings } = e;
@@ -517,7 +945,7 @@ var Editor = class {
517
945
  }
518
946
  case "update_track": {
519
947
  const track = this.track(e.track);
520
- for (const k of Object.keys(e.set ?? {})) if (!["name", "muted", "hidden", "locked", "volume"].includes(k)) refuse(`"${k}" cannot be set on a track; use name, muted, hidden, locked or volume.`);
948
+ for (const k of Object.keys(e.set ?? {})) if (!TRACK_FIELD_KEYS.includes(k)) refuse(`"${k}" cannot be set on a track; use ${TRACK_FIELD_KEYS.join(", ")}.`);
521
949
  Object.assign(track, e.set);
522
950
  for (const k of e.remove ?? []) delete track[k];
523
951
  return this.outcomes.push({ op: e.op, track: track.id });
@@ -538,6 +966,7 @@ var Editor = class {
538
966
  case "add_clip": {
539
967
  const track = this.track(e.track);
540
968
  this.unlocked(track);
969
+ const mode = this.placement(e.mode, e.ripple);
541
970
  const draft = { ...e.clip, type: e.clip.type ?? "media" };
542
971
  this.kindFits(track, draft);
543
972
  const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
@@ -555,14 +984,9 @@ var Editor = class {
555
984
  const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
556
985
  draft.start ??= end;
557
986
  const clip = parseVideoClip(draft, "clip");
558
- if (e.ripple) {
559
- const inside = track.clips.find((c) => c.start < clip.start - VIDEO_TIME_EPSILON && videoClipEnd(c) > clip.start + VIDEO_TIME_EPSILON);
560
- if (inside) refuse(`${formatVideoSeconds(clip.start)} is inside clip "${inside.id}"; split it there first (split_clip), or insert at its start or end.`);
561
- this.shift(track, clip.start, clip.duration);
562
- } else this.fits(track, clip.start, clip.duration, /* @__PURE__ */ new Set(), `Clip "${clip.id}"`);
563
- track.clips.push(clip);
564
- track.clips.sort((a, b) => a.start - b.start);
565
- return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
987
+ const outcome = { op: e.op, clip: clip.id, track: track.id };
988
+ this.place(track, clip, mode, outcome);
989
+ return this.outcomes.push(outcome);
566
990
  }
567
991
  case "move_clip": {
568
992
  const from = this.clip(e.clip);
@@ -570,15 +994,14 @@ var Editor = class {
570
994
  const to = e.track !== void 0 ? this.track(e.track) : from.track;
571
995
  this.unlocked(to);
572
996
  if (to !== from.track) this.kindFits(to, from.clip);
997
+ const mode = this.placement(e.mode);
573
998
  const start = e.start ?? from.clip.start;
574
- this.fits(to, start, from.clip.duration, /* @__PURE__ */ new Set([from.clip.id]), `Clip "${from.clip.id}"`);
999
+ if (start < 0) refuse("A clip cannot start before 0.");
1000
+ from.track.clips.splice(from.index, 1);
575
1001
  from.clip.start = start;
576
- if (to !== from.track) {
577
- from.track.clips.splice(from.index, 1);
578
- to.clips.push(from.clip);
579
- }
580
- to.clips.sort((a, b) => a.start - b.start);
581
- return this.outcomes.push({ op: e.op, clip: from.clip.id, track: to.id });
1002
+ const outcome = { op: e.op, clip: from.clip.id, track: to.id };
1003
+ this.place(to, from.clip, mode, outcome);
1004
+ return this.outcomes.push(outcome);
582
1005
  }
583
1006
  case "trim_clip": {
584
1007
  const { track, clip } = this.clip(e.clip);
@@ -596,9 +1019,19 @@ var Editor = class {
596
1019
  clip.in = Math.max(0, sourceIn);
597
1020
  if (clip.in === 0) delete clip.in;
598
1021
  }
1022
+ retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
599
1023
  if (e.ripple) {
600
- clip.duration -= delta;
601
- this.shift(track, end, -delta, /* @__PURE__ */ new Set([clip.id]));
1024
+ const tracks = this.rippleTracks([track]);
1025
+ if (delta > 0) {
1026
+ clip.start += delta;
1027
+ clip.duration -= delta;
1028
+ this.close(tracks, clip.start - delta, clip.start);
1029
+ } else if (delta < 0) {
1030
+ const created = this.open(tracks, clip.start, -delta, /* @__PURE__ */ new Set([clip.id]));
1031
+ clip.duration -= delta;
1032
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...created.length ? { created } : {} });
1033
+ }
1034
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id) });
602
1035
  } else {
603
1036
  if (e.to < 0) refuse("A clip cannot start before 0.");
604
1037
  if (delta < 0) this.fits(track, e.to, -delta, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
@@ -614,8 +1047,19 @@ var Editor = class {
614
1047
  if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
615
1048
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where "${asset.id}" runs out.`);
616
1049
  }
617
- if (e.ripple) this.shift(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
618
- else if (e.to > end) this.fits(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
1050
+ if (e.ripple) {
1051
+ const tracks = this.rippleTracks([track]);
1052
+ let created = [];
1053
+ if (e.to < end) {
1054
+ clip.duration = duration;
1055
+ this.close(tracks, e.to, end);
1056
+ } else if (e.to > end) {
1057
+ created = this.open(tracks, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
1058
+ clip.duration = duration;
1059
+ }
1060
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, rippled: tracks.map((t) => t.id), ...created.length ? { created } : {} });
1061
+ }
1062
+ if (e.to > end) this.fits(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
619
1063
  clip.duration = duration;
620
1064
  }
621
1065
  return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
@@ -626,31 +1070,25 @@ var Editor = class {
626
1070
  const end = videoClipEnd(clip);
627
1071
  const minimum = 1e3 / this.p.fps;
628
1072
  if (e.at < clip.start + minimum - VIDEO_TIME_EPSILON || e.at > end - minimum + VIDEO_TIME_EPSILON) refuse(`${formatVideoSeconds(e.at)} is not inside "${clip.id}" (${formatVideoSeconds(clip.start)}\u2013${formatVideoSeconds(end)}), at least a frame from either end.`);
629
- const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
630
- if (e.id !== void 0 && ids.includes(e.id)) refuse(`There is already a clip "${e.id}".`);
631
- const id = e.id ?? freshId(`${clip.id}-`, ids);
632
- const left = e.at - clip.start;
633
- const right = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };
634
- delete right.transition;
635
- delete right.fadeIn;
636
- if (clip.type === "media") {
637
- right.in = (clip.in ?? 0) + left * videoClipSpeed(clip);
638
- }
639
- clip.duration = left;
640
- delete clip.fadeOut;
641
- track.clips.push(right);
642
- track.clips.sort((a, b) => a.start - b.start);
643
- return this.outcomes.push({ op: e.op, clips: [clip.id, id], clip: id, track: track.id });
1073
+ if (e.id !== void 0 && this.ids().includes(e.id)) refuse(`There is already a clip "${e.id}".`);
1074
+ const right = this.cut(track, clip, e.at, e.id);
1075
+ return this.outcomes.push({ op: e.op, clips: [clip.id, right.id], clip: right.id, track: track.id });
644
1076
  }
645
1077
  case "delete_clips": {
646
1078
  const found = [...new Set(e.clips)].map((id) => this.clip(id));
647
1079
  for (const f of found) this.unlocked(f.track);
648
- for (const track of new Set(found.map((f) => f.track))) {
649
- const gone = found.filter((f) => f.track === track).map((f) => f.clip);
650
- track.clips = track.clips.filter((c) => !gone.includes(c));
651
- if (e.ripple) for (const c of [...gone].sort((a, b) => b.start - a.start)) this.shift(track, videoClipEnd(c), -c.duration);
1080
+ for (const track of new Set(found.map((f) => f.track))) track.clips = track.clips.filter((c) => !found.some((f) => f.clip === c));
1081
+ if (!e.ripple) return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id) });
1082
+ const rippled = /* @__PURE__ */ new Set();
1083
+ const groups = [...new Set(found.map((f) => f.track))].filter((t) => !videoTrackSyncLocked(t)).map((t) => [t]);
1084
+ if (found.some((f) => videoTrackSyncLocked(f.track))) groups.push(found.filter((f) => videoTrackSyncLocked(f.track)).map((f) => f.track));
1085
+ for (const sources of groups) {
1086
+ const tracks = this.rippleTracks(sources);
1087
+ tracks.forEach((t) => rippled.add(t));
1088
+ const spans = mergeSpans(found.filter((f) => sources.includes(f.track)).map((f) => [f.clip.start, videoClipEnd(f.clip)]));
1089
+ for (const [from, to] of spans.reverse()) this.close(tracks, from, to);
652
1090
  }
653
- return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id) });
1091
+ return this.outcomes.push({ op: e.op, clips: found.map((f) => f.clip.id), rippled: this.p.tracks.filter((t) => rippled.has(t)).map((t) => t.id) });
654
1092
  }
655
1093
  case "update_clip": {
656
1094
  const at = this.clip(e.clip);
@@ -659,12 +1097,17 @@ var Editor = class {
659
1097
  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
1098
  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
1099
  if ("speed" in set && (typeof set.speed !== "number" || !(set.speed > 0))) refuse('"speed" must be a number greater than 0 (1 = normal speed).');
1100
+ let stretch = 1;
662
1101
  if (at.clip.type === "media" && !("duration" in set)) {
663
1102
  const was = videoClipSpeed(at.clip);
664
1103
  const now = "speed" in set ? set.speed : e.remove?.includes("speed") ? 1 : was;
665
- if (now !== was) set.duration = at.clip.duration * was / now;
1104
+ if (now !== was) {
1105
+ set.duration = at.clip.duration * was / now;
1106
+ stretch = was / now;
1107
+ }
666
1108
  }
667
- const next = { ...at.clip, ...set };
1109
+ const next = { ...structuredClone(at.clip), ...set };
1110
+ if (stretch !== 1) retimeClipKeys(next, (v) => retimeVideoAnimatable(v, 0, stretch));
668
1111
  for (const k of e.remove ?? []) delete next[k];
669
1112
  const clip = parseVideoClip(next, "set");
670
1113
  if (clip.type === "media" && clip.asset !== at.clip.asset) this.kindFits(at.track, clip);
@@ -680,6 +1123,133 @@ var Editor = class {
680
1123
  else clip.transition = parseVideoTransition(e.transition, "transition");
681
1124
  return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
682
1125
  }
1126
+ case "slip_clip": {
1127
+ const { track, clip } = this.clip(e.clip);
1128
+ this.unlocked(track);
1129
+ if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to slip; only video and audio clips slip.`);
1130
+ const asset = this.asset(clip.asset);
1131
+ if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no source to slip; only video and audio clips slip.`);
1132
+ const handles = videoClipHandles(clip, videoAssetMap(this.p));
1133
+ const { lead, tail } = videoClipOverhang(track, clip);
1134
+ const delta = this.clamp(
1135
+ e.delta,
1136
+ lead - handles.before,
1137
+ handles.after - tail,
1138
+ () => e.delta > 0 ? `"${clip.id}" already plays to the end of "${asset.id}"${tail ? ` (less the ${tail} ms the transition after it needs)` : ""}, so it cannot slip later; slip it earlier (a negative delta).` : `"${clip.id}" already starts at the beginning of "${asset.id}"${lead ? ` (plus the ${lead} ms its transition needs)` : ""}, so it cannot slip earlier; slip it later (a positive delta).`
1139
+ );
1140
+ this.moveIn(clip, delta);
1141
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id, delta });
1142
+ }
1143
+ case "roll_edit": {
1144
+ const left = this.clip(e.left);
1145
+ const right = this.clip(e.right);
1146
+ if (left.track !== right.track) refuse(`"${e.left}" is on track "${left.track.id}" and "${e.right}" on "${right.track.id}"; a roll moves the cut between two clips on one track.`);
1147
+ const track = left.track;
1148
+ this.unlocked(track);
1149
+ const L = left.clip;
1150
+ const R = right.clip;
1151
+ if (Math.abs(videoClipEnd(L) - R.start) > VIDEO_TIME_EPSILON) {
1152
+ if (Math.abs(videoClipEnd(R) - L.start) <= VIDEO_TIME_EPSILON) refuse(`"${R.id}" comes before "${L.id}"; pass left "${R.id}" and right "${L.id}".`);
1153
+ refuse(`"${L.id}" ends at ${formatVideoSeconds(videoClipEnd(L))} but "${R.id}" starts at ${formatVideoSeconds(R.start)}; a roll needs two clips that meet at a cut (use trim_clip to move one edge).`);
1154
+ }
1155
+ const assets = videoAssetMap(this.p);
1156
+ const lo = Math.max(-(L.duration - this.frame), -(videoClipHandles(R, assets).before - videoClipOverhang(track, R).lead));
1157
+ const hi = Math.min(R.duration - this.frame, videoClipHandles(L, assets).after - videoClipOverhang(track, L).tail);
1158
+ const delta = this.clamp(
1159
+ e.delta,
1160
+ lo,
1161
+ hi,
1162
+ () => e.delta > 0 ? `The cut cannot move later: ${R.duration - this.frame < 1 ? `"${R.id}" is already a frame long` : `"${L.id}" has no media after its out point`}. Roll it earlier (a negative delta).` : `The cut cannot move earlier: ${L.duration - this.frame < 1 ? `"${L.id}" is already a frame long` : `"${R.id}" has no media before its in point`}. Roll it later (a positive delta).`
1163
+ );
1164
+ L.duration += delta;
1165
+ R.start += delta;
1166
+ R.duration -= delta;
1167
+ this.trimHead(R, delta);
1168
+ return this.outcomes.push({ op: e.op, clip: R.id, clips: [L.id, R.id], track: track.id, delta });
1169
+ }
1170
+ case "slide_clip": {
1171
+ const { track, clip } = this.clip(e.clip);
1172
+ this.unlocked(track);
1173
+ const before = videoClipBefore(track, clip);
1174
+ const after = videoClipAfter(track, clip);
1175
+ if (!before && !after) refuse(`"${clip.id}" has no clip right before or after it on track "${track.id}", so there is nothing to slide against; use move_clip.`);
1176
+ const assets = videoAssetMap(this.p);
1177
+ const end = videoClipEnd(clip);
1178
+ const others = track.clips.filter((c) => c !== clip);
1179
+ const room = {
1180
+ earlier: before ? before.duration - this.frame : clip.start - others.filter((c) => videoClipEnd(c) <= clip.start + VIDEO_TIME_EPSILON).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0),
1181
+ later: after ? after.duration - this.frame : others.filter((c) => c.start >= end - VIDEO_TIME_EPSILON).reduce((m, c) => Math.min(m, c.start - end), Infinity)
1182
+ };
1183
+ const lo = -Math.min(room.earlier, after ? videoClipHandles(after, assets).before - videoClipOverhang(track, after).lead : Infinity);
1184
+ const hi = Math.min(room.later, before ? videoClipHandles(before, assets).after - videoClipOverhang(track, before).tail : Infinity);
1185
+ const delta = this.clamp(
1186
+ e.delta,
1187
+ lo,
1188
+ hi,
1189
+ () => e.delta > 0 ? `"${clip.id}" cannot slide later: ${room.later < 1 ? after ? `"${after.id}" is already a frame long` : "the next clip is right after it" : `"${before.id}" has no media after its out point to fill in behind it`}. Slide it earlier (a negative delta).` : `"${clip.id}" cannot slide earlier: ${room.earlier < 1 ? before ? `"${before.id}" is already a frame long` : "it is against the clip before it or the start" : `"${after.id}" has no media before its in point to fill in behind it`}. Slide it later (a positive delta).`
1190
+ );
1191
+ clip.start += delta;
1192
+ if (before) before.duration += delta;
1193
+ if (after) {
1194
+ after.start += delta;
1195
+ after.duration -= delta;
1196
+ this.trimHead(after, delta);
1197
+ }
1198
+ track.clips.sort((a, b) => a.start - b.start);
1199
+ return this.outcomes.push({ op: e.op, clip: clip.id, clips: [before?.id, clip.id, after?.id].filter((x) => !!x), track: track.id, delta });
1200
+ }
1201
+ case "lift_range":
1202
+ case "extract_range": {
1203
+ this.range(e.from, e.to);
1204
+ const tracks = this.rangeTracks(e.tracks);
1205
+ const report = { removed: [], trimmed: [], created: [] };
1206
+ for (const t of tracks) this.clear(t, e.from, e.to, report);
1207
+ const outcome = { op: e.op, ...report };
1208
+ if (e.op === "extract_range") {
1209
+ const rippled = this.rippleTracks(tracks);
1210
+ this.close(rippled, e.from, e.to);
1211
+ outcome.rippled = rippled.map((t) => t.id);
1212
+ }
1213
+ return this.outcomes.push(outcome);
1214
+ }
1215
+ case "split_at": {
1216
+ const tracks = this.rangeTracks(e.tracks);
1217
+ const lefts = tracks.flatMap((t) => t.clips.filter((c) => c.start < e.at - VIDEO_TIME_EPSILON && videoClipEnd(c) > e.at + VIDEO_TIME_EPSILON).map((c) => c.id));
1218
+ if (!lefts.length) refuse(`No clip runs across ${formatVideoSeconds(e.at)} on ${e.tracks ? `track${tracks.length > 1 ? "s" : ""} ${tracks.map((t) => `"${t.id}"`).join(", ")}` : "the unlocked tracks"}, so there is nothing to split there.`);
1219
+ const created = this.cutAcross(tracks, e.at).map((c) => c.id);
1220
+ return this.outcomes.push({ op: e.op, clips: lefts, created });
1221
+ }
1222
+ case "set_keyframes": {
1223
+ const { track, clip } = this.clip(e.clip);
1224
+ this.unlocked(track);
1225
+ if (!VIDEO_KEYFRAME_PROPERTIES.includes(e.property)) refuse(`"${e.property}" cannot be keyframed; use one of ${VIDEO_KEYFRAME_PROPERTIES.join(", ")}.`);
1226
+ 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.`);
1227
+ const limits = VIDEO_KEYFRAME_LIMITS[e.property];
1228
+ const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
1229
+ const c = clip;
1230
+ const current = transform ? c.transform?.[e.property] : c[e.property];
1231
+ let value;
1232
+ if (e.keys === null) {
1233
+ const v = videoValueAt(current, 0, limits.default);
1234
+ value = isVideoKeyframes(current) ? v === limits.default ? void 0 : v : current;
1235
+ } else {
1236
+ if (!Array.isArray(e.keys) || !e.keys.length) refuse('"keys" must list at least one key { t, v }; pass null to remove the keyframes.');
1237
+ let sorted = [];
1238
+ try {
1239
+ sorted = sortVideoKeys(e.keys);
1240
+ } catch (error) {
1241
+ refuse(error.message);
1242
+ }
1243
+ value = parseVideoAnimatable({ keys: sorted }, e.property, limits);
1244
+ }
1245
+ if (transform) {
1246
+ c.transform = { ...c.transform, [e.property]: value };
1247
+ if (value === void 0) delete c.transform[e.property];
1248
+ if (!Object.keys(c.transform).length) delete c.transform;
1249
+ } else if (value === void 0) delete c[e.property];
1250
+ else c[e.property] = value;
1251
+ return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
1252
+ }
683
1253
  case "close_gaps": {
684
1254
  const track = this.track(e.track);
685
1255
  this.unlocked(track);
@@ -757,7 +1327,10 @@ function applyVideoEdits(project, edits) {
757
1327
  }
758
1328
 
759
1329
  // src/video-editor/evaluate.ts
760
- function placeVideoRect(frame, size, fit = "contain", transform = {}) {
1330
+ function videoTransformAt(transform, clipTime = 0) {
1331
+ 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) };
1332
+ }
1333
+ function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
761
1334
  let w = size.width;
762
1335
  let h = size.height;
763
1336
  if (fit === "fill") {
@@ -768,18 +1341,18 @@ function placeVideoRect(frame, size, fit = "contain", transform = {}) {
768
1341
  w *= k;
769
1342
  h *= k;
770
1343
  }
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);
1344
+ const t = videoTransformAt(transform, clipTime);
1345
+ w *= t.scale;
1346
+ h *= t.scale;
1347
+ const cx = frame.width / 2 + t.x;
1348
+ const cy = frame.height / 2 + t.y;
776
1349
  return { x: cx - w / 2, y: cy - h / 2, width: w, height: h };
777
1350
  }
778
1351
  function videoTextStyle(clip) {
779
1352
  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
1353
  }
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) };
1354
+ function videoTitleFrame(project, clip, clipTime = 0) {
1355
+ 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
1356
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
784
1357
  }
785
1358
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
@@ -819,14 +1392,15 @@ function videoFrameAt(project, time) {
819
1392
  const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
820
1393
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
821
1394
  const sourceTime = sourceTimeAt(clip, time);
1395
+ const clipTime = time - clip.start;
822
1396
  const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
823
1397
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
824
- const gain = (clip.volume ?? 1) * (track.volume ?? 1) * fade * mix;
1398
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fade * mix;
825
1399
  audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
826
1400
  }
827
1401
  if (track.kind !== "video" || track.hidden) continue;
828
1402
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
829
- let opacity = (clip.opacity ?? 1) * fade;
1403
+ let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
830
1404
  let wipe;
831
1405
  let dip = null;
832
1406
  if (incoming) {
@@ -837,8 +1411,8 @@ function videoFrameAt(project, time) {
837
1411
  if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
838
1412
  const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
839
1413
  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 };
1414
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
1415
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(clip.transform?.rotation, clipTime, 0) };
842
1416
  if (wipe) layer.wipe = wipe;
843
1417
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
844
1418
  if (clip.type === "text") layer.text = videoTextStyle(clip);
@@ -1060,7 +1634,13 @@ function toVideoFfmpeg(project, options = {}) {
1060
1634
  const audio = [];
1061
1635
  let cur = "base";
1062
1636
  let usesDrawtext = false;
1637
+ const animated = /* @__PURE__ */ new Set();
1063
1638
  let k = 0;
1639
+ const step = 2e3 / fps;
1640
+ const expr = (prop, clipTime, fallback, what) => {
1641
+ if (isVideoKeyframes(prop)) animated.add(what);
1642
+ return videoKeyframeFfmpegExpr(prop, clipTime, fallback, step);
1643
+ };
1064
1644
  const input = (clip, asset, lead, tail) => {
1065
1645
  const speed = videoClipSpeed(clip);
1066
1646
  const src = options.resolve ? options.resolve(asset) : asset.src;
@@ -1085,8 +1665,9 @@ function toVideoFfmpeg(project, options = {}) {
1085
1665
  const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
1086
1666
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1087
1667
  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}`];
1668
+ const trackGain = track.volume ?? 1;
1669
+ 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}`;
1670
+ const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
1090
1671
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
1091
1672
  if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1092
1673
  if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
@@ -1102,33 +1683,43 @@ function toVideoFfmpeg(project, options = {}) {
1102
1683
  if (track.kind !== "video" || track.hidden) continue;
1103
1684
  const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
1104
1685
  const next = `v${k++}`;
1686
+ const onTimeline = `(t*1000-${Math.round(clip.start)})`;
1687
+ const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
1688
+ const tf = clip.transform;
1689
+ const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(tf?.[p]));
1105
1690
  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 })]);
1691
+ 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
1692
  index = inputs.length - 1;
1108
1693
  } else if (clip.type === "text") {
1109
1694
  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;
1695
+ const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(clip.opacity ?? 1);
1696
+ 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("*");
1697
+ const coord = (axis) => {
1698
+ const v = tf?.[axis];
1699
+ const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
1700
+ return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${v ?? 0}`;
1701
+ };
1702
+ const fontSize = clip.fontSize ?? 64;
1114
1703
  const opts = [
1115
1704
  `text=${ffmpegFilterValue(clip.text)}`,
1116
1705
  options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(clip.fontFamily ?? "sans-serif")}`,
1117
- `fontsize=${Math.round((clip.fontSize ?? 64) * (clip.transform?.scale ?? 1))}`,
1706
+ isVideoKeyframes(tf?.scale) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (tf?.scale ?? 1))}`,
1118
1707
  `fontcolor=${color(clip.color ?? "#ffffff")}`,
1119
- `x=(w-text_w)/2+${x}`,
1120
- `y=(h-text_h)/2+${y}`,
1708
+ coord("x"),
1709
+ coord("y"),
1121
1710
  `alpha='${alpha}'`,
1122
1711
  ...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((clip.fontSize ?? 64) / 2)}`] : [],
1123
1712
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1124
1713
  ];
1125
- if (clip.transform?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1714
+ if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1126
1715
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
1127
1716
  cur = next;
1128
1717
  continue;
1129
1718
  }
1130
1719
  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);
1720
+ const placed = clip.type === "text" && !moving ? void 0 : tf;
1721
+ const still = Object.fromEntries(Object.entries(placed ?? {}).filter(([, v]) => !isVideoKeyframes(v)));
1722
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : clip.fit ?? (clip.type === "media" ? "contain" : "fill"), still);
1132
1723
  const chain = [];
1133
1724
  let source;
1134
1725
  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 +1727,13 @@ function toVideoFfmpeg(project, options = {}) {
1136
1727
  source = `[${index}:v]`;
1137
1728
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
1138
1729
  }
1730
+ if (isVideoKeyframes(placed?.scale)) {
1731
+ const s = expr(placed.scale, onChain("t"), 1, "scale");
1732
+ 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`);
1733
+ }
1139
1734
  chain.push("format=yuva420p");
1140
- if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
1735
+ 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)'`);
1736
+ else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
1141
1737
  if (clip.fadeIn) chain.push(`fade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}:alpha=1`);
1142
1738
  if (clip.fadeOut) chain.push(`fade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}:alpha=1`);
1143
1739
  if (lead && clip.transition.type === "crossfade") chain.push(`fade=t=in:st=0:d=${sec(2 * lead)}:alpha=1`);
@@ -1148,12 +1744,18 @@ function toVideoFfmpeg(project, options = {}) {
1148
1744
  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
1745
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
1150
1746
  }
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)`);
1747
+ const rotation = placed?.rotation;
1748
+ if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
1749
+ else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
1152
1750
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
1153
1751
  const cx = rect.x + rect.width / 2;
1154
1752
  const cy = rect.y + rect.height / 2;
1753
+ const at = (axis, c) => {
1754
+ const v = placed?.[axis];
1755
+ 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'`;
1756
+ };
1155
1757
  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}]`);
1758
+ graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
1157
1759
  cur = next;
1158
1760
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1159
1761
  if (before && clip.transition.type === "dip") {
@@ -1169,6 +1771,8 @@ function toVideoFfmpeg(project, options = {}) {
1169
1771
  if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);
1170
1772
  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
1773
  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.');
1774
+ if (animated.size)
1775
+ 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
1776
  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
1777
  const filterComplex = graph.join(";");
1174
1778
  const args = [
@@ -1581,7 +2185,17 @@ var CLIP_ID = { type: "string", minLength: 1, description: 'A clip id from get_v
1581
2185
  var TRACK_ID = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
1582
2186
  var ASSET_ID = { type: "string", minLength: 1, description: "An asset id from get_video_project." };
1583
2187
  var COLOR = (what) => ({ type: "string", minLength: 1, description: `${what}: a CSS colour such as "#ffcc00".` });
1584
- var RIPPLE = { type: "boolean", description: "Push (or pull) the later clips on the same track along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap)." };
2188
+ var RIPPLE = {
2189
+ type: "boolean",
2190
+ description: "Push (or pull) later material along by the change, keeping the cuts after it in sync (default false: refuse if it would overlap). When the track is sync-locked (the default), every other unlocked sync-locked track moves too: clips across the edit point are cut when time opens, and closing time is refused if another sync-locked track has a clip in that stretch."
2191
+ };
2192
+ var MODE = {
2193
+ type: "string",
2194
+ enum: [...VIDEO_PLACEMENT_MODES],
2195
+ description: 'What to do with clips already where it lands: "refuse" (default) fails with the next free place; "overwrite" cuts, trims or removes what is underneath on that track; "insert" cuts everything across the start and pushes later material along by its length on this track and every sync-locked track.'
2196
+ };
2197
+ var DELTA = (what) => ({ type: "number", description: `${what}, in milliseconds of timeline time: positive is later, negative earlier. It is clamped to what the media and neighbours allow; the result says how far it went.` });
2198
+ var RANGE_TRACKS = { type: "array", items: TRACK_ID, minItems: 1, uniqueItems: true, description: "Tracks to work on (default: every unlocked track). Locked tracks are refused." };
1585
2199
  var TRANSFORM = object(
1586
2200
  {
1587
2201
  x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
@@ -1604,7 +2218,7 @@ var TRANSITION = object(
1604
2218
  );
1605
2219
  var CLIP_COMMON = {
1606
2220
  name: { type: "string", description: "A label for the clip." },
1607
- opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1)." },
2221
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
1608
2222
  transform: TRANSFORM,
1609
2223
  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
2224
  fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
@@ -1613,7 +2227,7 @@ var CLIP_COMMON = {
1613
2227
  var MEDIA_FIELDS = {
1614
2228
  in: MS("Where in the source it starts (default 0)"),
1615
2229
  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)." },
2230
+ volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
1617
2231
  muted: { type: "boolean", description: "Silence this clip (default false)." }
1618
2232
  };
1619
2233
  var TEXT_FIELDS = {
@@ -1649,6 +2263,7 @@ var TRACK_FIELDS = {
1649
2263
  muted: { type: "boolean", description: "Silence every clip on it." },
1650
2264
  hidden: { type: "boolean", description: "Leave its pictures out of the frame." },
1651
2265
  locked: { type: "boolean", description: "Refuse edits to its clips." },
2266
+ syncLocked: { type: "boolean", description: "Move with ripple edits on other sync-locked tracks so it stays in sync (default true). Turn off for a track that should stay put, e.g. a music bed." },
1652
2267
  volume: { type: "number", minimum: 0, maximum: 4, description: "Gain for every clip on it (1 = unchanged)." }
1653
2268
  };
1654
2269
  var ASSET_FIELDS = {
@@ -1660,6 +2275,32 @@ var ASSET_FIELDS = {
1660
2275
  hasAudio: { type: "boolean", description: "For video: whether it has sound (default true)." },
1661
2276
  poster: { type: "string", description: "For video: a still (URL) to show in rendered frames." }
1662
2277
  };
2278
+ var KEY_PROPERTY = {
2279
+ type: "string",
2280
+ enum: [...VIDEO_KEYFRAME_PROPERTIES],
2281
+ 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)."
2282
+ };
2283
+ var KEYS = {
2284
+ type: "array",
2285
+ minItems: 1,
2286
+ maxItems: 500,
2287
+ 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.",
2288
+ items: object(
2289
+ {
2290
+ 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." },
2291
+ v: { type: "number", description: "The value at that time, in the property's units." },
2292
+ ease: {
2293
+ 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.",
2294
+ anyOf: [
2295
+ { type: "string", enum: [...VIDEO_EASE_NAMES] },
2296
+ { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4 }
2297
+ ]
2298
+ }
2299
+ },
2300
+ ["t", "v"],
2301
+ "One keyframe."
2302
+ )
2303
+ };
1663
2304
  var issueSummary = (issues) => ({
1664
2305
  errors: issues.filter((i) => i.severity === "error").length,
1665
2306
  warnings: issues.filter((i) => i.severity === "warning").length,
@@ -1683,6 +2324,7 @@ function describeVideoProject(project) {
1683
2324
  ...t.muted ? { muted: true } : {},
1684
2325
  ...t.hidden ? { hidden: true } : {},
1685
2326
  ...t.locked ? { locked: true } : {},
2327
+ ...t.syncLocked === false ? { syncLocked: false } : {},
1686
2328
  ...t.volume !== void 0 ? { volume: t.volume } : {},
1687
2329
  clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} }))
1688
2330
  })),
@@ -1711,6 +2353,17 @@ function createVideoEditorToolProvider(controller, options = {}) {
1711
2353
  }
1712
2354
  return null;
1713
2355
  };
2356
+ const placed = (o) => ({
2357
+ ...o.created?.length ? { created: o.created } : {},
2358
+ ...o.trimmed?.length ? { trimmed: o.trimmed } : {},
2359
+ ...o.removed?.length ? { removed: o.removed } : {},
2360
+ ...o.rippled ? { rippled: o.rippled } : {}
2361
+ });
2362
+ const keysOut = (p, clip, property) => {
2363
+ const c = clipOut(p, clip);
2364
+ const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? c?.transform?.[property] : c?.[property];
2365
+ return typeof value === "object" ? { keys: value.keys } : { keys: [], value: value ?? null };
2366
+ };
1714
2367
  const getProject = {
1715
2368
  name: "get_video_project",
1716
2369
  title: "Read the video project",
@@ -1748,7 +2401,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
1748
2401
  const checkProject = {
1749
2402
  name: "check_video_project",
1750
2403
  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.",
2404
+ 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
2405
  inputSchema: object({}),
1753
2406
  annotations: { title: "Check the video project", ...READ },
1754
2407
  handler: () => {
@@ -1875,8 +2528,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
1875
2528
  const updateTrack = {
1876
2529
  name: "update_video_track",
1877
2530
  title: "Change a track",
1878
- description: "Renames, mutes, hides, locks or sets the volume of a track (one undo step). Returns the track's settings.",
1879
- inputSchema: object({ track: TRACK_ID, set: object(TRACK_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "muted", "hidden", "locked", "volume"] }, description: "Fields to clear." } }, ["track"]),
2531
+ description: "Renames, mutes, hides, locks, sync-locks or sets the volume of a track (one undo step). Returns the track's settings.",
2532
+ inputSchema: object({ track: TRACK_ID, set: object(TRACK_FIELDS, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["name", "muted", "hidden", "locked", "syncLocked", "volume"] }, description: "Fields to clear." } }, ["track"]),
1880
2533
  annotations: { title: "Change a track", ...WRITE_IDEMPOTENT },
1881
2534
  handler: ({ track, set, remove }) => apply({ op: "update_track", track, set, remove }, "Change track", (r) => {
1882
2535
  const { clips, ...t } = r.project.tracks.find((x) => x.id === track);
@@ -1902,20 +2555,20 @@ function createVideoEditorToolProvider(controller, options = {}) {
1902
2555
  const addClip = {
1903
2556
  name: "add_video_clip",
1904
2557
  title: "Add a clip",
1905
- description: 'Puts a clip on a track (one undo step): media (a stretch of an asset from its "in" point), a title or a colour. With no start it goes at the end of the track; with ripple it is inserted at "start", pushing later clips along; otherwise an overlap is refused with the next free place. Returns the clip with its end and source range.',
1906
- inputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, ripple: RIPPLE }, ["track", "clip"]),
2558
+ description: 'Puts a clip on a track (one undo step): media (a stretch of an asset from its "in" point), a title or a colour. With no start it goes at the end of the track. Where it lands on other clips, mode decides: refuse (default, with the next free place), overwrite (replace what is underneath) or insert (push later material along on every sync-locked track). Returns the clip with its end and source range, plus any clips it cut (created), trimmed or removed and the tracks it rippled.',
2559
+ inputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, mode: MODE, ripple: { type: "boolean", description: 'Same as mode "insert" (kept for older callers; default false).' } }, ["track", "clip"]),
1907
2560
  annotations: { title: "Add a clip", ...WRITE },
1908
- handler: ({ track, clip, ripple }) => apply({ op: "add_clip", track, clip, ripple }, "Add clip", (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip) }))
2561
+ handler: ({ track, clip, mode, ripple }) => apply({ op: "add_clip", track, clip, mode, ripple }, mode === "overwrite" ? "Overwrite clip" : mode === "insert" || ripple ? "Insert clip" : "Add clip", (r) => ({ clip: clipOut(r.project, r.outcomes[0].clip), ...placed(r.outcomes[0]) }))
1909
2562
  };
1910
2563
  const moveClip = {
1911
2564
  name: "move_video_clip",
1912
2565
  title: "Move a clip",
1913
- description: "Moves a clip to a new start and/or another track of the same kind (one undo step). Refused if it would overlap another clip, naming it and the next free place. Returns the clip.",
1914
- inputSchema: object({ clip: CLIP_ID, start: MS("New start on the timeline (default: unchanged)"), track: { ...TRACK_ID, description: "Track to move it to (default: its own)." } }, ["clip"]),
1915
- annotations: { title: "Move a clip", ...WRITE_IDEMPOTENT },
1916
- handler: ({ clip, start, track }) => {
2566
+ description: "Moves a clip to a new start and/or another track of the same kind (one undo step), leaving a gap where it was. Where it lands on other clips, mode decides: refuse (default, naming the clip and the next free place), overwrite or insert. Returns the clip, plus any clips it cut (created), trimmed or removed and the tracks it rippled.",
2567
+ inputSchema: object({ clip: CLIP_ID, start: MS("New start on the timeline (default: unchanged)"), track: { ...TRACK_ID, description: "Track to move it to (default: its own)." }, mode: MODE }, ["clip"]),
2568
+ annotations: { title: "Move a clip", ...WRITE },
2569
+ handler: ({ clip, start, track, mode }) => {
1917
2570
  if (start === void 0 && track === void 0) throw new Error('Give "start", "track" or both.');
1918
- return apply({ op: "move_clip", clip, start, track }, "Move clip", (r) => ({ clip: clipOut(r.project, clip) }));
2571
+ return apply({ op: "move_clip", clip, start, track, mode }, mode === "overwrite" ? "Overwrite clip" : mode === "insert" ? "Insert clip" : "Move clip", (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }));
1919
2572
  }
1920
2573
  };
1921
2574
  const trimClip = {
@@ -1924,7 +2577,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
1924
2577
  description: 'Moves one edge of a clip to a timeline time (one undo step): "start" also moves its source in point, "end" its out point. Refused past the ends of the source (the error says how far it can go) or below one frame. With ripple the clip keeps its start and the later clips on the track move by the change, so no gap or overlap opens. Returns the clip.',
1925
2578
  inputSchema: object({ clip: CLIP_ID, edge: { type: "string", enum: ["start", "end"], description: "Which edge to move." }, to: MS("Where that edge goes on the timeline"), ripple: RIPPLE }, ["clip", "edge", "to"]),
1926
2579
  annotations: { title: "Trim a clip", ...WRITE_IDEMPOTENT },
1927
- handler: ({ clip, edge, to, ripple }) => apply({ op: "trim_clip", clip, edge, to, ripple }, "Trim clip", (r) => ({ clip: clipOut(r.project, clip) }))
2580
+ handler: ({ clip, edge, to, ripple }) => apply({ op: "trim_clip", clip, edge, to, ripple }, ripple ? "Ripple trim" : "Trim clip", (r) => ({ clip: clipOut(r.project, clip), ...placed(r.outcomes[0]) }))
1928
2581
  };
1929
2582
  const splitClip = {
1930
2583
  name: "split_video_clip",
@@ -1937,10 +2590,13 @@ function createVideoEditorToolProvider(controller, options = {}) {
1937
2590
  const deleteClips = {
1938
2591
  name: "delete_video_clips",
1939
2592
  title: "Delete clips",
1940
- description: "Removes clips (one undo step). With ripple the later clips on each track move up to close the gaps. Returns the ids removed.",
1941
- inputSchema: object({ clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clip ids." }, ripple: { type: "boolean", description: "Close the gaps they leave (default false)." } }, ["clips"]),
2593
+ description: "Removes clips (one undo step). With ripple the later material moves up to close the gaps they leave: on every sync-locked track when they were on one (refused, naming the clip, if another sync-locked track has something in a gap), else on their own track. Returns the ids removed and the tracks rippled.",
2594
+ inputSchema: object(
2595
+ { clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clip ids." }, ripple: { type: "boolean", description: "Close the gaps they leave, on every sync-locked track (default false)." } },
2596
+ ["clips"]
2597
+ ),
1942
2598
  annotations: { title: "Delete clips", ...DESTRUCTIVE },
1943
- handler: ({ clips, ripple }) => apply({ op: "delete_clips", clips, ripple }, ripple ? "Ripple delete" : "Delete clips", () => ({ removed: clips }))
2599
+ handler: ({ clips, ripple }) => apply({ op: "delete_clips", clips, ripple }, ripple ? "Ripple delete" : "Delete clips", (r) => ({ removed: clips, ...r.outcomes[0].rippled ? { rippled: r.outcomes[0].rippled } : {} }))
1944
2600
  };
1945
2601
  const updateClip = {
1946
2602
  name: "update_video_clip",
@@ -1973,6 +2629,82 @@ function createVideoEditorToolProvider(controller, options = {}) {
1973
2629
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
1974
2630
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
1975
2631
  };
2632
+ const slipClip = {
2633
+ name: "slip_video_clip",
2634
+ title: "Slip a clip",
2635
+ description: "Shows a different stretch of a video or audio clip's source without moving it on the timeline: its in and out points both move by delta (one undo step). Clamped to the media past either end, keeping what its transitions need. Returns the clip (with its new source range) and the delta applied.",
2636
+ inputSchema: object({ clip: CLIP_ID, delta: DELTA("How far to move the source (positive shows later media)") }, ["clip", "delta"]),
2637
+ annotations: { title: "Slip a clip", ...WRITE },
2638
+ handler: ({ clip, delta }) => apply({ op: "slip_clip", clip, delta }, "Slip clip", (r) => ({ clip: clipOut(r.project, clip), delta: r.outcomes[0].delta }))
2639
+ };
2640
+ const rollEdit = {
2641
+ name: "roll_video_edit",
2642
+ title: "Roll a cut",
2643
+ description: "Moves the cut between two clips that meet on one track (one undo step): the left one's out point and the right one's in point move together, so nothing else moves and no gap opens. Clamped to each clip's media and at least a frame of each. Returns both clips, the new cut time and the delta applied.",
2644
+ inputSchema: object({ left: { ...CLIP_ID, description: "The clip before the cut (from get_video_project)." }, right: { ...CLIP_ID, description: 'The clip after the cut, starting where "left" ends.' }, delta: DELTA("How far to move the cut") }, ["left", "right", "delta"]),
2645
+ annotations: { title: "Roll a cut", ...WRITE },
2646
+ handler: ({ left, right, delta }) => apply({ op: "roll_edit", left, right, delta }, "Roll edit", (r) => {
2647
+ const after = clipOut(r.project, right);
2648
+ return { left: clipOut(r.project, left), right: after, cut: after.start, delta: r.outcomes[0].delta };
2649
+ })
2650
+ };
2651
+ const slideClip = {
2652
+ name: "slide_video_clip",
2653
+ title: "Slide a clip",
2654
+ description: "Moves a clip along its track keeping its content, trimming the clips either side so the stretch stays gapless (one undo step): the one before gets longer as the one after gets shorter, or the other way. Without a neighbour on one side it moves through the gap there. Clamped to the neighbours' media and at least a frame of each. Returns the clip, its neighbours and the delta applied.",
2655
+ inputSchema: object({ clip: CLIP_ID, delta: DELTA("How far to move it") }, ["clip", "delta"]),
2656
+ annotations: { title: "Slide a clip", ...WRITE },
2657
+ handler: ({ clip, delta }) => apply({ op: "slide_clip", clip, delta }, "Slide clip", (r) => {
2658
+ const ids = r.outcomes[0].clips;
2659
+ const i = ids.indexOf(clip);
2660
+ return { clip: clipOut(r.project, clip), ...i > 0 ? { before: clipOut(r.project, ids[0]) } : {}, ...i < ids.length - 1 ? { after: clipOut(r.project, ids[ids.length - 1]) } : {}, delta: r.outcomes[0].delta };
2661
+ })
2662
+ };
2663
+ const rangeArgs = object({ from: MS("Start of the range on the timeline"), to: MS('End of the range on the timeline (after "from")'), tracks: RANGE_TRACKS }, ["from", "to"]);
2664
+ const rangeOut = (o) => ({ removed: o.removed ?? [], trimmed: o.trimmed ?? [], created: o.created ?? [], ...o.rippled ? { rippled: o.rippled } : {} });
2665
+ const liftRange = {
2666
+ name: "lift_video_range",
2667
+ title: "Lift a range",
2668
+ description: "Empties a stretch of time on some or all tracks, leaving a gap (one undo step): clips inside are deleted, clips across an edge are trimmed to it, and a clip across both is cut in two. Returns the clips removed, trimmed and created (right-hand parts of cuts).",
2669
+ inputSchema: rangeArgs,
2670
+ annotations: { title: "Lift a range", ...DESTRUCTIVE },
2671
+ handler: ({ from, to, tracks }) => apply({ op: "lift_range", from, to, tracks }, "Lift", (r) => rangeOut(r.outcomes[0]))
2672
+ };
2673
+ const extractRange = {
2674
+ name: "extract_video_range",
2675
+ title: "Extract a range",
2676
+ description: 'Removes a stretch of time and closes it up (one undo step): like lift, then everything after it moves earlier by its length on those tracks and every sync-locked track. Refused, naming the clip, when a sync-locked track left out of "tracks" has something in the range. Returns the clips removed, trimmed and created and the tracks that moved.',
2677
+ inputSchema: rangeArgs,
2678
+ annotations: { title: "Extract a range", ...DESTRUCTIVE },
2679
+ handler: ({ from, to, tracks }) => apply({ op: "extract_range", from, to, tracks }, "Extract", (r) => rangeOut(r.outcomes[0]))
2680
+ };
2681
+ const splitAt = {
2682
+ name: "split_video_at",
2683
+ title: "Cut across tracks",
2684
+ description: "Cuts every clip that runs across a time on some or all tracks (a razor through the timeline), as one undo step. Each left part keeps its id, fade in and transition; each right part gets a new id and the fade out. Refused when no clip runs across that time. Returns the pairs { left, right }.",
2685
+ inputSchema: object({ at: MS("Where to cut, on the timeline"), tracks: RANGE_TRACKS }, ["at"]),
2686
+ annotations: { title: "Cut across tracks", ...WRITE },
2687
+ handler: ({ at, tracks }) => apply({ op: "split_at", at, tracks }, "Split", (r) => {
2688
+ const o = r.outcomes[0];
2689
+ return { cuts: o.clips.map((left, i) => ({ left: clipOut(r.project, left), right: clipOut(r.project, o.created[i]) })) };
2690
+ })
2691
+ };
2692
+ const setKeyframes = {
2693
+ name: "set_video_keyframes",
2694
+ title: "Animate a clip property",
2695
+ 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.",
2696
+ inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY, keys: KEYS }, ["clip", "property", "keys"]),
2697
+ annotations: { title: "Animate a clip property", ...WRITE_IDEMPOTENT },
2698
+ handler: ({ clip, property, keys }) => apply({ op: "set_keyframes", clip, property, keys }, `Keyframe ${property}`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
2699
+ };
2700
+ const removeKeyframes = {
2701
+ name: "remove_video_keyframes",
2702
+ title: "Stop animating a clip property",
2703
+ 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).",
2704
+ inputSchema: object({ clip: CLIP_ID, property: KEY_PROPERTY }, ["clip", "property"]),
2705
+ annotations: { title: "Stop animating a clip property", ...WRITE_IDEMPOTENT },
2706
+ handler: ({ clip, property }) => apply({ op: "set_keyframes", clip, property, keys: null }, `Remove ${property} keyframes`, (r) => ({ clip, property, ...keysOut(r.project, clip, property) }))
2707
+ };
1976
2708
  const closeGaps = {
1977
2709
  name: "close_video_gaps",
1978
2710
  title: "Close gaps",
@@ -1999,7 +2731,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
1999
2731
  };
2000
2732
  const EDIT_OP = {
2001
2733
  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 }.`,
2734
+ 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, mode, ripple }, move_clip { clip, start, track, mode }, 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) }, slip_clip { clip, delta }, roll_edit { left, right, delta }, slide_clip { clip, delta }, lift_range { from, to, tracks }, extract_range { from, to, tracks }, split_at { at, tracks }, 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
2735
  properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
2004
2736
  required: ["op"]
2005
2737
  };
@@ -2040,7 +2772,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2040
2772
  }
2041
2773
  };
2042
2774
  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];
2775
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
2044
2776
  return {
2045
2777
  id: options.id ?? "video",
2046
2778
  namespace: options.namespace,
@@ -2099,7 +2831,8 @@ function planVideoClipDrag(project, input) {
2099
2831
  const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
2100
2832
  const target = input.track ?? track.id;
2101
2833
  const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
2102
- return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {} } : null };
2834
+ const mode = input.placement && input.placement !== "refuse" ? { mode: input.placement } : {};
2835
+ return { start, duration: clip.duration, track: target, edit: moved ? { op: "move_clip", clip: clip.id, start, ...target !== track.id ? { track: target } : {}, ...mode } : null };
2103
2836
  }
2104
2837
  const minimum = 1e3 / project.fps;
2105
2838
  if (input.mode === "trim-start") {
@@ -2117,13 +2850,17 @@ function videoDropTracks(project, clipId) {
2117
2850
  if (!at) return [];
2118
2851
  return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
2119
2852
  }
2853
+ function videoDragLabel(edit) {
2854
+ if (edit.op === "move_clip") return edit.mode === "insert" ? "Insert clip" : edit.mode === "overwrite" ? "Overwrite clip" : "Move clip";
2855
+ return edit.op === "trim_clip" && edit.ripple ? "Ripple trim" : "Trim clip";
2856
+ }
2120
2857
  function videoAssetDropEdit(project, assetId, options = {}) {
2121
2858
  const asset = videoAssetMap(project).get(assetId);
2122
2859
  if (!asset) return null;
2123
2860
  const kind = asset.kind === "audio" ? "audio" : "video";
2124
2861
  const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
2125
2862
  if (!track) return null;
2126
- return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {} };
2863
+ return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
2127
2864
  }
2128
2865
  function videoAssetKindOf(typeOrName) {
2129
2866
  const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
@@ -2162,14 +2899,86 @@ function probeVideoMedia(src, kind, timeout = 15e3) {
2162
2899
  el.src = src;
2163
2900
  });
2164
2901
  }
2902
+ var KEY_LABELS = {
2903
+ opacity: { label: "Opacity", step: 0.05 },
2904
+ volume: { label: "Volume", step: 0.05 },
2905
+ x: { label: "X", step: 1 },
2906
+ y: { label: "Y", step: 1 },
2907
+ scale: { label: "Scale", step: 0.05 },
2908
+ rotation: { label: "Rotation", step: 1 }
2909
+ };
2910
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? clip.transform?.[property] : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
2911
+ var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
2912
+ function videoKeyframeRows(project, clipId, time) {
2913
+ const at = findVideoClip(project, clipId);
2914
+ if (!at) return [];
2915
+ const { clip } = at;
2916
+ const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
2917
+ const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
2918
+ const audible = clip.type === "media" && (asset?.kind === "audio" || asset?.kind === "video" && asset.hasAudio !== false);
2919
+ const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
2920
+ const clipTime = playheadInClip(clip, time);
2921
+ return properties.map((property) => {
2922
+ const prop = animatableOf(clip, property);
2923
+ const keys = isVideoKeyframes(prop) ? prop.keys : [];
2924
+ const limits = VIDEO_KEYFRAME_LIMITS[property];
2925
+ return {
2926
+ property,
2927
+ ...KEY_LABELS[property],
2928
+ value: Math.round(videoValueAt(prop, clipTime, limits.default) * 1e3) / 1e3,
2929
+ keyed: keys.length > 0,
2930
+ keys: keys.length,
2931
+ onKey: keys.some((k) => Math.abs(k.t - clipTime) < 0.5),
2932
+ clipTime,
2933
+ ...limits.min !== void 0 ? { min: limits.min } : {},
2934
+ ...limits.max !== void 0 ? { max: limits.max } : {}
2935
+ };
2936
+ });
2937
+ }
2938
+ function plainValueEdit(clip, property, value) {
2939
+ if (!VIDEO_TRANSFORM_PROPERTIES.includes(property))
2940
+ return value === void 0 ? { op: "update_clip", clip: clip.id, remove: [property] } : { op: "update_clip", clip: clip.id, set: { [property]: value } };
2941
+ const transform = { ...clip.transform, [property]: value };
2942
+ if (value === void 0) delete transform[property];
2943
+ return Object.keys(transform).length ? { op: "update_clip", clip: clip.id, set: { transform } } : { op: "update_clip", clip: clip.id, remove: ["transform"] };
2944
+ }
2945
+ function videoKeyframeValueEdit(project, clipId, property, time, value) {
2946
+ const at = findVideoClip(project, clipId);
2947
+ if (!at) return null;
2948
+ const prop = animatableOf(at.clip, property);
2949
+ if (!isVideoKeyframes(prop)) return plainValueEdit(at.clip, property, value);
2950
+ if (value === void 0) return null;
2951
+ return { op: "set_keyframes", clip: clipId, property, keys: setVideoKey(prop, { t: playheadInClip(at.clip, time), v: value }, VIDEO_KEYFRAME_LIMITS[property].default).keys };
2952
+ }
2953
+ function videoKeyframeToggleEdit(project, clipId, property, time) {
2954
+ const at = findVideoClip(project, clipId);
2955
+ if (!at) return null;
2956
+ const prop = animatableOf(at.clip, property);
2957
+ const t = playheadInClip(at.clip, time);
2958
+ const fallback = VIDEO_KEYFRAME_LIMITS[property].default;
2959
+ if (isVideoKeyframes(prop)) {
2960
+ const key = prop.keys.find((k) => Math.abs(k.t - t) < 0.5);
2961
+ if (key) {
2962
+ const rest = removeVideoKey(prop, key.t);
2963
+ return isVideoKeyframes(rest) ? { op: "set_keyframes", clip: clipId, property, keys: rest.keys } : plainValueEdit(at.clip, property, rest === fallback ? void 0 : rest);
2964
+ }
2965
+ }
2966
+ const v = videoValueAt(prop, t, fallback);
2967
+ return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
2968
+ }
2165
2969
  export {
2166
2970
  VIDEO_CLIP_TYPES,
2167
2971
  VIDEO_DEFAULT_STILL_DURATION,
2168
2972
  VIDEO_DEFAULT_TITLE_DURATION,
2973
+ VIDEO_EASE_NAMES,
2169
2974
  VIDEO_EDIT_OPS,
2170
2975
  VIDEO_FITS,
2171
2976
  VIDEO_ISSUE_CODES,
2977
+ VIDEO_KEYFRAME_LIMITS,
2978
+ VIDEO_KEYFRAME_PROPERTIES,
2979
+ VIDEO_PLACEMENT_MODES,
2172
2980
  VIDEO_TIME_EPSILON,
2981
+ VIDEO_TRANSFORM_PROPERTIES,
2173
2982
  VIDEO_TRANSITION_TYPES,
2174
2983
  VIDEO_WIPE_DIRECTIONS,
2175
2984
  VideoEditorController,
@@ -2186,9 +2995,12 @@ export {
2186
2995
  findVideoGaps,
2187
2996
  formatVideoSeconds,
2188
2997
  formatVideoTimecode,
2998
+ isVideoKeyframes,
2189
2999
  nextFreeVideoSlot,
3000
+ parseVideoAnimatable,
2190
3001
  parseVideoAsset,
2191
3002
  parseVideoClip,
3003
+ parseVideoKeyframes,
2192
3004
  parseVideoMarker,
2193
3005
  parseVideoProject,
2194
3006
  parseVideoTrack,
@@ -2197,9 +3009,14 @@ export {
2197
3009
  placeVideoRect,
2198
3010
  planVideoClipDrag,
2199
3011
  probeVideoMedia,
3012
+ removeVideoKey,
3013
+ retimeVideoAnimatable,
3014
+ setVideoKey,
2200
3015
  snapToVideoFrame,
2201
3016
  snapVideoTime,
3017
+ sortVideoKeys,
2202
3018
  sortedVideoClips,
3019
+ splitVideoAnimatable,
2203
3020
  toVideoEdl,
2204
3021
  toVideoFfmpeg,
2205
3022
  videoAssetDropEdit,
@@ -2209,6 +3026,7 @@ export {
2209
3026
  videoClipBefore,
2210
3027
  videoClipEnd,
2211
3028
  videoClipHandles,
3029
+ videoClipKeyframes,
2212
3030
  videoClipLabel,
2213
3031
  videoClipOverhang,
2214
3032
  videoClipSourceRange,
@@ -2216,12 +3034,21 @@ export {
2216
3034
  videoClipTint,
2217
3035
  videoClipsAt,
2218
3036
  videoContactSheetSvg,
3037
+ videoDragLabel,
2219
3038
  videoDropTracks,
3039
+ videoEaseBezier,
3040
+ videoEaseProgress,
2220
3041
  videoFrameAt,
2221
3042
  videoFrameDuration,
2222
3043
  videoFrameToSvg,
2223
3044
  videoFrames,
2224
3045
  videoIssueKey,
3046
+ videoKeyframeBreakpoints,
3047
+ videoKeyframeFfmpegExpr,
3048
+ videoKeyframeRows,
3049
+ videoKeyframeToggleEdit,
3050
+ videoKeyframeValueEdit,
3051
+ videoKeysOf,
2225
3052
  videoOverlap,
2226
3053
  videoProjectDuration,
2227
3054
  videoProjectSvg,
@@ -2229,6 +3056,9 @@ export {
2229
3056
  videoSnapPoints,
2230
3057
  videoTextStyle,
2231
3058
  videoTitleFrame,
3059
+ videoTrackSyncLocked,
3060
+ videoTransformAt,
3061
+ videoValueAt,
2232
3062
  videoWipeRect
2233
3063
  };
2234
3064
  //# sourceMappingURL=index.js.map