Keyboard slip: clamp linked clips by one shared delta (#529)

This commit is contained in:
MarekSpodBiedry committed 2026-10-10 11:41:12 +02:00
1 parent 55d10195fb
commit 628d573f1d
3 files changed
+62 -19

No files matched your search

+27
View File
@@ -923,3 +923,30 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
}
#[test]
fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Slip"})).unwrap();
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
let item = r["item"].as_u64().unwrap();
let fr = s.sequence_rate().tick_of(1);
let r =
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
// lengthen only the picture: it keeps 10 frames of tail handle, the sound keeps 20
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 10})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
for _ in 0..3 {
s.execute("timeline.slipLeft5", json!({})).unwrap();
}
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 30)));
// with linked selection off each selected clip still slips on its own
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
s.execute("timeline.slipLeft5", json!({})).unwrap();
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 35)));
}
+20 -11
View File
@@ -340,6 +340,24 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>(), "overwritten": overwritten}))
}
/// Slip clips together: the media delta of each clip is scaled down by the same factor so that the
/// most constrained clip (media end or start) still moves, and no linked partner slips further.
pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> {
let mut scale = 1.0_f64;
for (c, d) in deltas {
if d.0 == 0 {
continue;
}
let x = edit::slip(&mut q.clone(), *c, *d, ctx)?;
scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64);
}
for (c, d) in deltas {
let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d };
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
}
/// Expand a clip selection with linked partners (when linked selection is on).
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
@@ -2220,17 +2238,8 @@ fn build() -> Vec<CommandSpec> {
let clips = with_links(s, &[c]);
s.edit_sequence("Slip", |q, ctx, _| {
// clamp across all linked partners, then slip them by the common delta
let mut dd = d;
for c in &clips {
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
let deltas: Vec<(ClipId, Tick)> = clips.iter().map(|c| (*c, d)).collect();
slip_together(q, ctx, &deltas)
})?;
Ok(Value::Null)
}),
+15 -8
View File
@@ -40,7 +40,7 @@ use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKi
use filmcraft_time::Tick;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, time_p, with_links};
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, slip_together, time_p, with_links};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
@@ -248,14 +248,21 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
let rate = s.sequence_rate();
let clips = with_links(s, &s.state.selection.clone());
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
// media delta per clip (source frames run at the clip's speed)
let deltas: Vec<(ClipId, Tick)> = clips
.iter()
.filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64))))
.collect();
let linked = s.state.linked_selection;
// media delta per clip (source frames run at the clip's speed); linked partners slip as one group
let mut groups: Vec<(Option<u64>, Vec<(ClipId, Tick)>)> = Vec::new();
for c in &clips {
let Some((_, i)) = seq.find_item(*c) else { continue };
let d = Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64);
let link = i.link.filter(|_| linked);
match groups.iter_mut().find(|(l, _)| link.is_some() && *l == link) {
Some((_, g)) => g.push((*c, d)),
None => groups.push((link, vec![(*c, d)])),
}
}
s.edit_sequence("Slip", |q, ctx, _| {
for (c, d) in &deltas {
edit::slip(q, *c, *d, ctx)?;
for (_, g) in &groups {
slip_together(q, ctx, g)?;
}
Ok(())
})?;