Merge pull request #950 from Themolx/trim-selected-layers

Timeline: trim all selected layers together

# Conflicts:
#	crates/engine/src/tests_timeline.rs
This commit is contained in:
Brandon Thomas
2026-10-12 04:21:07 -04:00
5 changed files with 66 additions and 9 deletions
+13 -1
View File
@@ -897,9 +897,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;
@@ -931,6 +935,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);
}
}
}
}
@@ -1595,7 +1607,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
@@ -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));
}
+6 -7
View File
@@ -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::<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
@@ -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
+3
View File
@@ -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