diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 787566de..d4cf1d90 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -897,9 +897,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; @@ -931,6 +935,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); + } } } } @@ -1595,7 +1607,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 53ebd52c..009af9e5 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -1077,3 +1077,46 @@ fn go_to_time_accepts_a_float_frame_from_the_dialog() { let r = s.execute("time.set", json!({"frame": 7})).unwrap(); assert_eq!(r["frame"], json!(7)); } + +/// 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 3a80dbe1..aa597d90 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2135,14 +2135,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 928ddd26..fa9f5488 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -145,7 +145,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 9240193b..bb61c6d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -391,6 +391,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