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:
commit
eb3ca3e811
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue