Merge pull request #1427 from bfenty/fix/ripple-head-1357
Add edit.rippleDeleteHead (Ripple Delete Head Frame)
This commit is contained in:
commit
23c9cdb33d
3 files changed
+162
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user