filmcraft/crates/engine/src/sequence_tools_tests.rs

674 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Sequence / Markers menu commands (`sequence_tools`).
use super::*;
use filmcraft_project::{AudioChannels, Label, MarkerKind, TrackKind};
use serde_json::json;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// An empty 23.976 sequence and a 20 s Bars and Tone item (video + audio).
fn fresh() -> (Session, u64) {
let mut s = Session::default();
s.execute("file.newSequence", json!({"name": "Test"})).unwrap();
let item = s.execute("file.newBarsAndTone", json!({"seconds": 20.0})).unwrap()["item"].as_u64().unwrap();
(s, item)
}
fn f(s: &Session, frames: i64) -> Tick {
s.sequence_rate().tick_of(frames)
}
fn place(s: &mut Session, item: u64, track: &str, at: i64, source_in: i64, frames: i64) -> Vec<u64> {
let (sin, dur) = (f(s, source_in), f(s, frames));
let at = f(s, at);
let audio = track.replace('V', "A");
let r =
s.execute("timeline.place", json!({"item": item, "track": track, "audioTrack": audio, "time": at.0, "sourceIn": sin.0, "duration": dur.0})).unwrap();
r["clips"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect()
}
fn ids() -> Vec<&'static str> {
command_specs().iter().map(|c| c.id).collect()
}
#[test]
fn commands_are_registered_in_premiere_menu_order() {
let s = Session::default();
let all = ids();
let after = |a: &str, b: &str| all.iter().position(|x| *x == a).unwrap() + 1 == all.iter().position(|x| *x == b).unwrap();
assert!(after("sequence.matchFrame", "sequence.reverseMatchFrame"));
assert!(after("sequence.closeGap", "sequence.goToNextGap"));
assert!(after("sequence.addTracks", "sequence.deleteTracks"));
assert!(after("markers.markSelection", "markers.markSplitVideoIn"));
assert!(after("markers.goToOut", "markers.goToSplitVideoIn"));
assert!(after("markers.add", "markers.addRange"));
let gap = find_command("sequence.goToPrevGap").unwrap();
assert_eq!(gap.menu, &["Sequence", "Go to Gap"]);
assert_eq!(gap.label, "Previous in Sequence");
assert_eq!(find_command("markers.clearAll").unwrap().label, "Clear Markers");
assert_eq!(s.shortcuts.primary("sequence.reverseMatchFrame").as_deref(), Some("Shift+R"));
assert_eq!(s.shortcuts.primary("sequence.goToNextGap").as_deref(), Some("Shift+;"));
assert_eq!(s.shortcuts.primary("sequence.makeSubsequence").as_deref(), Some("Shift+U"));
assert_eq!(s.shortcuts.primary("markers.addRangeInOut").as_deref(), Some("Ctrl+M"));
// every id is unique
let mut sorted = all.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), all.len());
}
#[test]
fn go_to_gap_in_sequence_and_in_track() {
let (mut s, bars) = fresh();
let matte = s.execute("file.newColorMatte", json!({"seconds": 20.0})).unwrap()["item"].as_u64().unwrap();
place(&mut s, bars, "V1", 0, 0, 48); // V1/A1 0–48
place(&mut s, bars, "V1", 96, 0, 48); // V1/A1 96–144
s.execute("timeline.place", json!({"item": matte, "track": "V2", "frame": 48, "duration": f(&s, 24).0})).unwrap(); // V2 48–72
// sequence: empty everywhere only in 72–96; V1: 48–96
s.execute("playhead.set", json!({"frame": 0})).unwrap();
let r = s.execute("sequence.goToNextGap", json!({})).unwrap();
assert_eq!(r["time"], json!(f(&s, 72).0));
s.execute("playhead.set", json!({"frame": 0})).unwrap();
s.execute("sequence.goToNextGapInTrack", json!({"track": "V1"})).unwrap();
assert_eq!(s.playhead(), f(&s, 48));
// V2 has a leading gap from 0 and nothing after its clip
s.execute("playhead.set", json!({"frame": 100})).unwrap();
s.execute("sequence.goToPrevGapInTrack", json!({"track": "V2"})).unwrap();
assert_eq!(s.playhead(), Tick::ZERO);
s.execute("playhead.set", json!({"frame": 120})).unwrap();
s.execute("sequence.goToPrevGap", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 72));
// no further gap: the playhead stays
let r = s.execute("sequence.goToNextGap", json!({})).unwrap();
assert!(r["time"].is_null());
assert_eq!(s.playhead(), f(&s, 72));
// in track defaults to the targeted tracks
s.execute("playhead.set", json!({"frame": 0})).unwrap();
s.execute("sequence.goToNextGapInTrack", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 48));
}
#[test]
fn reverse_match_frame_finds_the_source_frame() {
let (mut s, bars) = fresh();
place(&mut s, bars, "V1", 0, 0, 48); // media 0–48
place(&mut s, bars, "V1", 96, 72, 48); // media 72–120 at 96
assert!(s.execute("sequence.reverseMatchFrame", json!({})).is_err(), "needs a source clip");
s.execute("source.open", json!({"item": bars})).unwrap();
s.execute("source.setPlayhead", json!({"frame": 84})).unwrap();
let r = s.execute("sequence.reverseMatchFrame", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 108), "{r}");
s.execute("source.setPlayhead", json!({"frame": 10})).unwrap();
s.execute("sequence.reverseMatchFrame", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 10));
s.execute("source.setPlayhead", json!({"frame": 60})).unwrap();
assert!(s.execute("sequence.reverseMatchFrame", json!({})).is_err(), "frame 60 is not in the sequence");
// match frame (F) and reverse match frame (Shift+R) are inverses
s.execute("playhead.set", json!({"frame": 130})).unwrap();
s.execute("sequence.matchFrame", json!({})).unwrap();
s.execute("playhead.set", json!({"frame": 0})).unwrap();
s.execute("sequence.reverseMatchFrame", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 130));
}
#[test]
fn through_edits_show_and_join() {
let mut s = demo();
let before = s.active_sequence().unwrap().clone();
assert!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().is_empty());
s.execute("playhead.set", json!({"frame": 30})).unwrap();
s.execute("sequence.addEditAllTracks", json!({})).unwrap();
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
let n = te.as_array().unwrap().len();
assert!(n >= 2, "V1 and A1 (and the music) get through edits: {te}");
assert!(te.as_array().unwrap().iter().all(|e| e["time"] == json!(f(&s, 30).0)));
assert_eq!(s.execute("sequence.showThroughEdits", json!({})).unwrap()["showThroughEdits"], json!(true));
// join only the V1 one (and its linked audio)
let v1 = te.as_array().unwrap().iter().find(|e| e["track"] == json!(before.video_tracks[0].id.0)).unwrap()["right"].as_u64().unwrap();
let r = s.execute("sequence.joinThroughEdits", json!({"clips": [v1]})).unwrap();
assert_eq!(r["joined"], json!(2), "V1 + linked A1");
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
s.execute("sequence.joinThroughEdits", json!({"all": true})).unwrap();
let q = s.active_sequence().unwrap();
for (a, b) in q.all_tracks().zip(before.all_tracks()) {
assert_eq!(a.items, b.items, "{}", a.name);
}
assert!(s.execute("sequence.joinThroughEdits", json!({"all": true})).is_err(), "nothing left to join");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
}
/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its
/// linked partner, even when one of its pieces is also part of another through edit.
#[test]
fn join_one_through_edit_by_its_cut() {
let mut s = demo();
let v1 = s.active_sequence().unwrap().video_tracks[0].id.0;
for frame in [30, 40] {
s.execute("playhead.set", json!({"frame": frame})).unwrap();
s.execute("sequence.addEditAllTracks", json!({})).unwrap();
}
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect();
assert_eq!(on_v1.len(), 2);
// the second cut: its left piece is also the right piece of the first one
let (second, first) = (on_v1[1], on_v1[0]);
assert_eq!(second["left"], first["right"]);
let n = te.as_array().unwrap().len();
let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap();
assert_eq!(r["joined"], json!(2), "V1 + linked A1");
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
assert_eq!(te.as_array().unwrap().len(), n - 2);
assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays");
}
#[test]
fn join_by_cut_rejects_hostile_parameters() {
let mut s = demo();
let items = s.active_sequence().unwrap().video_tracks[0].items.clone();
let before = s.active_sequence().unwrap().clone();
for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] {
assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}");
}
assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed");
}
#[test]
fn make_subsequence_from_in_out_and_from_selection() {
let mut s = demo();
let seq = s.state.active_sequence.unwrap();
let orig = s.active_sequence().unwrap().clone();
assert!(s.execute("sequence.makeSubsequence", json!({})).is_err(), "needs In/Out or a selection");
s.execute("markers.markIn", json!({"frame": 30})).unwrap();
s.execute("markers.markOut", json!({"frame": 77})).unwrap();
let r = s.execute("sequence.makeSubsequence", json!({})).unwrap();
let sub = ItemId(r["sequence"].as_u64().unwrap());
assert_eq!(r["name"], json!("Main Edit_Sub_01"));
assert_eq!(s.state.active_sequence, Some(seq), "the original stays open");
assert_eq!(s.state.project_selection, vec![sub]);
let q = s.project.sequence(sub).unwrap();
q.check().unwrap();
assert_eq!(q.duration(), f(&s, 48));
assert_eq!(q.video_tracks.len(), orig.video_tracks.len());
// same frames at the same relative times
for (nt, ot) in q.all_tracks().zip(orig.all_tracks()) {
for it in &nt.items {
let t = it.start + Tick(1);
let o = ot.item_at(t + f(&s, 30)).expect("source clip");
assert_eq!(it.item, o.item);
assert_eq!(it.source_time_at(t), o.source_time_at(t + f(&s, 30)));
}
}
// ids are fresh, links stay pairwise
let all: Vec<_> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect();
assert!(all.iter().all(|c| orig.find_item(*c).is_none()));
for (a, b) in s.active_sequence().unwrap().all_tracks().zip(orig.all_tracks()) {
assert_eq!(a, b, "source sequence untouched");
}
// from a selection (no In/Out): the clips, starting at 0
s.execute("markers.clearInOut", json!({})).unwrap();
let c = orig.video_tracks[0].items[2].clone();
s.execute("timeline.select", json!({"clips": [c.id.0]})).unwrap();
let r = s.execute("sequence.makeSubsequence", json!({"name": "Shot 3"})).unwrap();
let q = s.project.sequence(ItemId(r["sequence"].as_u64().unwrap())).unwrap();
assert_eq!(q.video_tracks[0].items.len(), 1);
assert_eq!(q.video_tracks[0].items[0].start, Tick::ZERO);
assert_eq!(q.video_tracks[0].items[0].duration, c.duration);
assert_eq!(q.audio_tracks[0].items.len(), 1, "linked audio comes along");
assert_eq!(s.project.item(ItemId(r["sequence"].as_u64().unwrap())).unwrap().name, "Shot 3");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.items.values().all(|i| i.name != "Shot 3"));
}
/// Sequence ▸ Add Tracks…: where the new tracks go, what they are, and what the amounts may be.
/// Premiere Pro 26.5.2: Placement is "Before First Track" or after one of the tracks (after the
/// last by default); a track added after Video 1 is the new Video 2, and what was on Video 2 is
/// then on Video 3; audio tracks are Standard, 5.1, Adaptive or Mono, submix tracks Stereo, 5.1,
/// Adaptive or Mono.
#[test]
fn add_tracks_places_and_types_them() {
let mut s = demo();
let names = |s: &Session, kind: TrackKind| s.active_sequence().unwrap().tracks(kind).iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let before = s.active_sequence().unwrap().clone();
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3"]);
let on_v2 = before.video_tracks[1].items[0].id;
// without a place: after the last track (and one video track when nothing is said, as before)
let r = s.execute("sequence.addTracks", json!({})).unwrap();
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 0, 0));
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4"]);
assert_eq!(s.active_sequence().unwrap().video_tracks[3].id.0, r["video"][0].as_u64().unwrap());
s.execute("edit.undo", json!({})).unwrap();
// after Video 1: two new empty tracks there, the tracks above move up and are numbered again
s.execute("sequence.addTracks", json!({"video": 2, "videoAfter": "V1"})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4", "Video 5"]);
assert!(q.video_tracks[1].items.is_empty() && q.video_tracks[2].items.is_empty());
assert_eq!(q.video_tracks[3].id, before.video_tracks[1].id);
assert_eq!(q.video_tracks[3].items[0].id, on_v2, "what was on Video 2 is on Video 4");
assert_eq!(q.audio_tracks.len(), before.audio_tracks.len(), "no audio track was asked for");
// one undo step
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
// before the first track; a name the user gave is kept, default names follow their place
s.edit_sequence("Rename Track", |q, _, _| {
q.audio_tracks[1].name = "Music".into();
Ok(())
})
.unwrap();
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": "first", "audioType": "mono"})).unwrap();
assert_eq!(names(&s, TrackKind::Audio), ["Audio 1", "Audio 2", "Music", "Audio 4"]);
let q = s.active_sequence().unwrap();
assert_eq!(q.audio_tracks[0].channels, AudioChannels::Mono);
assert_eq!(q.audio_tracks[1].id, before.audio_tracks[0].id);
// a number counts the tracks before the new ones; "Standard" is a stereo track
for (ty, want) in [("standard", AudioChannels::Stereo), ("5.1", AudioChannels::Surround51), ("adaptive", AudioChannels::Adaptive)] {
let r = s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": 2, "audioType": ty})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.audio_tracks[2].id.0, q.audio_tracks[2].channels), (r["audio"][0].as_u64().unwrap(), want), "{ty}");
}
// submix tracks: stereo unless said, placed among the submix tracks
assert!(before.submix_tracks.is_empty());
s.execute("sequence.addTracks", json!({"video": 0, "submix": 2})).unwrap();
s.execute("sequence.addTracks", json!({"video": 0, "submix": 1, "submixAfter": "S1", "submixType": "5.1"})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(
q.submix_tracks.iter().map(|t| (t.name.as_str(), t.channels)).collect::<Vec<_>>(),
[("Submix 1", AudioChannels::Stereo), ("Submix 2", AudioChannels::Surround51), ("Submix 3", AudioChannels::Stereo)]
);
// all three kinds at once are one undo step
let before = s.active_sequence().unwrap().clone();
let r = s.execute("sequence.addTracks", json!({"video": 1, "audio": 1, "submix": 1})).unwrap();
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 1, 1));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
}
/// Amounts and places come from a dialog, a script or the control channel: a wrong one is an
/// error that changes nothing, and no amount can make the sequence grow without bound.
#[test]
fn add_tracks_refuses_what_it_cannot_do() {
let mut s = demo();
let before = s.active_sequence().unwrap().clone();
for bad in [
json!({"video": 0}),
json!({"video": 100}),
json!({"video": u64::MAX}),
json!({"video": 0, "audio": 1_000_000}),
json!({"video": "many"}),
json!({"video": 1, "videoAfter": "V9"}),
json!({"video": 1, "videoAfter": "A1"}),
json!({"video": 1, "videoAfter": 4}),
json!({"video": 1, "videoAfter": "top"}),
json!({"video": 0, "audio": 1, "audioAfter": "V1"}),
json!({"video": 0, "audio": 1, "audioType": "quad"}),
json!({"video": 0, "submix": 1, "submixAfter": "S1"}),
json!({"video": 0, "submix": 1, "submixType": "standard-ish"}),
] {
let r = s.execute("sequence.addTracks", bad.clone());
assert!(r.is_err(), "{bad} was accepted: {r:?}");
assert_eq!(*s.active_sequence().unwrap(), before, "{bad} changed the sequence");
}
// 99 at a time, and a sequence stops at 999 tracks of a kind
for _ in 0..10 {
s.execute("sequence.addTracks", json!({"video": 99})).unwrap();
}
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3 + 990);
assert!(s.execute("sequence.addTracks", json!({"video": 7})).is_err());
s.execute("sequence.addTracks", json!({"video": 6})).unwrap();
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 999);
}
#[test]
fn delete_tracks_empty_and_specific() {
let mut s = demo();
let q = s.active_sequence().unwrap().clone();
let empty_v = q.video_tracks.iter().filter(|t| t.items.is_empty()).count();
let empty_a = q.audio_tracks.iter().filter(|t| t.items.is_empty()).count();
assert!(empty_v > 0 && empty_a > 0, "demo has empty tracks");
let r = s.execute("sequence.deleteTracks", json!({"video": "empty", "audio": "empty"})).unwrap();
assert_eq!(r["deleted"], json!(empty_v + empty_a));
let q2 = s.active_sequence().unwrap();
assert!(q2.all_tracks().all(|t| !t.items.is_empty()));
assert!(s.execute("sequence.deleteTracks", json!({"video": "empty"})).is_err(), "nothing left to delete");
// a specific track; its clips go with it
s.execute("sequence.deleteTracks", json!({"video": "V2"})).unwrap();
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), q.video_tracks.len() - empty_v - 1);
assert!(s.execute("sequence.deleteTracks", json!({"video": "A1"})).is_err(), "kind must match");
// the last track of a kind stays
let r = s.execute("sequence.deleteTracks", json!({"video": "V1"}));
assert!(r.is_err(), "{r:?}");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_sequence().unwrap(), &q);
}
#[test]
fn delete_tracks_removes_caption_tracks() {
let mut s = demo();
let before = s.active_sequence().unwrap().clone();
let n0 = before.caption_tracks.len();
for _ in 0..3 {
s.execute("captions.newTrack", json!({"format": "Subtitle"})).unwrap();
}
// new tracks are inserted on top: C1 gets a caption, C2 and C3 stay empty
let cap = s.execute("captions.add", json!({"track": "C1", "text": "Hi", "seconds": 1.0})).unwrap()["caption"].as_u64().unwrap();
s.execute("captions.select", json!({"captions": [cap]})).unwrap();
let empty = s.active_sequence().unwrap().caption_tracks.iter().filter(|t| t.captions.is_empty()).count();
assert!(empty >= 2);
let r = s.execute("sequence.deleteTracks", json!({"captions": "empty"})).unwrap();
assert_eq!(r["deleted"], json!(empty));
let q = s.active_sequence().unwrap();
assert!(q.caption_tracks.iter().all(|t| !t.captions.is_empty()));
assert_eq!(q.video_tracks.len(), before.video_tracks.len(), "video tracks untouched");
assert!(s.execute("sequence.deleteTracks", json!({"captions": "empty"})).is_err(), "nothing left to delete");
// unknown names, ids and value types are errors, not panics
for bad in [json!("C99"), json!("C0"), json!("Cx"), json!(999_999), json!(true), json!(["C1"])] {
assert!(s.execute("sequence.deleteTracks", json!({"captions": bad})).is_err(), "{bad}");
}
// a specific track (with its captions); the last caption track may go too
let left = s.active_sequence().unwrap().caption_tracks.len();
for _ in 0..left {
s.execute("sequence.deleteTracks", json!({"captions": "C1"})).unwrap();
}
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
assert!(s.state.caption_selection.is_empty(), "selection of deleted captions cleared");
for _ in 0..left + 1 {
s.execute("edit.undo", json!({})).unwrap();
}
assert_eq!(s.active_sequence().unwrap().caption_tracks.len(), n0 + 3);
}
#[test]
fn split_points_on_the_sequence() {
let (mut s, bars) = fresh();
place(&mut s, bars, "V1", 0, 0, 240);
s.execute("markers.markIn", json!({"frame": 10})).unwrap();
s.execute("markers.markOut", json!({"frame": 100})).unwrap();
assert!(!s.is_enabled("markers.goToSplitVideoIn"), "no split yet");
s.execute("markers.markSplitAudioIn", json!({"frame": 20})).unwrap();
s.execute("markers.markSplitVideoOut", json!({"frame": 90})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.split.audio_in, Some(f(&s, 20)));
assert_eq!(q.split.video_out, Some(f(&s, 90)));
// go to: split points, or the ordinary In/Out for the other channel
s.execute("markers.goToSplitAudioIn", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 20));
s.execute("markers.goToSplitVideoIn", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 10));
s.execute("markers.goToSplitVideoOut", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 90));
s.execute("markers.goToSplitAudioOut", json!({})).unwrap();
assert_eq!(s.playhead(), f(&s, 100));
// a split Out before a split In clears the In of that channel
s.execute("markers.markSplitAudioOut", json!({"frame": 15})).unwrap();
assert_eq!(s.active_sequence().unwrap().split.audio_in, None);
// Mark In replaces the In splits; Clear In and Out clears everything
s.execute("markers.markSplitAudioIn", json!({"frame": 5})).unwrap();
s.execute("markers.markIn", json!({"frame": 12})).unwrap();
let sp = s.active_sequence().unwrap().split;
assert_eq!((sp.video_in, sp.audio_in), (None, None));
assert!(sp.video_out.is_some());
s.execute("markers.clearInOut", json!({})).unwrap();
assert!(s.active_sequence().unwrap().split.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_sequence().unwrap().split.is_empty(), "split points are undoable");
}
#[test]
fn source_split_points_make_l_and_j_cuts() {
let (mut s, bars) = fresh();
s.execute("source.open", json!({"item": bars})).unwrap();
let r = s.sequence_rate();
s.execute("project.setMarks", json!({"item": bars, "in": r.tick_of(48).0, "out": r.tick_of(95).0})).unwrap();
// J-cut: audio starts 12 frames before the picture
s.execute("markers.markSplitAudioIn", json!({"target": "source", "time": r.tick_of(36).0})).unwrap();
assert_eq!(s.project.item(ItemId(bars)).unwrap().split.audio_in, Some(r.tick_of(36)));
s.execute("markers.goToSplitAudioIn", json!({"target": "source"})).unwrap();
assert_eq!(s.state.source_playhead, r.tick_of(36));
s.execute("playhead.set", json!({"frame": 0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let v = &q.video_tracks[0].items[0];
let a = &q.audio_tracks[0].items[0];
assert_eq!((v.start, v.duration, v.source_in), (r.tick_of(12), r.tick_of(48), r.tick_of(48)));
assert_eq!((a.start, a.duration, a.source_in), (Tick::ZERO, r.tick_of(60), r.tick_of(36)));
assert_eq!(v.link, a.link, "still linked");
assert_eq!(v.source_time_at(r.tick_of(30)), a.source_time_at(r.tick_of(30)), "in sync");
assert_eq!(s.playhead(), r.tick_of(60));
// an ordinary In clears the source split In
s.execute("markers.markIn", json!({"target": "source", "time": r.tick_of(40).0})).unwrap();
assert!(s.project.item(ItemId(bars)).unwrap().split.audio_in.is_none());
}
/// #920: Source Mark In/Out and Mark Split honour `frame` / `seconds` / `timecode`, not only `time`.
#[test]
fn source_mark_in_out_and_split_honour_frame_seconds_and_timecode() {
let (mut s, bars) = fresh();
s.execute("source.open", json!({"item": bars})).unwrap();
s.execute("source.setPlayhead", json!({"frame": 0})).unwrap();
let r = s.project.item(ItemId(bars)).unwrap().frame_rate();
assert_eq!(s.state.source_playhead, Tick::ZERO);
s.execute("markers.markIn", json!({"target": "source", "frame": 24})).unwrap();
let m = s.project.item(ItemId(bars)).unwrap().as_media().unwrap();
assert_eq!(m.mark_in, Some(r.tick_of(24)), "frame:24 marks source frame 24, not the playhead");
s.execute("markers.markOut", json!({"target": "source", "seconds": 2.0})).unwrap();
let m = s.project.item(ItemId(bars)).unwrap().as_media().unwrap();
assert_eq!(m.mark_out, Some(Tick::from_seconds_f64(2.0)));
s.execute("markers.markSplitVideoIn", json!({"target": "source", "timecode": "00:00:01:00"})).unwrap();
assert_eq!(s.project.item(ItemId(bars)).unwrap().split.video_in, Some(r.tick_of(r.as_f64().round() as i64)));
s.execute("markers.markSplitAudioOut", json!({"target": "source", "frame": 48})).unwrap();
assert_eq!(s.project.item(ItemId(bars)).unwrap().split.audio_out, Some(r.tick_of(48)));
}
#[test]
fn range_and_chapter_markers() {
let mut s = demo();
s.execute("playhead.set", json!({"frame": 24})).unwrap();
let id = s.execute("markers.addRange", json!({})).unwrap()["marker"].as_u64().unwrap();
let m = s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().clone();
assert_eq!((m.start, m.duration), (f(&s, 24), f(&s, 24)), "one second");
s.execute("markers.addRange", json!({"durationFrames": 10, "color": "Rose"})).unwrap();
assert!(!s.is_enabled("markers.addRangeInOut"));
s.execute("markers.markIn", json!({"frame": 48})).unwrap();
s.execute("markers.markOut", json!({"frame": 71})).unwrap();
let id = s.execute("markers.addRangeInOut", json!({"name": "Scene"})).unwrap()["marker"].as_u64().unwrap();
let m = s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().clone();
assert_eq!((m.start, m.duration, m.name.as_str()), (f(&s, 48), f(&s, 24), "Scene"));
let id = s.execute("markers.addChapter", json!({"frame": 100})).unwrap()["marker"].as_u64().unwrap();
let m = s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().clone();
assert_eq!((m.kind, m.name.as_str(), m.start), (MarkerKind::Chapter, "Chapter 1", f(&s, 100)));
// colour filter + Show All Marker Colors
assert!(!s.is_enabled("markers.showAllMarkerColors"));
s.execute("markers.filterColors", json!({"color": "Rose", "visible": false})).unwrap();
assert_eq!(s.state.hidden_marker_colors, vec![Label::Rose]);
s.execute("markers.showAllMarkerColors", json!({})).unwrap();
assert!(s.state.hidden_marker_colors.is_empty());
assert!(s.execute("markers.filterColors", json!({"hidden": ["Nope"]})).is_err());
s.execute("markers.clearAll", json!({})).unwrap();
assert!(s.active_sequence().unwrap().markers.is_empty());
}
#[test]
fn unknown_marker_colours_are_rejected_not_stored_as_green() {
// #857: `markers.add {color:"Red"}` used to succeed and store Green
let mut s = demo();
let before = s.project.to_json();
for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] {
for color in [json!("Red"), json!("NotAColor"), json!(3)] {
let err = s.execute(cmd, json!({"frame": 10, "color": color})).unwrap_err().to_string();
assert!(err.contains("Rose"), "{cmd}: the error lists the accepted names: {err}");
assert_eq!(s.project.to_json(), before, "{cmd} {color} must not add a marker");
}
}
let id = s.execute("markers.add", json!({"frame": 10, "color": "rose"})).unwrap()["marker"].as_u64().unwrap();
let colour = |s: &Session| s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().color;
assert_eq!(colour(&s), Label::Rose);
assert!(s.execute("markers.edit", json!({"marker": id, "color": "Red"})).is_err());
assert_eq!(colour(&s), Label::Rose, "a bad colour leaves the marker unchanged");
s.execute("markers.edit", json!({"marker": id, "color": "Teal"})).unwrap();
assert_eq!(colour(&s), Label::Teal);
}
#[test]
fn ripple_sequence_markers_follow_ripple_edits() {
let mut s = demo();
s.execute("markers.clearAll", json!({})).unwrap();
let mk = |s: &mut Session, frame: i64| {
s.execute("markers.add", json!({"frame": frame})).unwrap()["marker"].as_u64().unwrap();
};
mk(&mut s, 30);
mk(&mut s, 200);
let starts = |s: &Session| s.active_sequence().unwrap().markers.iter().map(|m| m.start).collect::<Vec<_>>();
// off: extract leaves markers alone
s.execute("markers.markIn", json!({"frame": 24})).unwrap();
s.execute("markers.markOut", json!({"frame": 47})).unwrap();
s.execute("sequence.extract", json!({})).unwrap();
assert_eq!(starts(&s), vec![f(&s, 30), f(&s, 200)]);
s.execute("edit.undo", json!({})).unwrap();
// on: the marker inside goes, the later one moves left
assert_eq!(s.execute("markers.rippleSequenceMarkers", json!({})).unwrap()["rippleSequenceMarkers"], json!(true));
s.execute("sequence.extract", json!({})).unwrap();
assert_eq!(starts(&s), vec![f(&s, 176)]);
s.execute("edit.undo", json!({})).unwrap();
// insert from the source pushes later markers right
let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;
s.execute("source.open", json!({"item": item.0})).unwrap();
let r = s.sequence_rate();
s.execute("project.setMarks", json!({"item": item.0, "in": r.tick_of(0).0, "out": r.tick_of(11).0})).unwrap();
s.execute("markers.clearInOut", json!({})).unwrap();
s.execute("playhead.set", json!({"frame": 100})).unwrap();
s.execute("source.insert", json!({})).unwrap();
assert_eq!(starts(&s), vec![f(&s, 30), f(&s, 212)]);
// ripple trim of the first clip's out point moves the later markers
let first = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
s.execute("timeline.setTrack", json!({"track": "A2", "syncLock": false})).unwrap();
s.execute("timeline.trim", json!({"clip": first.id.0, "edge": "out", "mode": "ripple", "deltaFrames": -6})).unwrap();
let st = starts(&s);
assert_eq!(st.last(), Some(&f(&s, 206)), "{st:?}");
}
#[test]
fn copy_paste_includes_sequence_markers() {
let mut s = demo();
s.execute("markers.clearAll", json!({})).unwrap();
let first = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
s.execute("markers.add", json!({"frame": 10, "name": "a"})).unwrap();
s.execute("timeline.select", json!({"clips": [first.id.0]})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("playhead.set", json!({"frame": 500})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active_sequence().unwrap().markers.len(), 1, "off: markers stay behind");
s.execute("edit.undo", json!({})).unwrap();
s.execute("markers.copyPasteIncludesSequenceMarkers", json!({"on": true})).unwrap();
s.execute("timeline.select", json!({"clips": [first.id.0]})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("playhead.set", json!({"frame": 500})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let ms = &s.active_sequence().unwrap().markers;
assert_eq!(ms.len(), 2);
assert_eq!(ms[1].start, f(&s, 510));
assert_eq!(ms[1].name, "a");
assert_ne!(ms[0].id, ms[1].id);
}
#[test]
fn selection_follows_playhead() {
let mut s = demo();
s.execute("sequence.selectionFollowsPlayhead", json!({"on": true})).unwrap();
s.execute("playhead.set", json!({"frame": 10})).unwrap();
let q = s.active_sequence().unwrap();
let v = q.video_tracks[0].item_at(f(&s, 10)).unwrap().id;
let a = q.audio_tracks[0].item_at(f(&s, 10)).unwrap().id;
assert!(s.state.selection.contains(&v) && s.state.selection.contains(&a), "{:?}", s.state.selection);
s.execute("playhead.nextEdit", json!({})).unwrap();
assert!(!s.state.selection.contains(&v), "moves on with the playhead");
s.execute("sequence.selectionFollowsPlayhead", json!({})).unwrap();
let sel = s.state.selection.clone();
s.execute("playhead.set", json!({"frame": 10})).unwrap();
assert_eq!(s.state.selection, sel, "off again");
}
/// #208: with no In / Out mark, Go to In / Go to Out move to the start / end of the sequence
/// (they did nothing); with marks they go to them.
#[test]
fn go_to_in_out_without_marks_go_to_the_sequence_ends() {
let mut s = demo();
let end = s.active_sequence().unwrap().duration();
let mid = filmcraft_time::Tick(end.0 / 2);
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
s.execute("markers.goToIn", json!({})).unwrap();
assert_eq!(s.playhead(), filmcraft_time::Tick::ZERO, "the start");
s.execute("markers.goToOut", json!({})).unwrap();
assert_eq!(s.playhead(), end, "the end");
// with marks, the marks
let at = filmcraft_time::Tick(end.0 / 4);
s.execute("playhead.set", json!({"time": at.0})).unwrap();
s.execute("markers.markIn", json!({})).unwrap();
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
s.execute("markers.goToIn", json!({})).unwrap();
assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap());
}
/// #483: media dropped in the empty space past the last track goes on a new track (with
/// `"new"`), one undo step; a placement that fails leaves no new track behind.
#[test]
fn place_on_new_tracks_adds_them_in_one_undo_step() {
let (mut s, item) = fresh();
let before = s.active_sequence().unwrap().clone();
let (nv, na) = (before.video_tracks.len(), before.audio_tracks.len());
let undo = s.history.undo.len();
let r = s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "new", "time": 0})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1));
let clips: Vec<ClipId> = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
assert_eq!(clips.len(), 2);
assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[0]], "the picture is on the new top video track");
assert_eq!(q.audio_tracks[na].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[1]], "the sound is on the new last audio track");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
// only the picture gets a new track; the sound follows the given track
s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A1", "time": 0})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na));
assert_eq!((q.video_tracks[nv].items.len(), q.audio_tracks[0].items.len()), (1, 1));
s.execute("edit.undo", json!({})).unwrap();
// a placement that fails after the tracks were added: nothing changes
assert!(s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A99", "time": 0})).is_err());
assert_eq!(*s.active_sequence().unwrap(), before);
assert_eq!(s.history.undo.len(), undo);
}
/// #1104: a clip dragged into the empty space past the last track moves onto a new track (with
/// `"new"`), one undo step; a move that fails leaves no new track behind.
#[test]
fn move_to_new_track_adds_it_in_one_undo_step() {
let (mut s, item) = fresh();
let clips = place(&mut s, item, "V1", 0, 0, 10);
let before = s.active_sequence().unwrap().clone();
let nv = before.video_tracks.len();
let undo = s.history.undo.len();
s.execute("timeline.move", json!({"moves": [{"clip": clips[0], "track": "new", "time": 0}], "linked": false})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.video_tracks.len(), nv + 1);
assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id.0).collect::<Vec<_>>(), [clips[0]], "the clip is on the new top video track");
assert!(q.video_tracks[0].items.is_empty());
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
assert!(s.execute("timeline.move", json!({"moves": [{"clip": 999_999, "track": "new", "time": 0}]})).is_err());
assert_eq!(*s.active_sequence().unwrap(), before);
assert_eq!(s.history.undo.len(), undo);
}