From ad0492b05659dbd689708a79d88afcc23f558919 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 13:28:09 +0100 Subject: [PATCH] Timeline: trimming a selected layer's in/out point trims every selected layer (as in After Effects) (cherry picked from commit 6af5aa81bf3b66d272f15f018f065f3324faa9fb) (cherry picked from commit 5423c78e4a58789c26df4f4db46e6f27fb458d51) (cherry picked from commit 8863abad1571815719e59715955c6a0402b5b67e) --- crates/engine/src/commands/layer.rs | 13 ++++++++- crates/engine/src/tests_timeline.rs | 29 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 +++++---- crates/ui-egui/tests/ui_timeline_trim.rs | 34 ++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09bad38fc..06e904601 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -857,10 +857,21 @@ fn timing(s: &mut Session, p: &Value) -> Result { let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); + // `inDelta` / `outDelta`: move each layer's In / Out point by the same amount (the Timeline's + // trim drag, which trims every selected layer as in After Effects), each layer within its own + // limits: the comp's start / end (unless already past it) and its other end. + let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); + let dur = comp.duration; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + if let Some(d) = in_delta { + l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd); + } + if let Some(d) = out_delta { + l.out_point = (l.out_point + d).min(dur.max(l.out_point)).max(l.in_point + fd); + } let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; @@ -1530,7 +1541,7 @@ pub fn specs() -> Vec { "Layer Timing", [], None, - "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}", + "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (trim each layer's In / Out by seconds), merge?}", has_layers, timing ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b3..69dd13ee1 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -883,3 +883,32 @@ fn property_edits_apply_to_every_selected_layer() { s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0); } + +/// `inDelta` / `outDelta` trim every layer by the same amount (the Timeline's trim drag over +/// several selected layers), each within its own limits, and a drag is one undo step. +#[test] +fn timing_deltas_trim_each_layer_within_its_limits() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [a], "in": 0.5, "out": 2.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [b], "in": 1.0, "out": 3.5})).unwrap(); + let times = |s: &Session| -> Vec<(f64, f64)> { + let c = s.active_comp().unwrap(); + [a, b].iter().map(|id| c.layer(effectcraft_project::LayerId(*id)).map(|l| (l.in_point.seconds(), l.out_point.seconds())).unwrap()).collect() + }; + let before = times(&s); + // A drag: two steps of half a second, one merge key; B stops at the comp's end (4 s). + for _ in 0..2 { + s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 0.5, "merge": "bar-1"})).unwrap(); + } + assert_eq!(times(&s), vec![(0.5, 3.0), (1.0, 4.0)]); + assert!(s.undo()); + assert_eq!(times(&s), before, "one undo step restores every layer"); + // In points: A stops at the comp's start; neither crosses its Out point. + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -1.0})).unwrap(); + assert_eq!(times(&s), vec![(0.0, 2.0), (0.0, 3.5)]); + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 3.0})).unwrap(); + let fd = 1.0 / 30.0; + let t = times(&s); + assert!((t[0].0 - (2.0 - fd)).abs() < 1e-9 && (t[1].0 - 3.0).abs() < 1e-9, "{t:?}"); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 90d86f5e4..4ef3dd2af 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2149,14 +2149,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if frames != 0 { acc -= (frames as f64 * fd * pps) as f32; let d = frames as f64 * fd; + // A selected layer's bar moves or trims every selected layer by the + // same amount (each within its own limits), as in After Effects. + let layers = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; let params = match kind { - "move" => { - json!({"layers": if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }, "delta": d, "merge": drag_key}) - } - "in" => json!({"layers": [layer.id.0], "in": (layer.in_point.seconds() + d).max(0.0), "merge": drag_key}), - _ => { - json!({"layers": [layer.id.0], "out": (layer.out_point.seconds() + d).min(comp.duration.seconds()), "merge": drag_key}) - } + "move" => json!({"layers": layers, "delta": d, "merge": drag_key}), + "in" => json!({"layers": layers, "inDelta": d, "merge": drag_key}), + _ => json!({"layers": layers, "outDelta": d, "merge": drag_key}), }; actions.push(("layer.timing".into(), params)); } diff --git a/crates/ui-egui/tests/ui_timeline_trim.rs b/crates/ui-egui/tests/ui_timeline_trim.rs index d66bf5b7a..634993042 100644 --- a/crates/ui-egui/tests/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/ui_timeline_trim.rs @@ -119,3 +119,37 @@ fn dragging_bar_ends_trims_in_and_out() { let (i, o, s) = timing(&h, pre); assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}"); } + +/// Trimming a selected layer's end trims every selected layer by the same amount (one undo +/// step), as in After Effects; an unselected layer's end trims that layer alone. +#[test] +fn dragging_a_selected_bar_end_trims_every_selected_layer() { + let (mut h, solid, pre) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [solid, pre]})).unwrap(); + h.run_steps(2); + let b = bar(&h, pre); + let pps = bar(&h, solid).width() as f64 / 4.0; + let y = b.center().y; + drag(&mut h, pos2(b.max.x - 2.0, y), pos2(b.max.x - 2.0 + pps as f32, y)); + let ((si, so, _), (pi, po, _)) = (timing(&h, solid), timing(&h, pre)); + assert!((so - 7.0).abs() < 1.5 / 30.0 && (po - 8.0).abs() < 1.5 / 30.0, "both outs ~+1 s: solid {so}, precomp {po}"); + assert!((so - 6.0 - (po - 7.0)).abs() < 1e-9, "the same delta for both"); + assert!((si - 2.0).abs() < 1e-9 && (pi - 3.0).abs() < 1e-9, "in points untouched"); + // The In end too. + let b = bar(&h, solid); + drag(&mut h, pos2(b.min.x + 2.0, b.center().y), pos2(b.min.x + 2.0 + pps as f32 / 2.0, b.center().y)); + let ((si, _, _), (pi, _, _)) = (timing(&h, solid), timing(&h, pre)); + assert!((si - 2.5).abs() < 1.5 / 30.0 && (si - 2.0 - (pi - 3.0)).abs() < 1e-9, "both ins ~+0.5 s: solid {si}, precomp {pi}"); + // One undo step per drag. + h.state_mut().session.undo(); + h.state_mut().session.undo(); + h.run_steps(2); + assert_eq!((timing(&h, solid), timing(&h, pre)), ((2.0, 6.0, 0.0), (3.0, 7.0, 0.0))); + // An unselected layer's end: only it. + h.state_mut().session.execute("layer.select", json!({"layers": [solid]})).unwrap(); + h.run_steps(2); + let b = bar(&h, pre); + drag(&mut h, pos2(b.max.x - 2.0, y), pos2(b.max.x - 2.0 + pps as f32, y)); + assert_eq!(timing(&h, solid), (2.0, 6.0, 0.0), "the selected layer stays"); + assert!((timing(&h, pre).1 - 8.0).abs() < 1.5 / 30.0); +}