Merge pull request #49 from punkhop/fix/replace-from-bin-keep-source-in

engine: clip.replaceFromBin can keep the clip's source In
This commit is contained in:
Brandon Thomas 2026-10-06 11:37:05 -04:00 committed by GitHub
commit eb3ca3e811
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 7 deletions

View File

@ -237,9 +237,15 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
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)
}

View File

@ -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();

View File

@ -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}");

View File

@ -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|<id>}`,
`color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,