@pixerate/editor 0.79.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,6 +15,7 @@ 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
 
20
21
  // src/video-editor/keyframes.ts
@@ -348,6 +349,7 @@ function parseVideoTrack(v, path = "track") {
348
349
  muted: bool(v.muted, `${path}.muted`),
349
350
  hidden: bool(v.hidden, `${path}.hidden`),
350
351
  locked: bool(v.locked, `${path}.locked`),
352
+ syncLocked: bool(v.syncLocked, `${path}.syncLocked`),
351
353
  volume: num(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
352
354
  clips: (v.clips ?? []).map((c, i) => parseVideoClip(c, `${path}.clips[${i}]`))
353
355
  });
@@ -411,6 +413,7 @@ function createVideoProject(options = {}) {
411
413
  // src/video-editor/timeline.ts
412
414
  var VIDEO_TIME_EPSILON = 0.5;
413
415
  var videoClipEnd = (clip) => clip.start + clip.duration;
416
+ var videoTrackSyncLocked = (track) => track.syncLocked !== false;
414
417
  var videoClipSpeed = (clip) => clip.type === "media" ? clip.speed ?? 1 : 1;
415
418
  function videoClipSourceRange(clip) {
416
419
  if (clip.type !== "media") return null;
@@ -632,6 +635,7 @@ function checkVideoProject(project) {
632
635
  }
633
636
 
634
637
  // src/video-editor/edit.ts
638
+ var TRACK_FIELD_KEYS = ["name", "muted", "hidden", "locked", "syncLocked", "volume"];
635
639
  var VIDEO_EDIT_OPS = [
636
640
  "set_settings",
637
641
  "add_asset",
@@ -648,6 +652,12 @@ var VIDEO_EDIT_OPS = [
648
652
  "delete_clips",
649
653
  "update_clip",
650
654
  "set_transition",
655
+ "slip_clip",
656
+ "roll_edit",
657
+ "slide_clip",
658
+ "lift_range",
659
+ "extract_range",
660
+ "split_at",
651
661
  "set_keyframes",
652
662
  "close_gaps",
653
663
  "add_marker",
@@ -667,6 +677,15 @@ function freshId(prefix, taken) {
667
677
  for (let n2 = 1; ; n2++) if (!used.has(`${prefix}${n2}`)) return `${prefix}${n2}`;
668
678
  }
669
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
+ }
670
689
  function clipAnimatables(clip) {
671
690
  const c = clip;
672
691
  const out = [
@@ -683,6 +702,7 @@ function retimeClipKeys(clip, fn) {
683
702
  }
684
703
  }
685
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"] };
686
706
  var Editor = class {
687
707
  constructor(p) {
688
708
  this.p = p;
@@ -714,7 +734,7 @@ var Editor = class {
714
734
  const hit = videoOverlap(track, start, start + duration, ignore);
715
735
  if (hit) {
716
736
  const free = nextFreeVideoSlot(track, start, duration, ignore);
717
- 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.`);
718
738
  }
719
739
  }
720
740
  kindFits(track, clip) {
@@ -726,8 +746,160 @@ var Editor = class {
726
746
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
727
747
  if (!delta) return;
728
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");
729
896
  }
730
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
+ }
731
903
  switch (e.op) {
732
904
  case "set_settings": {
733
905
  const { op: _op, ...settings } = e;
@@ -773,7 +945,7 @@ var Editor = class {
773
945
  }
774
946
  case "update_track": {
775
947
  const track = this.track(e.track);
776
- 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(", ")}.`);
777
949
  Object.assign(track, e.set);
778
950
  for (const k of e.remove ?? []) delete track[k];
779
951
  return this.outcomes.push({ op: e.op, track: track.id });
@@ -794,6 +966,7 @@ var Editor = class {
794
966
  case "add_clip": {
795
967
  const track = this.track(e.track);
796
968
  this.unlocked(track);
969
+ const mode = this.placement(e.mode, e.ripple);
797
970
  const draft = { ...e.clip, type: e.clip.type ?? "media" };
798
971
  this.kindFits(track, draft);
799
972
  const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
@@ -811,14 +984,9 @@ var Editor = class {
811
984
  const end = sortedVideoClips(track).reduce((m, c) => Math.max(m, videoClipEnd(c)), 0);
812
985
  draft.start ??= end;
813
986
  const clip = parseVideoClip(draft, "clip");
814
- if (e.ripple) {
815
- const inside = track.clips.find((c) => c.start < clip.start - VIDEO_TIME_EPSILON && videoClipEnd(c) > clip.start + VIDEO_TIME_EPSILON);
816
- if (inside) refuse(`${formatVideoSeconds(clip.start)} is inside clip "${inside.id}"; split it there first (split_clip), or insert at its start or end.`);
817
- this.shift(track, clip.start, clip.duration);
818
- } else this.fits(track, clip.start, clip.duration, /* @__PURE__ */ new Set(), `Clip "${clip.id}"`);
819
- track.clips.push(clip);
820
- track.clips.sort((a, b) => a.start - b.start);
821
- 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);
822
990
  }
823
991
  case "move_clip": {
824
992
  const from = this.clip(e.clip);
@@ -826,15 +994,14 @@ var Editor = class {
826
994
  const to = e.track !== void 0 ? this.track(e.track) : from.track;
827
995
  this.unlocked(to);
828
996
  if (to !== from.track) this.kindFits(to, from.clip);
997
+ const mode = this.placement(e.mode);
829
998
  const start = e.start ?? from.clip.start;
830
- 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);
831
1001
  from.clip.start = start;
832
- if (to !== from.track) {
833
- from.track.clips.splice(from.index, 1);
834
- to.clips.push(from.clip);
835
- }
836
- to.clips.sort((a, b) => a.start - b.start);
837
- 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);
838
1005
  }
839
1006
  case "trim_clip": {
840
1007
  const { track, clip } = this.clip(e.clip);
@@ -854,8 +1021,17 @@ var Editor = class {
854
1021
  }
855
1022
  retimeClipKeys(clip, (v) => retimeVideoAnimatable(v, -delta));
856
1023
  if (e.ripple) {
857
- clip.duration -= delta;
858
- 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) });
859
1035
  } else {
860
1036
  if (e.to < 0) refuse("A clip cannot start before 0.");
861
1037
  if (delta < 0) this.fits(track, e.to, -delta, /* @__PURE__ */ new Set([clip.id]), `Extending "${clip.id}"`);
@@ -871,8 +1047,19 @@ var Editor = class {
871
1047
  if (asset?.duration !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
872
1048
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (clip.in ?? 0)) / speed)}, where "${asset.id}" runs out.`);
873
1049
  }
874
- if (e.ripple) this.shift(track, end, e.to - end, /* @__PURE__ */ new Set([clip.id]));
875
- 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}"`);
876
1063
  clip.duration = duration;
877
1064
  }
878
1065
  return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
@@ -883,37 +1070,25 @@ var Editor = class {
883
1070
  const end = videoClipEnd(clip);
884
1071
  const minimum = 1e3 / this.p.fps;
885
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.`);
886
- const ids = this.p.tracks.flatMap((t) => t.clips.map((c) => c.id));
887
- if (e.id !== void 0 && ids.includes(e.id)) refuse(`There is already a clip "${e.id}".`);
888
- const id = e.id ?? freshId(`${clip.id}-`, ids);
889
- const left = e.at - clip.start;
890
- const right = { ...structuredClone(clip), id, start: e.at, duration: end - e.at };
891
- delete right.transition;
892
- delete right.fadeIn;
893
- const leftValues = clipAnimatables(clip);
894
- clipAnimatables(right).forEach((r, i) => {
895
- const [l, rr] = splitVideoAnimatable(leftValues[i].get(), left);
896
- if (isVideoKeyframes(l)) leftValues[i].set(l);
897
- if (isVideoKeyframes(rr)) r.set(rr);
898
- });
899
- if (clip.type === "media") {
900
- right.in = (clip.in ?? 0) + left * videoClipSpeed(clip);
901
- }
902
- clip.duration = left;
903
- delete clip.fadeOut;
904
- track.clips.push(right);
905
- track.clips.sort((a, b) => a.start - b.start);
906
- 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 });
907
1076
  }
908
1077
  case "delete_clips": {
909
1078
  const found = [...new Set(e.clips)].map((id) => this.clip(id));
910
1079
  for (const f of found) this.unlocked(f.track);
911
- for (const track of new Set(found.map((f) => f.track))) {
912
- const gone = found.filter((f) => f.track === track).map((f) => f.clip);
913
- track.clips = track.clips.filter((c) => !gone.includes(c));
914
- 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);
915
1090
  }
916
- 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) });
917
1092
  }
918
1093
  case "update_clip": {
919
1094
  const at = this.clip(e.clip);
@@ -948,6 +1123,102 @@ var Editor = class {
948
1123
  else clip.transition = parseVideoTransition(e.transition, "transition");
949
1124
  return this.outcomes.push({ op: e.op, clip: clip.id, track: track.id });
950
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
+ }
951
1222
  case "set_keyframes": {
952
1223
  const { track, clip } = this.clip(e.clip);
953
1224
  this.unlocked(track);
@@ -1914,7 +2185,17 @@ var CLIP_ID = { type: "string", minLength: 1, description: 'A clip id from get_v
1914
2185
  var TRACK_ID = { type: "string", minLength: 1, description: 'A track id from get_video_project (e.g. "v1" or "a1").' };
1915
2186
  var ASSET_ID = { type: "string", minLength: 1, description: "An asset id from get_video_project." };
1916
2187
  var COLOR = (what) => ({ type: "string", minLength: 1, description: `${what}: a CSS colour such as "#ffcc00".` });
1917
- 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." };
1918
2199
  var TRANSFORM = object(
1919
2200
  {
1920
2201
  x: { type: "number", description: "Offset of the centre to the right of the frame's centre, in pixels (default 0)." },
@@ -1982,6 +2263,7 @@ var TRACK_FIELDS = {
1982
2263
  muted: { type: "boolean", description: "Silence every clip on it." },
1983
2264
  hidden: { type: "boolean", description: "Leave its pictures out of the frame." },
1984
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." },
1985
2267
  volume: { type: "number", minimum: 0, maximum: 4, description: "Gain for every clip on it (1 = unchanged)." }
1986
2268
  };
1987
2269
  var ASSET_FIELDS = {
@@ -2042,6 +2324,7 @@ function describeVideoProject(project) {
2042
2324
  ...t.muted ? { muted: true } : {},
2043
2325
  ...t.hidden ? { hidden: true } : {},
2044
2326
  ...t.locked ? { locked: true } : {},
2327
+ ...t.syncLocked === false ? { syncLocked: false } : {},
2045
2328
  ...t.volume !== void 0 ? { volume: t.volume } : {},
2046
2329
  clips: sortedVideoClips(t).map((c) => ({ ...c, end: videoClipEnd(c), ...c.type === "media" ? { source: videoClipSourceRange(c) } : {} }))
2047
2330
  })),
@@ -2070,6 +2353,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
2070
2353
  }
2071
2354
  return null;
2072
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
+ });
2073
2362
  const keysOut = (p, clip, property) => {
2074
2363
  const c = clipOut(p, clip);
2075
2364
  const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? c?.transform?.[property] : c?.[property];
@@ -2239,8 +2528,8 @@ function createVideoEditorToolProvider(controller, options = {}) {
2239
2528
  const updateTrack = {
2240
2529
  name: "update_video_track",
2241
2530
  title: "Change a track",
2242
- description: "Renames, mutes, hides, locks or sets the volume of a track (one undo step). Returns the track's settings.",
2243
- 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"]),
2244
2533
  annotations: { title: "Change a track", ...WRITE_IDEMPOTENT },
2245
2534
  handler: ({ track, set, remove }) => apply({ op: "update_track", track, set, remove }, "Change track", (r) => {
2246
2535
  const { clips, ...t } = r.project.tracks.find((x) => x.id === track);
@@ -2266,20 +2555,20 @@ function createVideoEditorToolProvider(controller, options = {}) {
2266
2555
  const addClip = {
2267
2556
  name: "add_video_clip",
2268
2557
  title: "Add a clip",
2269
- 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.',
2270
- 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"]),
2271
2560
  annotations: { title: "Add a clip", ...WRITE },
2272
- 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]) }))
2273
2562
  };
2274
2563
  const moveClip = {
2275
2564
  name: "move_video_clip",
2276
2565
  title: "Move a clip",
2277
- 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.",
2278
- 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"]),
2279
- annotations: { title: "Move a clip", ...WRITE_IDEMPOTENT },
2280
- 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 }) => {
2281
2570
  if (start === void 0 && track === void 0) throw new Error('Give "start", "track" or both.');
2282
- 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]) }));
2283
2572
  }
2284
2573
  };
2285
2574
  const trimClip = {
@@ -2288,7 +2577,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2288
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.',
2289
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"]),
2290
2579
  annotations: { title: "Trim a clip", ...WRITE_IDEMPOTENT },
2291
- 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]) }))
2292
2581
  };
2293
2582
  const splitClip = {
2294
2583
  name: "split_video_clip",
@@ -2301,10 +2590,13 @@ function createVideoEditorToolProvider(controller, options = {}) {
2301
2590
  const deleteClips = {
2302
2591
  name: "delete_video_clips",
2303
2592
  title: "Delete clips",
2304
- description: "Removes clips (one undo step). With ripple the later clips on each track move up to close the gaps. Returns the ids removed.",
2305
- 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
+ ),
2306
2598
  annotations: { title: "Delete clips", ...DESTRUCTIVE },
2307
- 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 } : {} }))
2308
2600
  };
2309
2601
  const updateClip = {
2310
2602
  name: "update_video_clip",
@@ -2337,6 +2629,66 @@ function createVideoEditorToolProvider(controller, options = {}) {
2337
2629
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
2338
2630
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
2339
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
+ };
2340
2692
  const setKeyframes = {
2341
2693
  name: "set_video_keyframes",
2342
2694
  title: "Animate a clip property",
@@ -2379,7 +2731,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2379
2731
  };
2380
2732
  const EDIT_OP = {
2381
2733
  type: "object",
2382
- description: `One edit: { "op": one of ${VIDEO_EDIT_OPS.filter((o) => o !== "replace_project").join(", ")}, \u2026its fields }. Fields match the single tools: add_clip { track, clip, ripple }, move_clip { clip, start, track }, trim_clip { clip, edge, to, ripple }, split_clip { clip, at, id }, delete_clips { clips, ripple }, update_clip { clip, set, remove }, set_transition { clip, transition }, set_keyframes { clip, property, keys (null removes) }, close_gaps { track, from }, add_asset { asset }, update_asset { asset, set, remove }, remove_asset { asset }, add_track { track: { kind, id, \u2026 }, at }, update_track { track, set, remove }, remove_track { track }, move_track { track, to }, add_marker { marker: { time, label } }, update_marker { marker, set }, remove_marker { marker }, set_settings { name, width, height, fps, background }.`,
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 }.`,
2383
2735
  properties: { op: { type: "string", enum: VIDEO_EDIT_OPS.filter((o) => o !== "replace_project"), description: "Which edit." } },
2384
2736
  required: ["op"]
2385
2737
  };
@@ -2420,7 +2772,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2420
2772
  }
2421
2773
  };
2422
2774
  const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];
2423
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
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];
2424
2776
  return {
2425
2777
  id: options.id ?? "video",
2426
2778
  namespace: options.namespace,
@@ -2479,7 +2831,8 @@ function planVideoClipDrag(project, input) {
2479
2831
  const start = Math.max(0, raw + (shifts.length ? shifts.reduce((a, b) => Math.abs(b) < Math.abs(a) ? b : a) : 0));
2480
2832
  const target = input.track ?? track.id;
2481
2833
  const moved = Math.abs(start - clip.start) > 1e-6 || target !== track.id;
2482
- 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 };
2483
2836
  }
2484
2837
  const minimum = 1e3 / project.fps;
2485
2838
  if (input.mode === "trim-start") {
@@ -2497,13 +2850,17 @@ function videoDropTracks(project, clipId) {
2497
2850
  if (!at) return [];
2498
2851
  return project.tracks.filter((t) => t.kind === at.track.kind && !t.locked).map((t) => t.id);
2499
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
+ }
2500
2857
  function videoAssetDropEdit(project, assetId, options = {}) {
2501
2858
  const asset = videoAssetMap(project).get(assetId);
2502
2859
  if (!asset) return null;
2503
2860
  const kind = asset.kind === "audio" ? "audio" : "video";
2504
2861
  const track = options.track ?? project.tracks.find((t) => t.kind === kind && !t.locked)?.id;
2505
2862
  if (!track) return null;
2506
- 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 } : {} };
2507
2864
  }
2508
2865
  function videoAssetKindOf(typeOrName) {
2509
2866
  const s = typeOrName.toLowerCase().replace(/[?#].*$/, "");
@@ -2619,6 +2976,7 @@ export {
2619
2976
  VIDEO_ISSUE_CODES,
2620
2977
  VIDEO_KEYFRAME_LIMITS,
2621
2978
  VIDEO_KEYFRAME_PROPERTIES,
2979
+ VIDEO_PLACEMENT_MODES,
2622
2980
  VIDEO_TIME_EPSILON,
2623
2981
  VIDEO_TRANSFORM_PROPERTIES,
2624
2982
  VIDEO_TRANSITION_TYPES,
@@ -2676,6 +3034,7 @@ export {
2676
3034
  videoClipTint,
2677
3035
  videoClipsAt,
2678
3036
  videoContactSheetSvg,
3037
+ videoDragLabel,
2679
3038
  videoDropTracks,
2680
3039
  videoEaseBezier,
2681
3040
  videoEaseProgress,
@@ -2697,6 +3056,7 @@ export {
2697
3056
  videoSnapPoints,
2698
3057
  videoTextStyle,
2699
3058
  videoTitleFrame,
3059
+ videoTrackSyncLocked,
2700
3060
  videoTransformAt,
2701
3061
  videoValueAt,
2702
3062
  videoWipeRect