Merge pull request #1144 from tylerjenningsw/enhance/quick-offset

# Conflicts:
#	crates/ui-egui/src/i18n/ui.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:28:43 -04:00
commit 4f57d5d73d
12 files changed
+896 -68

No files matched your search

+2
View File
@@ -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());
+85 -54
View File
@@ -862,67 +862,98 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
})
}
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
/// locked layers stay. A key landing on another key of its property replaces it.
/// The keys a key drag's step applies to, the drag's total and the project as it was before the
/// drag. Steps of one drag (one `merge` key) start again from the keys as they were then, so a key
/// passed over survives; `d` adds to the drag's total, or is that total (`absolute`).
pub(crate) fn key_gesture(
s: &Session,
merge: Option<&str>,
d: Tick,
absolute: bool,
) -> (Vec<KeyRef>, Tick, Option<std::sync::Arc<effectcraft_project::Project>>) {
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, base))) if gesture => (g.from.clone(), if absolute { d } else { g.total + d }, Some(base.clone())),
_ => (s.state.selected_keys.clone(), d, None),
}
}
/// Move keys `from` to the layer times `to` gives (from a key's layer and layer time). A key
/// landing on another key of its property replaces it, roving keys follow their neighbours and
/// keys of locked layers stay. Returns the keys' new references in `from`'s order: the selection
/// keeps the order it was made in.
pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef], to: impl Fn(&Layer, Tick) -> Result<Tick>) -> Result<Vec<KeyRef>> {
let mut remapped: std::collections::HashMap<KeyRef, KeyRef> = Default::default();
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in from {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let at = |time| KeyRef { layer: lid, prop: uid, time };
if l.switches.locked {
remapped.extend(times.iter().map(|t| (at(*t), at(*t))));
continue;
}
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| Ok((*t, to(l, *t)?))).collect::<Result<_>>()?;
let Some(pr) = l.props.find_mut(uid) else { continue };
// Take the moving keys out, then put them back at their new times.
let mut moved = vec![];
for (old, new) in &moves {
if let Some(i) = key_at(&pr.keys, *old) {
let mut k = pr.keys.remove(i);
k.time = *new;
moved.push((*old, k));
}
}
// Target time -> the key's time before the move.
let mut targets: std::collections::HashMap<Tick, Tick> = Default::default();
for (old, k) in moved {
targets.insert(k.time, old);
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(j) => pr.keys[j] = k,
Err(j) => pr.keys.insert(j, k),
}
}
// Resolve stored indices before roving keys change their times.
let tracked: Vec<(Tick, usize)> = targets.into_iter().filter_map(|(t, old)| key_at(&pr.keys, t).map(|i| (old, i))).collect();
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
for (old, i) in tracked {
if let Some(key) = pr.keys.get(i) {
remapped.insert(at(old), at(key.time));
}
}
}
let mut seen = std::collections::HashSet::new();
Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect())
}
/// A move of `d` in comp time as the move of `l`'s layer times (through its stretch, reversed
/// too), without a round trip through comp time.
pub(crate) fn layer_shift(l: &Layer, d: Tick) -> Tick {
l.layer_time(l.start_time + d)
}
/// Move the selected keys by `d` comp seconds, rounded to whole frames: the keys keep their
/// spacing, subframe offsets (Quick Offset) included, and keys of time-stretched layers move with
/// the pointer, not by the layer-time distance. Keys of locked layers stay. A key landing on
/// another key of its property replaces it.
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
// A drag (steps with one merge key) starts again from the keys before it each step.
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
_ => (s.state.selected_keys.clone(), d, None),
};
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
s.edit(label, merge, |proj, st| {
let (from, total, base) = key_gesture(s, merge, d, false);
let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
let moved = s.edit(label, merge, |proj, st| {
if let Some(b) = base {
*proj = (*b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &from {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
let mut sel = vec![];
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
if l.switches.locked {
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
continue;
}
let Some(pr) = l.props.find_mut(uid) else { continue };
// Take the moving keys out, then put them back at their new times.
let mut moved = vec![];
for (old, new) in &moves {
if let Some(i) = key_at(&pr.keys, *old) {
let mut k = pr.keys.remove(i);
k.time = *new;
moved.push(k);
}
}
for k in moved {
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(j) => pr.keys[j] = k,
Err(j) => pr.keys.insert(j, k),
}
}
// Roving keys follow their neighbours: keep the selection on them.
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
if let Some(i) = before.iter().position(|t| *t == r.time) {
r.time = pr.keys[i].time;
}
}
}
}
st.selected_keys = sel;
let d = comp.frame_rate.snap_nearest(total);
st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(t + layer_shift(l, d)))?;
Ok(json!(st.selected_keys.len()))
})
})?;
// Only a step that applied counts towards the drag.
s.key_move = next;
Ok(moved)
}
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
+144
View File
@@ -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, layer_shift, 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<Tick> {
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<Tick> {
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<Tick> {
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<Item = LayerId>, unlocked: impl Fn(LayerId) -> bool) -> Result<Vec<LayerId>> {
let mut out: Vec<LayerId> = 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<Value> {
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::<Vec<_>>()}))
})
}
/// `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<Value> {
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<LayerId, Tick> = order.iter().copied().zip(shares(total, order.len())).collect();
let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
let moved = 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);
shifted(l.start_time, *d, NAME, &what)?;
shifted(t, layer_shift(l, *d), NAME, &what)
})?;
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::<Vec<_>>()}))
})?;
// Only a step that applied counts towards the drag (a refused one is as if never sent).
s.key_move = next;
Ok(moved)
}
pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
+2
View File
@@ -1175,6 +1175,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;
@@ -111,3 +111,16 @@ fn attribute_edit_deduplicates_legacy_refs_and_drops_stale_refs_without_snapping
let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap();
assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Linear));
}
#[test]
fn moving_keys_keeps_the_selection_order() {
let (mut s, first) = setup();
let second = s.execute_checked("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for layer in [first, second] {
add(&mut s, layer, "transform/opacity", 0, json!(0));
}
let (a, b) = (uid(&s, first, "transform/opacity"), uid(&s, second, "transform/opacity"));
s.execute_checked("keys.select", json!({"keys": [{"layer": second, "prop": b, "time": 0}, {"layer": first, "prop": a, "time": 0}]})).unwrap();
s.execute_checked("keys.move", json!({"delta": 2.0 / 30.0})).unwrap();
assert_eq!(s.state.selected_keys, vec![key(second, b, 2), key(first, a, 2)], "not sorted by layer");
}
+287
View File
@@ -0,0 +1,287 @@
//! 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<u64>) {
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<i64> {
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<i64> = ids.iter().map(|id| comp_time(&s, *id)).collect();
select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::<Vec<_>>());
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::<Vec<_>>());
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<i64> = 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<_>>(), vec![ids[1], ids[0]]);
}
#[test]
fn moving_staggered_keys_keeps_their_subframe_spacing() {
let (mut s, ids) = setup(4);
for id in &ids {
add_key(&mut s, *id, "transform/opacity", 0.0, json!(0));
}
select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 0.0)).collect::<Vec<_>>());
s.execute_checked("keys.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap();
// A plain move (and a nudge) translates them: it used to put each key on its nearest frame.
s.execute_checked("keys.move", json!({"delta": 1.0 / 30.0})).unwrap();
s.execute_checked("keys.nudgeForward", json!({})).unwrap();
for (i, id) in ids.iter().enumerate() {
assert_eq!(key_times(&s, *id, "transform/opacity"), vec![2 * F + F * i as i64 / 3], "layer {i}");
}
}
#[test]
fn a_refused_step_does_not_count_towards_the_drag() {
let (mut s, ids) = setup(2);
for id in &ids {
add_key(&mut s, *id, "transform/opacity", 1.0, json!(0));
}
select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::<Vec<_>>());
let before = s.project.clone();
// The second step's last key would land past the end of time (one second short of it, its
// own delta isn't); with it counted, the third would still move the key that far.
let far = (Tick::MAX.seconds() - 1.0).floor();
s.execute_checked("keys.quickOffset", json!({"delta": 1.0, "merge": "steps"})).unwrap();
assert!(s.execute_checked("keys.quickOffset", json!({"delta": far, "merge": "steps"})).is_err());
s.execute_checked("keys.quickOffset", json!({"delta": -1.0, "merge": "steps"})).unwrap();
assert!(s.project.same_content(&before), "only the steps that applied count: {:?}", key_times(&s, ids[1], "transform/opacity"));
}
+12
View File
@@ -622,6 +622,8 @@ const JAPANESE: &[(&str, &str)] = &[
("After Effects project", "After Effectsプロジェクト"),
("Lottie animation", "Lottieアニメーション"),
("Project", "プロジェクト"),
("Total Offset", "合計オフセット"),
("Per Layer", "レイヤーごと"),
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
@@ -1344,6 +1346,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Match System", "跟随系统"),
("Default", "默认"),
("Stored, but not used by EffectCraft yet", "已保存,但 EffectCraft 尚未使用"),
("Total Offset", "总偏移"),
("Per Layer", "每个图层"),
];
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
@@ -2061,6 +2065,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Match System", "跟隨系統"),
("Default", "預設"),
("Stored, but not used by EffectCraft yet", "已儲存,但 EffectCraft 尚未使用"),
("Total Offset", "總偏移"),
("Per Layer", "每個圖層"),
];
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
@@ -2618,6 +2624,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
("After Effects project", "Проєкт After Effects"),
("Lottie animation", "Анімація Lottie"),
("Project", "Проєкт"),
("Total Offset", "Загальний зсув"),
("Per Layer", "На шар"),
];
// Brazilian Portuguese.
@@ -3052,6 +3060,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"),
("▣ {}", "▣ {}"),
("⚠ {} is used by:", "⚠ {} é usado por:"),
("Total Offset", "Deslocamento total"),
("Per Layer", "Por camada"),
];
/// Czech (cs): original translations covering every row in the Chinese panel catalogs.
@@ -3587,6 +3597,8 @@ const CZECH: &[(&str, &str)] = &[
"Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.",
),
("Variable fonts only", "Jen proměnná písma"),
("Total Offset", "Celkový posun"),
("Per Layer", "Na vrstvu"),
];
#[cfg(test)]
+98 -11
View File
@@ -1127,18 +1127,62 @@ fn layer_drag_id() -> egui::Id {
}
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
/// (seconds) and how many frames the selected keys have moved so far.
/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset
/// (Ctrl/Cmd+Alt), how many layers it staggers.
#[derive(Clone, Copy, Debug)]
struct KeyDrag {
origin_x: f32,
anchor: f64,
applied: i64,
quick: Option<usize>,
}
fn key_drag_id() -> egui::Id {
egui::Id::new("tl-key-drag")
}
/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where
/// the pointer started, how many layers it staggers and how many frames the last one has moved.
#[derive(Clone, Copy, Debug)]
struct QuickBar {
layer: LayerId,
origin_x: f32,
layers: usize,
applied: i64,
}
fn quick_bar_id() -> egui::Id {
egui::Id::new("tl-quick-bar")
}
/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if
/// there are at least two.
fn quick_offset_layers(comp: &Comp, layers: impl IntoIterator<Item = LayerId>) -> Option<usize> {
let mut seen: Vec<LayerId> = vec![];
for id in layers {
if !seen.contains(&id) && comp.layer(id).is_some_and(|l| !l.switches.locked) {
seen.push(id);
}
}
(seen.len() >= 2).then_some(seen.len())
}
/// A Quick Offset's feedback: the last layer's move and the step from one layer to the next.
fn quick_offset_note(frames: i64, layers: usize) -> String {
let per = frames as f64 / layers.saturating_sub(1).max(1) as f64;
format!("{}: {:+} f {}: {:+.2} f", tr("Total Offset"), frames, tr("Per Layer"), per)
}
/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel).
fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) {
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
fg.rect_filled(r, 3.0, t.panel_bg);
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
}
/// A switch pressed and dragged over other rows: the switch, the state the press gave it, and
/// the undo step the drag folds into.
#[derive(Clone, Debug)]
@@ -2136,7 +2180,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions);
let drag_key = format!("bar-{}", layer.id.0);
// Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
// drag starts and followed below the rows until the button is released.
let mods = ui.input(|i| i.modifiers);
if body.drag_started()
&& is_sel
&& mods.command
&& mods.alt
&& let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
}
let quick = ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())).is_some_and(|q| q.layer == layer.id);
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
if kind == "move" && quick {
continue;
}
if resp.dragged() {
let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind));
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
@@ -2586,6 +2646,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
// Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
let mods = ui.input(|i| i.modifiers);
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
if kresp.clicked() && mods.command {
@@ -2680,7 +2742,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
});
// Alt-drag the first or last key of a selected group: scale the group in time.
if kresp.drag_started()
&& ui.input(|i| i.modifiers.alt)
&& mods.alt
&& !mods.command
&& let Some(c) = app.session.active_comp_arc()
{
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
@@ -2693,7 +2756,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& !super::graph_tools::alt_scale_active(&ctx)
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
// Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
}
}
}
@@ -2877,6 +2942,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
}
}
// Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
// until the button is released, wherever the bar's row has scrolled to.
if let Some(mut q) = ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())) {
let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
match ptr.filter(|_| down) {
Some(ptr) => {
let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64;
if frames != q.applied {
q.applied = frames;
ctx.data_mut(|d| d.insert_temp(quick_bar_id(), q));
let merge = format!("bar-quick-{}", q.layer.0);
actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true})));
}
drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers));
}
None => {
ctx.data_mut(|d| d.remove::<QuickBar>(quick_bar_id()));
ui_actions.push(UiAct::EndMerge);
}
}
}
// Keyframe drag: the selected keys move by whole frames with the pointer until it is
// released (one undo step); Shift snaps the grabbed key to the current time, the work area
// and composition markers.
@@ -2897,7 +2983,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let total = ((target - kd.anchor) / fd).round() as i64;
if total != kd.applied {
actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})));
actions.push(match kd.quick {
Some(_) => ("keys.quickOffset".into(), json!({"delta": total as f64 * fd, "merge": "key-quick-offset", "fromStart": true})),
None => ("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})),
});
kd.applied = total;
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
}
@@ -2905,13 +2994,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Where the grabbed key is and how far the keys moved (After Effects shows this
// in the Info panel).
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied);
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
fg.rect_filled(r, 3.0, t.panel_bg);
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
let note = match kd.quick {
Some(n) => quick_offset_note(kd.applied, n),
None => format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied),
};
drag_tip(&ctx, &t, ptr, note);
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
}
None => {
+243
View File
@@ -0,0 +1,243 @@
//! Quick Offset in the Timeline (#1012): Ctrl/Cmd+Alt dragging selected layer bars or keys
//! staggers the layers in selection order, onto subframes, as one undo step; a plain drag still
//! moves them together and Alt alone still scales a key group.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_engine::time::Tick;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// One frame at 30 fps.
const F: i64 = 8_467_200_000;
/// A 4 s, 30 fps comp with three solids, each with Opacity keys at 0 s and 1 s, revealed.
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Stagger", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids = vec![];
for i in 0..3 {
let id =
s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
ids.push(id);
}
s.execute("layer.select", json!({"layers": ids})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
h.run_steps(4);
(h, ids)
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
/// Press at `from`, move to `to` in small steps with `modifiers` held (a real drag), release.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for i in 1..=24 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default()));
h.run_steps(2);
}
fn ctrl_alt() -> Modifiers {
Modifiers { alt: true, ..Modifiers::COMMAND }
}
fn start(h: &Harness<'_, EffectcraftApp>, id: u64) -> i64 {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().start_time.0
}
/// The comp times (ticks) of a layer's Opacity keys.
fn key_times(h: &Harness<'_, EffectcraftApp>, id: u64) -> Vec<i64> {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| l.comp_time(k.time).0).collect()
}
fn opacity_uid(h: &Harness<'_, EffectcraftApp>, id: u64) -> u64 {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid
}
#[test]
fn ctrl_alt_dragging_layer_bars_staggers_them_in_selection_order() {
let (mut h, ids) = harness();
let order = [ids[2], ids[0], ids[1]];
for (i, id) in order.iter().enumerate() {
h.state_mut().session.execute("layer.select", json!({"layers": [id], "add": i > 0})).unwrap();
}
h.run_steps(2);
let before = h.state().session.project.clone();
// Drag the middle layer's bar: the drag moves the last selected layer.
let from = center(&h, &format!("timeline.layer.{}.bar", ids[0]));
drag(&mut h, from, from + vec2(160.0, 0.0), ctrl_alt());
let d = start(&h, order[2]);
assert!(d > 0 && d % F == 0, "the last selected layer moves by whole frames: {d}");
assert_eq!([start(&h, order[0]), start(&h, order[1])], [0, d / 2], "the first stays, the middle one moves half");
assert_eq!(key_times(&h, order[1]), vec![d / 2, Tick::from_seconds_f64(1.0).0 + d / 2], "its keys come along");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.project.same_content(&before), "one undo step");
// A plain drag still moves every selected layer together.
h.run_steps(2);
drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default());
let moved: Vec<i64> = order.iter().map(|id| start(&h, *id)).collect();
assert!(moved[0] > 0 && moved.iter().all(|m| *m == moved[0]), "{moved:?}");
}
#[test]
fn ctrl_alt_dragging_keys_staggers_each_layers_keys_and_alt_still_scales() {
let (mut h, ids) = harness();
// Layer 1's keys first, then layer 0's, then layer 2's.
let mut picks = vec![];
for id in [ids[1], ids[0], ids[2]] {
for t in [0.0, 1.0] {
picks.push(json!({"layer": id, "path": "transform/opacity", "time": t}));
}
}
for (i, k) in picks.iter().enumerate() {
h.state_mut().session.execute("keys.select", json!({"keys": [k], "add": i > 0})).unwrap();
}
h.run_steps(2);
let before = h.state().session.project.clone();
let one = Tick::from_seconds_f64(1.0).0;
let grab = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0])));
drag(&mut h, grab, grab + vec2(160.0, 0.0), ctrl_alt());
let d = key_times(&h, ids[2])[0];
assert!(d > 0 && d % F == 0, "the last layer's keys move by whole frames: {d}");
assert_eq!(key_times(&h, ids[1]), vec![0, one], "the first layer's keys stay");
assert_eq!(key_times(&h, ids[0]), vec![d / 2, one + d / 2], "the middle layer's keys move half, together");
assert_eq!(key_times(&h, ids[2]), vec![d, one + d]);
let layers: Vec<u64> = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect();
assert_eq!(layers, vec![ids[1], ids[1], ids[0], ids[0], ids[2], ids[2]], "the selection keeps its order");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.project.same_content(&before), "one undo step");
// Alt alone still scales a key group in time: the first key stays, the last follows.
h.state_mut().session.execute("keys.select", json!({"keys": [picks[2], picks[3]]})).unwrap();
h.run_steps(2);
let last = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0])));
drag(&mut h, last, last + vec2(160.0, 0.0), Modifiers { alt: true, ..Default::default() });
let scaled = key_times(&h, ids[0]);
assert!(scaled[0] == 0 && scaled[1] > one, "Alt scales: {scaled:?}");
assert_eq!(key_times(&h, ids[1]), vec![0, one], "and staggers nothing");
}
#[test]
fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() {
let (mut h, ids) = harness();
let from = center(&h, &format!("timeline.layer.{}.bar", ids[1]));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt()));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() });
h.step();
for i in 1..=12 {
h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0)));
h.step();
}
let shapes = h.output().shapes.clone();
let text: Vec<String> = shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect();
assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}");
}
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default()));
h.run_steps(2);
}
/// A Shift-click range of layers and a box of keys, drawn from the bottom right up to the top left,
/// stagger in the comp's layer order (top first), whichever way they were made (#1012).
#[test]
fn shift_ranges_and_key_boxes_stagger_in_comp_order() {
let (mut h, ids) = harness();
// ids[2] is the top layer, ids[0] the bottom one. Click the bottom bar, Shift-click the top.
let bar = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.layer.{id}.bar"));
let (bottom, top) = (bar(&h, ids[0]), bar(&h, ids[2]));
click(&mut h, bottom, Modifiers::default());
click(&mut h, top, Modifiers::SHIFT);
let selected: Vec<u64> = h.state().session.state.selected_layers.iter().map(|l| l.0).collect();
assert_eq!(selected, vec![ids[2], ids[1], ids[0]], "a Shift range is in comp order");
h.state_mut().session.execute("layer.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap();
assert_eq!([start(&h, ids[2]), start(&h, ids[1]), start(&h, ids[0])], [0, F, 2 * F]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
// A box around the 1 s keys, drawn upwards and to the left.
let one = Tick::from_seconds_f64(1.0).0;
let key = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.keyAt.{}.{one}", opacity_uid(h, id)));
let (low, high) = (key(&h, ids[0]), key(&h, ids[2]));
drag(&mut h, low + vec2(30.0, 5.0), high - vec2(30.0, 5.0), Modifiers::default());
let order: Vec<u64> = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect();
assert_eq!(order, vec![ids[2], ids[1], ids[0]], "a key box is in comp order");
h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap();
assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]);
}
/// Releasing a Quick Offset drag with its row scrolled out of view ends it: a later plain drag of
/// that bar moves the selection together, as its own undo step.
#[test]
fn a_quick_offset_released_off_screen_ends() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let ids: Vec<u64> =
(0..40).map(|i| s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020"})).unwrap()["layer"].as_u64().unwrap()).collect();
let (top, next) = (ids[39], ids[38]);
s.execute("layer.select", json!({"layers": [top, next]})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let from = center(&h, &format!("timeline.layer.{top}.bar"));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt()));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() });
h.step();
for i in 1..=12 {
h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0)));
h.step();
}
// Let go of Ctrl+Alt, scroll the row away, then release the button.
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default()));
h.state_mut().ui.timeline.scroll_y = 10_000.0;
h.run_steps(2);
assert!(h.state().auto.find(&format!("timeline.layer.{top}.bar")).is_none(), "the row is out of view");
let to = from + vec2(120.0, 0.0);
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::default() });
h.run_steps(2);
let staggered = (start(&h, next), start(&h, top));
assert!(staggered.0 > 0 && staggered.1 == 0, "the Quick Offset applied: {staggered:?}");
h.state_mut().ui.timeline.scroll_y = 0.0;
h.run_steps(3);
let from = center(&h, &format!("timeline.layer.{top}.bar"));
drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default());
let d = start(&h, top);
assert!(d > 0 && start(&h, next) == staggered.0 + d, "a plain drag moves both by the same amount");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!((start(&h, next), start(&h, top)), staggered, "as its own undo step");
}
+5 -1
View File
@@ -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).
+3 -1
View File
@@ -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
+2 -1
View File
@@ -118,7 +118,8 @@ one), `panel.tab.Viewer(<n>)` and `viewers.<n>` (View ▸ New Viewer's other Com
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.nestedMarker.<i>` (a precomp's comp markers), `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
`timeline.key.<uid>.<frame>` (and `timeline.keyAt.<uid>.<ticks>`, exact in layer-time ticks, for
keys on subframes), `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.revolutions` and
`properties.prop.<uid>.revolutions`) and `effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,