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 <[email protected]>
This commit is contained in:
ThemolxandClaude Opus 5.5 committed 2026-10-12 03:16:06 +02:00
1 parent 67434e9038
commit 91b078777e
5 files changed
+66 -9

No files matched your search

+13 -1
View File
@@ -857,9 +857,13 @@ 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"));
// 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<Value> {
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<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? (seconds, per layer), merge?}",
has_layers,
timing
),
+43
View File
@@ -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));
}
+6 -7
View File
@@ -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::<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": 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));
}
+1 -1
View File
@@ -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
+3
View File
@@ -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