@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
|
|
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 (!
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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)
|
|
875
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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 = {
|
|
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
|
|
2270
|
-
inputSchema: object({ track: TRACK_ID, clip: NEW_CLIP, ripple:
|
|
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)
|
|
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", ...
|
|
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
|
|
2305
|
-
inputSchema: object(
|
|
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
|
-
|
|
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
|