mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
67434e9038
commit
91b078777e
5 files changed
+66
-9
No files matched your search
@@ -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
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user