From 91b078777edf29bc3da5abb12823ccff72c4c4a6 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:16:06 +0200 Subject: [PATCH] Timeline: trim all selected layers together Dragging the in or out edge of a selected layer's bar now trims every selected layer by the same delta from its own in/out point, as in After Effects. layer.timing gains relative inDelta/outDelta params (seconds, frame-snapped), clamped to the comp start/end and to one frame of layer; the drag stays one undo step via the merge key. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 14 ++++++++- crates/engine/src/tests_timeline.rs | 43 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 ++++---- docs/agents.md | 2 +- docs/architecture.md | 3 ++ 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09bad38f..c49c3f29 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -857,9 +857,13 @@ 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")); + // Relative trims: each layer moves its own in/out point by the same amount, so dragging an + // edge of several selected layers trims them all together (as in AE). + 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 comp_dur = comp.duration; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; @@ -891,6 +895,14 @@ fn timing(s: &mut Session, p: &Value) -> Result { if let Some(o) = outp { l.out_point = o.max(l.in_point + fd); } + // Stops at the comp start/end (a point already past it doesn't jump onto + // it), keeping at least one frame of layer. + 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(comp_dur.max(l.out_point)).max(l.in_point + fd); + } } } } @@ -1530,7 +1542,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? (seconds, per layer), merge?}", has_layers, timing ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b..d6faa352 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -883,3 +883,46 @@ 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); } + +/// Dragging a trim edge with several layers selected trims every selected layer by the same +/// amount from its own in/out point (as in AE), clamped to the comp and to one frame of layer; +/// a whole merged drag is one undo step. +#[test] +fn relative_trim_moves_every_selected_layer_by_the_same_delta() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).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 io = |s: &Session, l: u64| { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap(); + (l.in_point.seconds(), l.out_point.seconds()) + }; + let near = |x: f64, y: f64| (x - y).abs() < 1e-9; + let undo_len = s.history.undo.len(); + // In-point drag in frame steps, merged into one edit. + for _ in 0..3 { + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 0.1, "merge": "bar-trim"})).unwrap(); + } + assert_eq!(s.history.undo.len(), undo_len + 1); + let ((ai, ao), (bi, bo)) = (io(&s, a), io(&s, b)); + assert!(near(ai, 0.8) && near(ao, 2.0) && near(bi, 1.3) && near(bo, 3.5), "{ai} {ao} {bi} {bo}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(near(io(&s, a).0, 0.5) && near(io(&s, b).0, 1.0)); + s.history.merge_key = None; + // Out-point drag: b hits the comp end (4 s) while a keeps going. + s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 1.0})).unwrap(); + assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b)); + // In-point drag left: a stops at the comp start. + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -0.8})).unwrap(); + assert!(near(io(&s, a).0, 0.0) && near(io(&s, b).0, 0.2), "{:?} {:?}", io(&s, a), io(&s, b)); + // Trimming past the other edge keeps one frame of layer. + s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 10.0})).unwrap(); + let fd = 1.0 / 30.0; + assert!(near(io(&s, a).0, 3.0 - fd) && near(io(&s, b).0, 4.0 - fd), "{:?} {:?}", io(&s, a), io(&s, b)); + s.execute("layer.timing", json!({"layers": [a, b], "outDelta": -10.0})).unwrap(); + assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b)); + // A point already outside the comp trims from where it is instead of jumping onto the edge. + s.execute("layer.timing", json!({"layers": [a], "in": -1.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); + assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d..c262cd55 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2108,14 +2108,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; + // Moving or trimming a selected layer acts on the whole + // selection; each layer trims from its own in/out point. + let targets = 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": targets, "delta": d, "merge": drag_key}), + "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), + _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}), }; actions.push(("layer.timing".into(), params)); } diff --git a/docs/agents.md b/docs/agents.md index 6a3b9e85..326d5b3b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -144,7 +144,7 @@ carries outside-range warnings; the existing objects from `keys.set` and `prop.g also report both bases and warnings. `prop.get.time` is comp seconds. Separated Position `prop.set` retains per-axis replies; for these commands, `path` or `prop` identifies a property, not a group. -Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds. +Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds; `inDelta` / `outDelta` trim each layer from its own in/out point. For an unstretched layer starting at 10.5 s in a 30 fps comp, `prop.addKey {layer: "A", path: "transform/opacity", time: 15.3, timeBase: "comp", value: 0}` writes a key at comp 15.3 s (layer 4.8 s), matching diff --git a/docs/architecture.md b/docs/architecture.md index 9e6fc0e7..f4e53078 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -388,6 +388,9 @@ their on-screen scale, as text is vector in After Effects; on shape layers and v switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points. +Dragging a layer bar's in or out edge with several layers selected trims every selected layer by +the same amount from its own in/out point (`layer.timing {inDelta | outDelta}`), clamped to the +comp and to one frame of layer, as one undo step. **Nested comps** show nothing before they start or after they end (`Comp::covers`), even when the precomp layer is trimmed or remapped past them. A comp with **Preserve frame rate when nested