diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c6de553e..c99e5c64 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1677,6 +1677,58 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!("edit.rippleDeleteHead", "Ripple Delete Head Frame", ["Edit"], None, r#"{"clips":[id]?,"clip":id?}"#, has_selection, |s, p| { + // #1357: remove the head — from each selected clip's in point through the playhead — + // and ripple the sequence closed. Linked clips ripple as one group (the engine's + // group trim moves every member's in point by one delta and shifts the synced + // tracks once). The playhead past every member's out point deletes the group whole. + let ph = s.playhead(); + let clips = with_links(s, &clips_p(s, p)); + // groups keyed by link id (unlinked clips: their own id) + let mut groups: Vec<(u64, Vec)> = Vec::new(); + { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + for c in &clips { + let (_, item) = q.find_item(*c).ok_or_else(|| EngineError::Other("clip not in the active sequence".into()))?; + if (ph - item.start) <= Tick::ZERO { + return Err(EngineError::Other("the playhead is at or before the clip's in point".into())); + } + let key = item.link.unwrap_or(c.0); + match groups.iter_mut().find(|(k, _)| *k == key) { + Some((_, members)) => members.push(*c), + None => groups.push((key, vec![*c])), + } + } + } + s.edit_sequence("Ripple Delete Head Frame", |q, ctx, st| { + for (_, members) in &groups { + let live: Vec<(ClipId, Tick, Tick)> = members.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| (*c, i.start, i.duration))).collect(); + if live.is_empty() { + continue; + } + let head = live.iter().map(|(_, s, _)| (ph - *s).max(Tick::ZERO)).max().unwrap_or(Tick::ZERO); + let all_whole = live.iter().all(|(_, _, d)| head >= *d); + let ids: Vec = live.iter().map(|(c, _, _)| *c).collect(); + if all_whole { + let spans = edit::ripple_delete_items(q, &ids)?; + if st.ripple_sequence_markers { + for r in spans.iter().rev() { + crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration); + } + } + continue; + } + let start = live.iter().map(|(_, s, _)| *s).min().unwrap_or(ph); + edit::ripple_trim_group(q, &ids, Edge::In, head, ctx)?; + if st.ripple_sequence_markers { + // the head [start, start + head) closes: later markers move back by it + crate::sequence_tools::ripple_markers(&mut q.markers, start + head, Tick::ZERO - head); + } + } + Ok(()) + })?; + Ok(Value::Null) + }), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| { let all: Vec = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default(); s.state.selection = all; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4098893a..778312b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1288,6 +1288,8 @@ mod remix_tests; #[cfg(test)] mod ripple_delete_tests; #[cfg(test)] +mod ripple_head_tests; +#[cfg(test)] mod roll_linked_tests; #[cfg(test)] mod scopes_tests; diff --git a/crates/engine/src/ripple_head_tests.rs b/crates/engine/src/ripple_head_tests.rs new file mode 100644 index 00000000..61588539 --- /dev/null +++ b/crates/engine/src/ripple_head_tests.rs @@ -0,0 +1,108 @@ +//! #1357: `edit.rippleDeleteHead` removes the portion of the selected clip from its in point +//! through the playhead and ripples the sequence closed. + +use crate::Session; +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +fn f(s: &Session, n: i64) -> Tick { + s.sequence_rate().tick_of(n) +} + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4]. +fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Head", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for n in 0..4 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + [0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +/// The head (in point .. playhead) goes and the rest of the sequence closes up: the clip keeps its +/// tail at the left of its old range, later clips shift left. +#[test] +fn ripple_delete_head_closes_the_head_and_shifts_later_clips() { + let mut s = Session::default(); + let [(_, _), (pic2, snd2), (pic3, snd3), (pic4, snd4)] = four_clips(&mut s); + s.set_playhead(f(&s, 60)); + s.state.selection = vec![pic2]; + + s.execute("edit.rippleDeleteHead", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + q.check().unwrap(); + assert_eq!(range(&s, pic2), (f(&s, 48), f(&s, 84)), "the head is gone and the tail slid left"); + assert_eq!(range(&s, snd2), (f(&s, 48), f(&s, 84)), "the linked sound follows"); + assert_eq!(range(&s, pic3), (f(&s, 84), f(&s, 132)), "the next picture closes up by the head length"); + assert_eq!((range(&s, snd3), range(&s, pic4), range(&s, snd4)), ((f(&s, 84), f(&s, 132)), (f(&s, 132), f(&s, 180)), (f(&s, 132), f(&s, 180)))); + assert_eq!(q.duration(), f(&s, 180), "the sequence is 12 frames shorter"); +} + +/// The trimmed clip's media in point advances by the head length (the head frames are dropped, +/// not hidden). +#[test] +fn ripple_delete_head_advances_the_source_in_point() { + let mut s = Session::default(); + let [(pic1, _), ..] = four_clips(&mut s); + let in_before = s.active_sequence().unwrap().find_item(pic1).unwrap().1.source_in; + s.set_playhead(f(&s, 12)); + s.state.selection = vec![pic1]; + s.execute("edit.rippleDeleteHead", json!({})).unwrap(); + let item = s.active_sequence().unwrap().find_item(pic1).unwrap().1; + assert_eq!(item.source_in, in_before + f(&s, 12), "the head frames are dropped from the source"); + assert_eq!((item.start, item.end()), (Tick::ZERO, f(&s, 36)), "the clip starts at zero, 36 frames long"); +} + +/// The playhead at or before the in point has no head to remove. +#[test] +fn ripple_delete_head_needs_the_playhead_past_the_in_point() { + let mut s = Session::default(); + let [(pic1, _), ..] = four_clips(&mut s); + s.set_playhead(Tick::ZERO); + s.state.selection = vec![pic1]; + s.execute("edit.rippleDeleteHead", json!({})).unwrap_err(); + assert_eq!(range(&s, pic1), (Tick::ZERO, s.sequence_rate().tick_of(48)), "nothing moved"); +} + +/// The playhead past the clip's out point deletes the whole clip (the head covers all of it). +#[test] +fn ripple_delete_head_past_the_end_removes_the_whole_clip() { + let mut s = Session::default(); + let [(_, _), (pic2, snd2), (pic3, snd3), ..] = four_clips(&mut s); + s.set_playhead(f(&s, 120)); // past clip 2's out point at 96 + s.state.selection = vec![pic2]; + s.execute("edit.rippleDeleteHead", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + q.check().unwrap(); + assert!(q.find_item(pic2).is_none() && q.find_item(snd2).is_none(), "the whole clip went"); + assert_eq!(range(&s, pic3), (f(&s, 48), f(&s, 96)), "later clips closed the whole 48-frame gap"); + assert_eq!(range(&s, snd3), (f(&s, 48), f(&s, 96))); +} + +/// With Ripple Sequence Markers on, markers after the removed head move back with the clips, and +/// markers inside the head go with it. +#[test] +fn ripple_delete_head_ripples_sequence_markers() { + let mut s = Session::default(); + let [(_, _), (pic2, _), (_, _), (_, _)] = four_clips(&mut s); + s.state.ripple_sequence_markers = true; + let at = |s: &Session, n: i64| s.sequence_rate().tick_of(n).0; + for (frame, name) in [(30, "before"), (52, "inside"), (120, "after")] { + s.execute("markers.add", json!({"time": at(&s, frame), "name": name})).unwrap(); + } + s.set_playhead(f(&s, 60)); + s.state.selection = vec![pic2]; + + s.execute("edit.rippleDeleteHead", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + let markers: Vec<(String, Tick)> = q.markers.iter().map(|m| (m.name.clone(), m.start)).collect(); + assert_eq!(markers, vec![("before".to_string(), f(&s, 30)), ("after".to_string(), f(&s, 108))], "the 12-frame head closes"); +}