From 28e9e5229ecb3069c5152a579ad6ceec57ca1034 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:31 -0400 Subject: [PATCH] Quick Offset: layer.quickOffset and keys.quickOffset commands (#1012) After Effects 25.4's Quick Offset staggers selected layers, or each layer's selected keyframes, in the order they were selected: the first stays, the last moves by the drag and the ones between by equal steps, landing on subframes. layer.quickOffset {delta, layers?} moves each layer's start, in and out point by total * i / (n - 1), worked out once in ticks with integer arithmetic, so 1 frame over 4 layers gives 0, 1/3, 2/3 and 1 frame exactly. keys.quickOffset {delta} does the same per layer for the selected keys, the layers in the order their first key was selected; every selected key of a layer, across its properties, moves by that layer's comp-time offset through its start and stretch (reversed too), keeping values, interpolation, easing and labels. Locked layers are skipped and at least two layers are needed. With merge and fromStart, delta is the whole drag and each step starts again from before it, so the drag is one undo step and a key passed over survives. A delta or a destination out of the range of times is an error, not a clamped move. Docs: agents.md; architecture.md notes Quick Offset as the exception to times snapping to frames. --- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/quick_offset.rs | 144 ++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_quick_offset.rs | 254 +++++++++++++++++++++ docs/agents.md | 6 +- docs/architecture.md | 4 +- 6 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/commands/quick_offset.rs create mode 100644 crates/engine/src/tests_quick_offset.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642..fc027c7b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -49,6 +49,7 @@ mod prop_groups; mod proxy; pub mod puppet; mod query; +mod quick_offset; mod relink; mod render_queue; pub(crate) mod rig3d; @@ -190,6 +191,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(focus::specs()); v.extend(key_labels::specs()); v.extend(key_transform::specs()); + v.extend(quick_offset::specs()); v.extend(file_more::specs()); v.extend(relink::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs new file mode 100644 index 00000000..aeda6c78 --- /dev/null +++ b/crates/engine/src/commands/quick_offset.rs @@ -0,0 +1,144 @@ +//! Quick Offset (After Effects 25.4): Ctrl/Cmd+Alt dragging selected layers or keyframes in the +//! Timeline staggers them in the order they were selected. The first stays, the last moves by the +//! whole drag and the ones between spread evenly, so they land on subframes; each layer's +//! selected keys move together. + +use std::collections::HashMap; + +use effectcraft_project::LayerId; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use super::prop::{key_gesture, move_key_refs}; +use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// `delta` (comp seconds) in ticks; out of range is an error, not a clamped drag. +fn delta_p(p: &Value, name: &str) -> Result { + let d = f_p(p, "delta").ok_or_else(|| bad(name, "missing `delta` (comp seconds)"))?; + let t = (d * TICKS_PER_SECOND as f64).round(); + if !t.is_finite() || t.abs() > Tick::MAX.0 as f64 { + return Err(bad(name, "`delta` is out of range")); + } + Ok(Tick(t as i64)) +} + +/// How far each of `n` layers moves, in order, when the last moves by `total`: `total × i / (n − 1)` +/// to the nearest tick, so the steps between them are equal. +fn shares(total: Tick, n: usize) -> Vec { + let last = n.saturating_sub(1).max(1) as i128; + (0..n) + .map(|i| { + let x = total.0 as i128 * i as i128; + Tick(((x + x.signum() * (last / 2)) / last) as i64) + }) + .collect() +} + +/// `t + d`, or an error (`what` names the time) when that leaves the range of times. +fn shifted(t: Tick, d: Tick, name: &str, what: &str) -> Result { + let v = t.0 as i128 + d.0 as i128; + if v < Tick::MIN.0 as i128 || v > Tick::MAX.0 as i128 { + return Err(bad(name, format!("{what} would move out of range"))); + } + Ok(Tick(v as i64)) +} + +/// The layers to stagger, in order: unlocked, each once. At least two. +fn order(name: &str, layers: impl IntoIterator, unlocked: impl Fn(LayerId) -> bool) -> Result> { + let mut out: Vec = vec![]; + for l in layers { + if unlocked(l) && !out.contains(&l) { + out.push(l); + } + } + if out.len() < 2 { + return Err(bad(name, "select at least two unlocked layers (or keys on two layers), in the order to offset them")); + } + Ok(out) +} + +/// `layer.quickOffset`: move the layers (`layers`, else the selection, in that order) by their +/// share of `delta`, the last layer's move. With `merge` and `fromStart`, `delta` is the whole drag +/// and every step applies to the layers as they were before it. +fn layers(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "layer.quickOffset"; + let d = delta_p(p, NAME)?; + let (cid, ids) = layers_p(s, p)?; + let merge = merge_p(p); + let gesture = b_p(p, "fromStart").unwrap_or(false) && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)); + let base = s.history.undo.last().filter(|_| gesture).map(|(_, b)| b.clone()); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, ids, |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let moves: Vec<(LayerId, Tick)> = order.iter().copied().zip(shares(d, order.len())).collect(); + s.edit("Quick Offset", merge, |proj, _| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &moves { + let l = comp.layer_mut(*id).ok_or(EngineError::NoComp)?; + let what = format!("layer \"{}\"", l.name); + let times = (shifted(l.start_time, *d, NAME, &what)?, shifted(l.in_point, *d, NAME, &what)?, shifted(l.out_point, *d, NAME, &what)?); + (l.start_time, l.in_point, l.out_point) = times; + } + Ok(json!({"layers": moves.iter().map(|(id, d)| json!({"layer": id.0, "offset": d.seconds()})).collect::>()})) + }) +} + +/// `keys.quickOffset`: the selected keys of each layer move together by the layer's share of +/// `delta` (comp seconds, the last layer's move), the layers in the order their first key was +/// selected. Like `keys.move`, steps sharing `merge` are one drag; with `fromStart`, `delta` is the +/// whole drag. +fn keys(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "keys.quickOffset"; + let d = delta_p(p, NAME)?; + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let merge = merge_p(p); + let (from, total, base) = key_gesture(s, merge, d, b_p(p, "fromStart").unwrap_or(false)); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let by_layer: HashMap = order.iter().copied().zip(shares(total, order.len())).collect(); + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + s.edit("Quick Offset Keyframes", merge, |proj, st| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + st.selected_keys = move_key_refs(comp, &from, |l, t| { + let Some(d) = by_layer.get(&l.id) else { return Ok(t) }; + let what = format!("a key of \"{}\"", l.name); + let at = l.layer_time(shifted(l.comp_time(t), *d, NAME, &what)?); + if at < Tick::MIN || at > Tick::MAX { + return Err(bad(NAME, format!("{what} would move out of range"))); + } + Ok(at) + })?; + Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.quickOffset", + "Quick Offset", + [], + None, + "{delta (comp s: the last layer's move), layers? (in order; default the selection), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_layers, + layers + ), + cmd!( + "keys.quickOffset", + "Quick Offset Keyframes", + [], + None, + "{delta (comp s: the last layer's keys' move), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_keys, + keys + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44..787df4f7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1158,6 +1158,8 @@ mod tests_project_items; #[cfg(test)] mod tests_proxy; #[cfg(test)] +mod tests_quick_offset; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs new file mode 100644 index 00000000..7ccf75f1 --- /dev/null +++ b/crates/engine/src/tests_quick_offset.rs @@ -0,0 +1,254 @@ +//! Quick Offset (#1012): `layer.quickOffset` and `keys.quickOffset` stagger layers and each layer's +//! selected keys in selection order, onto subframes, as one undo step. + +use effectcraft_keyframe::Interp; +use effectcraft_project::{Layer, LayerId}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::{KeyRef, Session}; + +/// One frame at 30 fps. +const F: i64 = 8_467_200_000; + +fn setup(n: usize) -> (Session, Vec) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + let ids = + (0..n).map(|i| s.execute_checked("layer.newSolid", json!({"name": format!("L{i}"), "color": "#fff"})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> &Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap() +} + +/// (start, in, out) in ticks. +fn timing(s: &Session, id: u64) -> (i64, i64, i64) { + let l = layer(s, id); + (l.start_time.0, l.in_point.0, l.out_point.0) +} + +/// Click the first, Ctrl/Cmd-click the rest. +fn select(s: &mut Session, ids: &[u64]) { + for (i, id) in ids.iter().enumerate() { + s.execute_checked("layer.select", json!({"layers": [id], "add": i > 0})).unwrap(); + } +} + +fn add_key(s: &mut Session, id: u64, path: &str, seconds: f64, value: Value) { + s.execute_checked("prop.addKey", json!({"layer": id, "path": path, "time": seconds, "value": value})).unwrap(); +} + +fn select_keys(s: &mut Session, keys: &[(u64, &str, f64)]) { + for (i, (id, path, t)) in keys.iter().enumerate() { + s.execute_checked("keys.select", json!({"keys": [{"layer": id, "path": path, "time": t}], "add": i > 0})).unwrap(); + } +} + +/// The stored key times (ticks) of a property. +fn key_times(s: &Session, id: u64, path: &str) -> Vec { + layer(s, id).props.prop(path).unwrap().keys.iter().map(|k| k.time.0).collect() +} + +fn uid(s: &Session, id: u64, path: &str) -> u64 { + layer(s, id).props.prop(path).unwrap().uid +} + +#[test] +fn layers_stagger_in_selection_order_onto_subframes_as_one_undo() { + let (mut s, ids) = setup(4); + let order = [ids[2], ids[0], ids[3], ids[1]]; + select(&mut s, &order); + let before = s.project.clone(); + let reply = s.execute_checked("layer.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap(); + // The first selected stays, the last moves the whole frame, the ones between a third each. + for (i, id) in order.iter().enumerate() { + let d = F * i as i64 / 3; + let (st, inp, out) = timing(&s, *id); + assert_eq!((st, inp, out - inp), (d, d, 10 * 30 * F), "layer {i} in selection order"); + } + assert_eq!(reply["layers"].as_array().unwrap().len(), 4); + // Negative drags mirror it. + s.execute_checked("layer.quickOffset", json!({"delta": -2.0 / 30.0})).unwrap(); + for (i, id) in order.iter().enumerate() { + assert_eq!(timing(&s, *id).0, -F * i as i64 / 3, "layer {i} after dragging back two frames"); + } + s.execute_checked("edit.undo", json!({})).unwrap(); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "two offsets, two undo steps"); + s.execute_checked("edit.redo", json!({})).unwrap(); + assert_eq!(timing(&s, order[3]).0, F); +} + +#[test] +fn a_drag_restarts_from_the_layers_before_it_and_is_one_undo_step() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // An offset layer, a stretched one and a reversed one move their whole timing; their keys + // (in layer time) come along untouched. + s.execute_checked("layer.timing", json!({"layers": [ids[0]], "start": 1.0})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + add_key(&mut s, ids[2], "transform/opacity", 1.0, json!(50)); + let keys = key_times(&s, ids[2], "transform/opacity"); + let start: Vec<_> = ids.iter().map(|id| timing(&s, *id)).collect(); + let before = s.project.clone(); + for frames in [2.0, 5.0, -3.0, 2.0] { + s.execute_checked("layer.quickOffset", json!({"delta": frames / 30.0, "merge": "qo", "fromStart": true})).unwrap(); + } + for (i, id) in ids.iter().enumerate() { + let d = 2 * F * i as i64 / 2; + let (a, b, c) = start[i]; + assert_eq!(timing(&s, *id), (a + d, b + d, c + d), "layer {i}: only the last step's whole drag counts"); + } + assert_eq!(key_times(&s, ids[2], "transform/opacity"), keys); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "the drag is one undo step"); +} + +#[test] +fn locked_layers_bad_arguments_and_out_of_range_moves_change_nothing() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // A locked layer is skipped: the other two are first and last. + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("layer.quickOffset", json!({"layers": ids, "delta": 0.1})).unwrap(); + assert_eq!((timing(&s, ids[0]).0, timing(&s, ids[1]).0), (0, 0)); + assert_eq!(timing(&s, ids[2]).0, Tick::from_seconds_f64(0.1).0); + let before = (s.project.clone(), s.revision); + for p in [ + json!({"layers": [ids[0]], "delta": 0.1}), + json!({"layers": [ids[0], ids[1]], "delta": 0.1}), + json!({"layers": ids}), + json!({"layers": ids, "delta": "far"}), + json!({"layers": ids, "delta": 1e12}), + json!({"layers": [ids[0], 999], "delta": 0.1}), + ] { + assert!(s.execute_checked("layer.quickOffset", p.clone()).is_err(), "{p}"); + } + assert!(s.project.same_content(&before.0) && s.revision == before.1, "refused calls change nothing"); + // The last layer's destination is past the end of time: nothing moves. + s.execute_checked("layer.timing", json!({"layers": [ids[2]], "start": 9.0e6})).unwrap(); + let before = s.project.clone(); + assert!(s.execute_checked("layer.quickOffset", json!({"layers": [ids[0], ids[2]], "delta": 1.0e5})).is_err()); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_stagger_per_layer_in_the_order_their_layers_were_selected() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + add_key(&mut s, ids[0], "transform/position", 0.5, json!([10, 20])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/opacity", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"interpolation": "hold"})).unwrap(); + // Layer 1's keys first, then layer 0's two properties, then layer 2's. + let picks = [ + (ids[1], "transform/opacity", 0.0), + (ids[0], "transform/opacity", 1.0), + (ids[1], "transform/opacity", 1.0), + (ids[0], "transform/position", 0.5), + (ids[2], "transform/opacity", 1.0), + ]; + select_keys(&mut s, &picks); + let before = s.project.clone(); + let reply = s.execute_checked("keys.quickOffset", json!({"delta": 3.0 / 30.0})).unwrap(); + assert_eq!(reply["keys"], 5); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[1], "transform/opacity"), vec![0, sec(1.0)], "the first layer stays"); + assert_eq!(key_times(&s, ids[0], "transform/opacity"), vec![0, sec(1.0) + 3 * F / 2], "the middle layer: 1.5 frames"); + assert_eq!(key_times(&s, ids[0], "transform/position"), vec![sec(0.5) + 3 * F / 2], "both its properties move as one group"); + assert_eq!(key_times(&s, ids[2], "transform/opacity"), vec![0, sec(1.0) + 3 * F], "the last layer: the whole drag"); + // The keys keep their values and interpolation, and the selection its order. + let moved = layer(&s, ids[0]).props.prop("transform/opacity").unwrap().keys[1].clone(); + assert_eq!((moved.value.components(), moved.out_interp), (vec![100.0], Interp::Hold)); + let order: Vec<(u64, u64)> = s.state.selected_keys.iter().map(|k| (k.layer.0, k.prop)).collect(); + let want: Vec<(u64, u64)> = picks.iter().map(|(id, path, _)| (*id, uid(&s, *id, path))).collect(); + assert_eq!(order, want); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_of_stretched_and_reversed_layers_follow_the_comp_time_offset() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 1.0, json!(10)); + } + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + let comp_time = |s: &Session, id: u64| { + let l = layer(s, id); + l.comp_time(l.props.prop("transform/opacity").unwrap().keys[0].time).0 + }; + let before: Vec = ids.iter().map(|id| comp_time(&s, *id)).collect(); + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("keys.quickOffset", json!({"delta": 4.0 / 30.0})).unwrap(); + for (i, id) in ids.iter().enumerate() { + let moved = comp_time(&s, *id) - before[i]; + assert!((moved - 2 * F * i as i64).abs() <= 1, "layer {i}: moved {moved} ticks in comp time"); + } +} + +#[test] +fn a_key_drag_over_another_key_and_back_keeps_it() { + let (mut s, ids) = setup(2); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + } + add_key(&mut s, ids[1], "transform/opacity", 0.5, json!(50)); + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[1], "transform/opacity", 0.0)]); + let before = s.project.clone(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.5, "merge": "qk", "fromStart": true})).unwrap(); + assert_eq!(key_times(&s, ids[1], "transform/opacity").len(), 1, "the dragged key replaces the one it lands on"); + s.execute_checked("keys.quickOffset", json!({"delta": 0.0, "merge": "qk", "fromStart": true})).unwrap(); + assert!(s.project.same_content(&before), "dragged back, the key passed over is there again"); + assert_eq!(s.state.selected_keys.len(), 2); +} + +#[test] +fn keys_need_two_unlocked_layers() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[0], "transform/opacity", 1.0)]); + let before = s.project.clone(); + assert!(s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).is_err(), "keys on one layer"); + // Locked after selecting: that layer's keys stay; the other two still stagger. + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).unwrap(); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[0], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[1], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[2], "transform/opacity")[1], sec(1.1)); + assert!(s.state.selected_keys.contains(&KeyRef { layer: LayerId(ids[1]), prop: uid(&s, ids[1], "transform/opacity"), time: Tick(sec(1.0)) })); + assert!(!before.same_content(&s.project)); +} + +#[test] +fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { + let (mut s, ids) = setup(2); + for (t, x) in [(0.0, 0), (1.0, 50), (2.0, 100)] { + add_key(&mut s, ids[0], "transform/position", t, json!([x, 0])); + } + add_key(&mut s, ids[1], "transform/position", 2.0, json!([0, 0])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/position", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"roving": true})).unwrap(); + // Only the last key of the roving layer is selected, with a key on the other layer first. + select_keys(&mut s, &[(ids[1], "transform/position", 2.0), (ids[0], "transform/position", 2.0)]); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0})).unwrap(); + let pos = layer(&s, ids[0]).props.prop("transform/position").unwrap(); + let times: Vec = pos.keys.iter().map(|k| k.time.0).collect(); + assert_eq!((times[0], times[2]), (0, Tick::from_seconds_f64(3.0).0)); + assert!(times[1] > 0 && times[1] < times[2], "the roving key retimed between its neighbours: {times:?}"); + for k in &s.state.selected_keys { + assert!(layer(&s, k.layer.0).props.find(k.prop).unwrap().keys.iter().any(|x| x.time == k.time), "{k:?} points at a key"); + } + assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), vec![ids[1], ids[0]]); +} diff --git a/docs/agents.md b/docs/agents.md index 01738edd..b63e69d7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -248,7 +248,11 @@ build real projects through these interfaces; they are worked examples of everyt / `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are one drag: each applies to the keys as they were before it, so a key passed over survives) and `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag - started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` + started). **Quick Offset** (Ctrl/Cmd+Alt drag in the Timeline): `layer.quickOffset {delta, + layers?}` and `keys.quickOffset {delta}` stagger the selected layers, or each layer's selected + keys together, in selection order: the first stays, the last moves by `delta` (comp seconds) and + the ones between by equal steps, onto subframes; `merge` + `fromStart: true` make `delta` the whole + drag. Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` at the current time (keys copied from several layers go to as many selected layers in order). `time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). diff --git a/docs/architecture.md b/docs/architecture.md index 72d8b734..e5564c22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,9 @@ All time is an integer `Tick`, 254 016 000 000 per second, the least common mult broadcast frame rate (×1001) and audio sample rate, so frame and sample positions are exact. Keyframe times are in **layer time**; `layer_time = (comp_time - start_time) / stretch`, and a Time Remap property overrides the source time. Commands snap layer times, keyframe times and -comp durations to whole frames, as After Effects does. Timecode is only a display. +comp durations to whole frames, as After Effects does, except Quick Offset (`layer.quickOffset`, +`keys.quickOffset`), which staggers layers and keys onto subframes, as After Effects 25.4 does. +Timecode is only a display. ## 3. Document model