diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 903f9e13..57ea8deb 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -928,9 +928,16 @@ pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect()) } -/// 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. +/// 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 { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let (from, total, base) = key_gesture(s, merge, d, false); @@ -940,8 +947,8 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T *proj = (*b).clone(); } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let fr = comp.frame_rate; - st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(l.layer_time(fr.snap_nearest(l.comp_time(t) + total))))?; + 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())) }) } diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs index aeda6c78..c3c377f7 100644 --- a/crates/engine/src/commands/quick_offset.rs +++ b/crates/engine/src/commands/quick_offset.rs @@ -9,7 +9,7 @@ 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::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}; @@ -110,11 +110,8 @@ fn keys(s: &mut Session, p: &Value) -> Result { 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) + 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::>()})) }) diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs index 7ccf75f1..aeff851a 100644 --- a/crates/engine/src/tests_quick_offset.rs +++ b/crates/engine/src/tests_quick_offset.rs @@ -252,3 +252,19 @@ fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { } assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), 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::>()); + 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}"); + } +}