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