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:
Eduardo Andrade Machado
2026-10-25 10:42:53 -03:00
committed by GitHub
parent d5fd9f85f3
commit d81c8975df
4 changed files with 181 additions and 9 deletions
+59 -8
View File
@@ -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> {
+61
View File
@@ -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");
}
}
+7 -1
View File
@@ -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]