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)
This commit is contained in:
Elias
2026-10-11 12:29:56 +00:00
committed by Claude
parent 7731283797
commit ad0492b056
4 changed files with 81 additions and 8 deletions
+12 -1
View File
@@ -857,10 +857,21 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),
+29
View File
@@ -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:?}");
}
+6 -7
View File
@@ -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::<Vec<_>>()) } else { json!([layer.id.0]) };
let params = match kind {
"move" => {
json!({"layers": if is_sel { json!(selected.iter().map(|l| l.0).collect::<Vec<_>>()) } 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));
}
+34
View File
@@ -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);
}