From 0e3aebee5dc9dba8fe1d654f7b4c94150cf914e3 Mon Sep 17 00:00:00 2001 From: punkhop <1851839+punkhop@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:25:50 -0700 Subject: [PATCH] engine: clip.replaceFromBin can keep the clip's source In Problem: `clip.replaceFromBin` always restarts the clip at the new item's In point (or its start). That matches Premiere's Replace With Clip > From Bin, but a script or agent swapping one take for another has no way to keep the clip's place in the media. Solution: the default is unchanged, and so is its result. An optional `keepSourceIn` (default false) keeps each clip's current source In. A subclip that restricts trims has no media outside its range, so there a kept In outside the range is moved to the subclip's first or last frame; with `keepSourceIn` the result gains `sourceInClamped`, the clips this was done for. The parameter doc lists the new parameter only (a result description in it would take the command out of the Keyboard Shortcuts list, which reads the doc to see whether a command needs parameters). Tests: `replace_from_bin_keeps_the_source_in_on_request` (default and its result, keepSourceIn, undo / redo, the disabled case, subclips before, after and around the In, an unrestricted subclip, junk values), and `list_searches_labels_and_keys` now asserts the three Replace With Clip commands are bindable. docs/control-protocol.md describes the commands. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops.rs | 42 +++++++++++++--- crates/engine/src/clip_ops_tests.rs | 73 ++++++++++++++++++++++++++++ crates/engine/src/shortcuts_tests.rs | 5 ++ docs/control-protocol.md | 7 +++ 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 6a3f4196..701ba5a4 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -237,9 +237,15 @@ pub(crate) fn commands() -> Vec { can_replace_from_source, |s, p| replace_with(s, p, Replace::MatchFrame), ), - spec("clip.replaceFromBin", "From Bin", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?,"item":id?}"#, can_replace_from_bin, |s, p| { - replace_with(s, p, Replace::Bin) - }), + spec( + "clip.replaceFromBin", + "From Bin", + &["Clip", "Replace With Clip"], + None, + r#"{"clips":[id]?,"item":id?,"keepSourceIn":bool=false}"#, + can_replace_from_bin, + |s, p| replace_with(s, p, Replace::Bin), + ), ] } @@ -1664,12 +1670,22 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { Replace::Bin => item_range(&s.project, new_item, s.prefs.timeline.still_duration(s.sequence_rate())).map(|r| r.start).unwrap_or_default(), _ => source_range(s).map(|(_, r)| r.start).unwrap_or_default(), }; + // From Bin starts at the item's In point, as Premiere does; `keepSourceIn` keeps each clip's own + let keep_in = how == Replace::Bin && bool_p(p, "keepSourceIn").unwrap_or(false); + // ...but a subclip that restricts trims has no media outside its range: a kept In stays on + // one of its frames (first frame, last frame) + let keep_within = match &pi.kind { + ItemKind::Subclip { range, restrict_trims: true, .. } => { + Some((range.start, (range.end() - item_rate(&s.project, new_item).frame_duration()).max(range.start))) + } + _ => None, + }; let src_ph = s.state.source_playhead; let ph = s.playhead(); let targets = with_links(s, &clips_p(s, p)); let src_channels = pi.as_media().and_then(|m| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default(); - let replaced = s.edit_sequence("Replace With Clip", |q, _, _| { - let mut out = Vec::new(); + let (replaced, clamped) = s.edit_sequence("Replace With Clip", |q, _, _| { + let (mut out, mut clamped) = (Vec::new(), Vec::new()); for c in &targets { let Some((tid, _)) = q.find_item(*c) else { continue }; let kind = q.track(tid).map(|t| t.kind).unwrap_or_default(); @@ -1680,6 +1696,13 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { let src_in = match how { // the Source monitor frame lines up with the sequence playhead Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64), + _ if keep_in => { + let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in); + if kept != it.source_in { + clamped.push(c.0); + } + kept + } _ => in_point, }; if src_in < Tick::ZERO { @@ -1699,7 +1722,12 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { if out.is_empty() { return Err(EngineError::Other("the replacement has no video or audio for the selected clips".into())); } - Ok(out) + Ok((out, clamped)) })?; - Ok(json!({"clips": replaced, "item": new_item.0})) + let mut result = json!({"clips": replaced, "item": new_item.0}); + if keep_in { + // the clips whose In was outside a trims-restricted subclip and now sits on its nearest frame + result["sourceInClamped"] = json!(clamped); + } + Ok(result) } diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 3fb4aab5..4121e0b7 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -602,6 +602,79 @@ fn replace_with_clip_from_source_match_frame_and_bin() { assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err()); } +/// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does); +/// `keepSourceIn` keeps the clip's place in the media instead. +#[test] +fn replace_from_bin_keeps_the_source_in_on_request() { + let mut s = demo(); + let rate = s.sequence_rate(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let c = v1(&s)[3].clone(); + let dunes = item_named(&s, "Desert_Dunes.mp4"); + let forest = item_named(&s, "Misty_Forest.mp4"); + // disabled without a clip and a replacement, with or without the new parameter + s.execute("timeline.select", json!({"clips": []})).unwrap(); + assert!(matches!(s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})), Err(crate::EngineError::Disabled(..)))); + // give the clip a source In that is not the start of its media + s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap(); + let trimmed = clip(&s, c.id.0); + assert_eq!(trimmed.source_in, c.source_in + rate.tick_of(7)); + select(&mut s, &[c.id.0]); + s.execute("project.select", json!({"items": [dunes.0]})).unwrap(); + let before = s.project.clone(); + // default: the replacement starts at the item's In point, and the result is what it was + let r = s.execute("clip.replaceFromBin", json!({})).unwrap(); + assert_eq!((clip(&s, c.id.0).item, clip(&s, c.id.0).source_in), (dunes, Tick::ZERO)); + assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0})); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + // keepSourceIn: the same place in the new media, everything else as before + let r = s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!((got.item, got.source_in, got.start, got.duration), (dunes, trimmed.source_in, trimmed.start, trimmed.duration)); + assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "sourceInClamped": []})); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("clip.replaceFromBin", json!({"keepSourceIn": false})).unwrap(); + assert_eq!(clip(&s, c.id.0).source_in, Tick::ZERO); + s.execute("edit.undo", json!({})).unwrap(); + + // a subclip that restricts trims has no media outside its range: a kept In before or after it + // moves to its first or last frame, and the result names the clip + let sub = |s: &mut Session, from: Tick, to: Tick, restrict: bool| { + let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": from.0, "end": to.0, "restrictTrims": restrict})).unwrap(); + ItemId(r["item"].as_u64().unwrap()) + }; + let kept = trimmed.source_in; + let media_frame = s.project.item(forest).unwrap().as_media().unwrap().frame_rate().frame_duration(); + assert!(kept > rate.tick_of(10) && media_frame > Tick::ZERO); + let later = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), true); + let earlier = sub(&mut s, Tick::ZERO, kept - rate.tick_of(2), true); + let loose = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), false); + for (item, want, clamped) in [(later, kept + rate.tick_of(3), true), (earlier, kept - rate.tick_of(2) - media_frame, true), (loose, kept, false)] { + let r = s.execute("clip.replaceFromBin", json!({"item": item.0, "keepSourceIn": true})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!((got.item, got.source_in, got.start, got.duration), (item, want, trimmed.start, trimmed.duration)); + assert_eq!(r["sourceInClamped"], if clamped { json!([c.id.0]) } else { json!([]) }); + s.execute("edit.undo", json!({})).unwrap(); + } + // a kept In that is already inside the subclip stays where it is + let around = sub(&mut s, kept - rate.tick_of(5), kept + rate.tick_of(23), true); + let r = s.execute("clip.replaceFromBin", json!({"item": around.0, "keepSourceIn": true})).unwrap(); + assert_eq!((clip(&s, c.id.0).source_in, &r["sourceInClamped"]), (kept, &json!([]))); + // hostile values of the flag fall back to the default + s.execute("edit.undo", json!({})).unwrap(); + for junk in [json!("yes"), json!(1), json!(null), json!([true])] { + let r = s.execute("clip.replaceFromBin", json!({"item": dunes.0, "keepSourceIn": junk})).unwrap(); + assert_eq!((clip(&s, c.id.0).source_in, r.get("sourceInClamped")), (Tick::ZERO, None)); + s.execute("edit.undo", json!({})).unwrap(); + } +} + #[test] fn audio_source_channels_pick_what_a_clip_plays() { let mut it = v1(&demo())[0].clone(); diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 23dc6dec..0bf0b63b 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -205,6 +205,11 @@ fn list_searches_labels_and_keys() { let r = s.shortcuts.commands(); assert!(r.iter().any(|c| c.id == "trim.forward")); assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable"); + // a parameter doc holds parameters only (each optional or with a default): a result + // description in it would take the command out of this list + for id in ["clip.replaceFromBin", "clip.replaceFromSource", "clip.replaceFromSourceMatchFrame"] { + assert!(r.iter().any(|c| c.id == id), "{id} has no required parameter, so it can be bound"); + } let mut s = s; let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap(); assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b473892b..f8e95c8d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -80,6 +80,13 @@ it shows. Dynamic trimming takes an explicit clock (seconds) so it is determinis {direction, slow?, clock}` (J/L), `trim.tick {clock}`, `trim.shuttleStop {clock?}` (K, one undo step), `trim.cancelDynamic` (Esc), `trim.playAround {clock, loop?}` (Space / Shift+K). +**Replace With Clip**: `clip.replaceFromSource`, `clip.replaceFromSourceMatchFrame` and +`clip.replaceFromBin {clips?, item?, keepSourceIn?}` swap the media of the clips and return +`{clips: [ids], item}`. From Bin starts each clip at the new item's In point; with +`keepSourceIn: true` each clip keeps its own source In instead. A subclip that restricts trims has +no media outside its range, so there a kept In is moved to the subclip's first or last frame, and +the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for. + **Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq| rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace: auto|}`, `color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,