Merge pull request #1427 from bfenty/fix/ripple-head-1357

Add edit.rippleDeleteHead (Ripple Delete Head Frame)
This commit is contained in:
Brandon Thomas committed 2026-10-13 02:34:37 -04:00
commit 23c9cdb33d
3 files changed
+162

No files matched your search

+52
View File
@@ -1677,6 +1677,58 @@ fn build() -> Vec<CommandSpec> {
})?;
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<ClipId>)> = 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<ClipId> = 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<ClipId> = 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;
+2
View File
@@ -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;
+108
View File
@@ -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");
}