mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Lift and Extract put the removed range on the clipboard (#3211)
* #3019: Lift and Extract put the removed range on the clipboard Lift and Extract took the range off the targeted tracks but left the clipboard untouched, so Paste answered "clipboard is empty". They now hold the range the way Copy holds clips: cut at the In and Out points, from the targeted unlocked tracks, with the sequence markers in it when Copy Paste Includes Sequence Markers is on. The desktop app treats them like Copy and Cut for the system clipboard, so Ctrl+V pastes the range rather than files left on the OS clipboard (#697, #199). * #3019: Lift and Extract over no clips leave the OS clipboard alone Over a range with no clips on the targeted tracks, Lift and Extract leave the clipboard as it was, but the desktop app still marked the OS clipboard media as stale and replaced its text with the names of older copied clips, so Ctrl+V pasted those clips instead of an image or files copied since. The commands now report how many clips they copied ({"copied": n}, as Copy does for captions) and the app updates the OS clipboard only when that is more than zero.
This commit is contained in:
committed by
GitHub
parent
d5fd9f85f3
commit
d81c8975df
@@ -2146,13 +2146,19 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("sequence.lift", "Lift", ["Sequence"], Some(";"), "{}", has_in_out, |s, _| {
|
||||
let r = in_out_range(s)?;
|
||||
let tg = s.targeting().targeted;
|
||||
s.edit_sequence("Lift", |q, ctx, _| Ok(edit::lift(q, &tg, r, ctx)))?;
|
||||
Ok(Value::Null)
|
||||
let cut = s.edit_sequence("Lift", |q, ctx, st| {
|
||||
let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers);
|
||||
edit::lift(q, &tg, r, ctx);
|
||||
Ok(cut)
|
||||
})?;
|
||||
let copied = store_cut(s, cut);
|
||||
Ok(json!({"copied": copied}))
|
||||
}),
|
||||
cmd!("sequence.extract", "Extract", ["Sequence"], Some("'"), "{}", has_in_out, |s, _| {
|
||||
let r = in_out_range(s)?;
|
||||
let tg = s.targeting().targeted;
|
||||
s.edit_sequence("Extract", |q, ctx, st| {
|
||||
let cut = s.edit_sequence("Extract", |q, ctx, st| {
|
||||
let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers);
|
||||
edit::extract(q, &tg, r, ctx);
|
||||
if st.ripple_sequence_markers {
|
||||
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
|
||||
@@ -2160,10 +2166,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
q.mark_in = None;
|
||||
q.mark_out = None;
|
||||
q.split = Default::default();
|
||||
Ok(())
|
||||
Ok(cut)
|
||||
})?;
|
||||
let copied = store_cut(s, cut);
|
||||
s.set_playhead(r.start);
|
||||
Ok(Value::Null)
|
||||
Ok(json!({"copied": copied}))
|
||||
}),
|
||||
cmd!(
|
||||
"sequence.applyVideoTransition",
|
||||
@@ -3810,9 +3817,53 @@ fn copy_selection(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.clipboard = clip;
|
||||
s.state.clipboard_markers = markers;
|
||||
s.state.caption_clipboard.clear();
|
||||
set_clipboard(&mut s.state, (clip, markers));
|
||||
}
|
||||
|
||||
/// Timeline clips (track kind, track index, item) and the sequence markers that go with them.
|
||||
type Clipboard = (Vec<(TrackKind, usize, filmcraft_project::TrackItem)>, Vec<Marker>);
|
||||
|
||||
fn set_clipboard(st: &mut crate::EditorState, (clips, markers): Clipboard) {
|
||||
st.clipboard = clips;
|
||||
st.clipboard_markers = markers;
|
||||
st.caption_clipboard.clear();
|
||||
}
|
||||
|
||||
/// Put a Lift or Extract cut on the clipboard; returns how many clips it holds (0 leaves the
|
||||
/// clipboard as it was).
|
||||
fn store_cut(s: &mut Session, cut: Option<Clipboard>) -> usize {
|
||||
let Some(cut) = cut else { return 0 };
|
||||
let copied = cut.0.len();
|
||||
set_clipboard(&mut s.state, cut);
|
||||
copied
|
||||
}
|
||||
|
||||
/// What Lift and Extract put on the clipboard, as in Premiere (#3019): the clips of the unlocked
|
||||
/// `tracks` (the targeted ones) in `range`, cut at its edges and held the way Copy holds them.
|
||||
/// Extract also cuts sync-locked tracks only to keep them in sync; that material is not copied.
|
||||
/// Reads the sequence before the edit; None when the range has no clips on those tracks.
|
||||
fn cut_range(q: &filmcraft_project::Sequence, tracks: &[TrackId], range: TimeRange, ctx: &mut edit::EditCtx, with_markers: bool) -> Option<Clipboard> {
|
||||
let mut links = std::collections::HashMap::new();
|
||||
let mut clips = Vec::new();
|
||||
for (kind, list) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||||
for (ti, t) in list.iter().enumerate().filter(|(_, t)| !t.locked && tracks.contains(&t.id)) {
|
||||
let mut t = t.clone();
|
||||
edit::split_track_at(&mut t, range.start, ctx, &mut links);
|
||||
edit::split_track_at(&mut t, range.end(), ctx, &mut links);
|
||||
clips.extend(t.items.into_iter().filter(|i| i.start >= range.start && i.end() <= range.end()).map(|i| (kind, ti, i)));
|
||||
}
|
||||
}
|
||||
let min = clips.iter().map(|c| c.2.start).min()?;
|
||||
let max = clips.iter().map(|c| c.2.end()).max()?;
|
||||
for c in &mut clips {
|
||||
c.2.start -= min;
|
||||
}
|
||||
let markers = if with_markers {
|
||||
q.markers.iter().filter(|m| m.start >= min && m.start < max).map(|m| Marker { start: m.start - min, ..m.clone() }).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some((clips, markers))
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, insert: bool) -> Result<Value> {
|
||||
|
||||
@@ -836,3 +836,64 @@ fn move_to_new_track_adds_it_in_one_undo_step() {
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// Lift and Extract put what they take off the timeline on the clipboard, cut at the In and Out
|
||||
/// points, from the targeted tracks only, with the sequence markers in the range when Copy Paste
|
||||
/// Includes Sequence Markers is on (#3019).
|
||||
#[test]
|
||||
fn lift_and_extract_put_the_range_on_the_clipboard() {
|
||||
for cmd in ["sequence.lift", "sequence.extract"] {
|
||||
let (mut s, item) = fresh();
|
||||
place(&mut s, item, "V1", 0, 0, 48);
|
||||
place(&mut s, item, "V2", 0, 0, 48);
|
||||
for t in ["V2", "A2"] {
|
||||
s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap();
|
||||
}
|
||||
s.execute("markers.copyPasteIncludesSequenceMarkers", json!({"on": true})).unwrap();
|
||||
s.execute("markers.add", json!({"frame": 15, "name": "inside"})).unwrap();
|
||||
s.execute("markers.add", json!({"frame": 30, "name": "after"})).unwrap();
|
||||
s.execute("markers.markIn", json!({"frame": 12})).unwrap();
|
||||
s.execute("markers.markOut", json!({"frame": 23})).unwrap();
|
||||
assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(2), "{cmd}: the V1 and A1 pieces");
|
||||
s.execute("playhead.set", json!({"frame": 100})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}"));
|
||||
let q = s.active_sequence().unwrap();
|
||||
let at = f(&s, 100);
|
||||
let pasted: Vec<_> = [&q.video_tracks[0], &q.audio_tracks[0]]
|
||||
.iter()
|
||||
.map(|t| t.items.iter().find(|i| i.start == at).unwrap_or_else(|| panic!("{cmd}: nothing pasted on {}", t.name)))
|
||||
.collect();
|
||||
for p in &pasted {
|
||||
assert_eq!((p.duration, p.source_in), (f(&s, 12), f(&s, 12)), "{cmd}: the pasted piece is frames 12-23 of the media");
|
||||
}
|
||||
assert!(pasted[0].link.is_some() && pasted[0].link == pasted[1].link, "{cmd}: the pasted video and audio stay linked");
|
||||
for t in [&q.video_tracks[1], &q.audio_tracks[1]] {
|
||||
assert!(t.items.iter().all(|i| i.start != at), "{cmd}: nothing from the untargeted {} is pasted", t.name);
|
||||
}
|
||||
let pasted_markers: Vec<_> = q.markers.iter().filter(|m| m.start >= at).map(|m| (m.start, m.name.as_str())).collect();
|
||||
assert_eq!(pasted_markers, vec![(f(&s, 103), "inside")], "{cmd}: only the marker inside the range comes along");
|
||||
}
|
||||
}
|
||||
|
||||
/// Over a range with no clips on the targeted tracks, Lift and Extract copy nothing and Paste still
|
||||
/// brings back what was copied before (#3019).
|
||||
#[test]
|
||||
fn lift_and_extract_over_no_clips_keep_the_clipboard() {
|
||||
for cmd in ["sequence.lift", "sequence.extract"] {
|
||||
let (mut s, item) = fresh();
|
||||
let clips = place(&mut s, item, "V1", 0, 0, 48);
|
||||
s.execute("timeline.select", json!({"clips": clips})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
for t in ["V1", "A1"] {
|
||||
s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap();
|
||||
}
|
||||
s.execute("markers.markIn", json!({"frame": 12})).unwrap();
|
||||
s.execute("markers.markOut", json!({"frame": 23})).unwrap();
|
||||
assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(0), "{cmd}");
|
||||
s.execute("playhead.set", json!({"frame": 100})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}"));
|
||||
let q = s.active_sequence().unwrap();
|
||||
let pasted = q.video_tracks[0].items.iter().find(|i| i.start == f(&s, 100)).map(|i| i.duration);
|
||||
assert_eq!(pasted, Some(f(&s, 48)), "{cmd}: the earlier copy is pasted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +606,13 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
|
||||
if r.is_ok() && matches!(id, "playhead.start" | "playhead.end") {
|
||||
crate::panels::timeline::reveal_playhead(app, ctx);
|
||||
}
|
||||
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") {
|
||||
let filled_clipboard = match id {
|
||||
"edit.copy" | "edit.cut" => r.is_ok(),
|
||||
// over a range with no clips on the targeted tracks they leave the clipboard (and the OS one) as it was
|
||||
"sequence.lift" | "sequence.extract" => r.as_ref().is_ok_and(|v| v["copied"].as_u64().is_some_and(|n| n > 0)),
|
||||
_ => false,
|
||||
};
|
||||
if filled_clipboard {
|
||||
remember_stale_clipboard_media(app);
|
||||
put_clip_names_on_system_clipboard(app, ctx);
|
||||
}
|
||||
|
||||
@@ -228,6 +228,60 @@ fn ctrl_v_pastes_a_cut_clip_even_when_old_files_linger_on_the_clipboard() {
|
||||
assert!(!d.clips().iter().any(|c| c.3.contains("stale")), "lingering OS files are not pasted");
|
||||
}
|
||||
|
||||
/// Lift and Extract put the range on the clipboard like Cut, so Ctrl+V pastes it rather than files
|
||||
/// left on the OS clipboard (#3019).
|
||||
#[test]
|
||||
fn ctrl_v_pastes_a_lifted_or_extracted_range_even_when_old_files_linger_on_the_clipboard() {
|
||||
for cmd in ["sequence.lift", "sequence.extract"] {
|
||||
let mut d = Driver::demo("lift-linger");
|
||||
d.ok("ui.set", json!({"focused": "Timeline"}));
|
||||
let png = d.dir.join("stale.png");
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([200, 0, 200, 255])).save(&png).unwrap();
|
||||
let path = png.to_string_lossy().into_owned();
|
||||
d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()]))));
|
||||
let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap();
|
||||
d.ok("engine.execute", json!({"command": "markers.markIn", "params": {"frame": 6}}));
|
||||
d.ok("engine.execute", json!({"command": "markers.markOut", "params": {"frame": 17}}));
|
||||
d.ok("ui.menu.invoke", json!({"id": cmd}));
|
||||
d.ok("engine.execute", json!({"command": "playhead.end", "params": {}}));
|
||||
let named = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count();
|
||||
let before = named(&mut d);
|
||||
d.ctrl_v_with_text();
|
||||
assert_eq!(named(&mut d), before + 1, "{cmd}: the range is pasted");
|
||||
assert!(!d.clips().iter().any(|c| c.3.contains("stale")), "{cmd}: lingering OS files are not pasted");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lift or Extract over a range with no clips on the targeted tracks leaves both clipboards
|
||||
/// alone: an image copied afterwards in another app is not taken for stale, and Ctrl+V pastes it
|
||||
/// rather than the clip copied before (#3019).
|
||||
#[test]
|
||||
fn lift_or_extract_over_no_clips_keeps_a_newer_os_image_pasteable() {
|
||||
for cmd in ["sequence.lift", "sequence.extract"] {
|
||||
let mut d = Driver::demo("lift-empty");
|
||||
d.ok("ui.set", json!({"focused": "Timeline"}));
|
||||
let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap();
|
||||
d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "edit.copy"}));
|
||||
d.clipboard(Some(red_image));
|
||||
let tracks: Vec<u64> = d.app().session.active_sequence().unwrap().all_tracks().map(|t| t.id.0).collect();
|
||||
let target = |d: &mut Driver, on: bool| {
|
||||
for t in &tracks {
|
||||
d.ok("engine.execute", json!({"command": "timeline.setTargeting", "params": {"track": t, "targeted": on}}));
|
||||
}
|
||||
};
|
||||
target(&mut d, false);
|
||||
d.ok("engine.execute", json!({"command": "markers.markIn", "params": {"frame": 6}}));
|
||||
d.ok("engine.execute", json!({"command": "markers.markOut", "params": {"frame": 17}}));
|
||||
d.ok("ui.menu.invoke", json!({"id": cmd}));
|
||||
target(&mut d, true);
|
||||
let before = d.clips();
|
||||
d.ctrl_v_without_text(egui::Modifiers::COMMAND);
|
||||
let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).map(|c| c.3).collect();
|
||||
assert_eq!(new, ["Pasted Image 1.png"], "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy from the keyboard (Ctrl+C as `Event::Copy`) also snapshots lingering OS media, so Ctrl+V
|
||||
/// pastes the clips rather than those files.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user