From fa632a6ebe2e37cf35a691f7d9f8f4518a56ad94 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:15:50 +0200 Subject: [PATCH 1/2] Offset all selected keyframes when a value is edited on one of them (#1963) --- crates/engine/src/commands/prop.rs | 17 ++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_set_selected_keys.rs | 53 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/tests_set_selected_keys.rs diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34c..35718197b8 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -443,6 +443,7 @@ fn set(s: &mut Session, p: &Value) -> Result { } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); let at = snapped_key_time(s, cid, lid, p, "prop.set")?; + let selected: Vec = s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == uid).map(|k| k.time).collect(); let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { let cur = pr.value_at(lt); let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?; @@ -452,7 +453,21 @@ fn set(s: &mut Session, p: &Value) -> Result { { nv = effectcraft_keyframe::Value::Scalar(x.clamp(min, max)); } - pr.set_value_at(at.unwrap_or(lt), nv.clone()); + let target = at.unwrap_or(lt); + // After Effects: editing the value with the CTI on one of several selected keys moves all + // the selected keys by the same change; between keys it only adds a key. + if selected.len() > 1 && selected.contains(&target) && key_at(&pr.keys, target).is_some() && nv.interpolates() { + let delta: Vec = nv.components().iter().zip(pr.value_at(target).components()).map(|(n, o)| n - o).collect(); + let range = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { Some((min, max)) } else { None }; + for k in pr.keys.iter_mut().filter(|k| k.time != target && selected.contains(&k.time)) { + let moved: Vec = k.value.components().iter().zip(&delta).map(|(c, d)| c + d).collect(); + k.value = k.value.with_components(&moved); + if let (Some((min, max)), effectcraft_keyframe::Value::Scalar(x)) = (range, &k.value) { + k.value = effectcraft_keyframe::Value::Scalar(x.clamp(min, max)); + } + } + } + pr.set_value_at(target, nv.clone()); Ok(nv.to_json()) })?; if p.get("timeBase").is_none() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e9..f3dc76fc43 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1294,6 +1294,8 @@ mod tests_rig3d; #[cfg(test)] mod tests_sequence; #[cfg(test)] +mod tests_set_selected_keys; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_shortcut_cmds; diff --git a/crates/engine/src/tests_set_selected_keys.rs b/crates/engine/src/tests_set_selected_keys.rs new file mode 100644 index 0000000000..5747e7fa23 --- /dev/null +++ b/crates/engine/src/tests_set_selected_keys.rs @@ -0,0 +1,53 @@ +//! `prop.set` with the CTI on one of several selected keys moves all the selected keys by the same change. + +use effectcraft_project::LayerId; +use serde_json::json; + +use crate::{KeyRef, Session}; + +fn setup() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap(); + let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for t in 0..3 { + s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap(); + } + let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid; + (s, layer, prop) +} + +fn values(s: &Session, layer: u64, prop: u64) -> Vec { + s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.iter().map(|k| k.value.as_f64()).collect() +} + +fn select_all(s: &mut Session, layer: u64, prop: u64) { + let keys = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.iter().map(|k| k.time).collect::>(); + s.state.selected_keys = keys.into_iter().map(|time| KeyRef { layer: LayerId(layer), prop, time }).collect(); +} + +#[test] +fn value_edit_on_a_selected_key_offsets_all_selected_keys() { + let (mut s, layer, prop) = setup(); + select_all(&mut s, layer, prop); + s.execute_checked("time.set", json!({"time": 1.0})).unwrap(); + s.execute_checked("prop.set", json!({"layer": layer, "path": "transform/opacity", "value": 40})).unwrap(); + assert_eq!(values(&s, layer, prop), vec![15.0, 40.0, 65.0]); +} + +#[test] +fn offset_selected_keys_stay_in_the_slider_range() { + let (mut s, layer, prop) = setup(); + select_all(&mut s, layer, prop); + s.execute_checked("time.set", json!({"time": 1.0})).unwrap(); + s.execute_checked("prop.set", json!({"layer": layer, "path": "transform/opacity", "value": 90})).unwrap(); + assert_eq!(values(&s, layer, prop), vec![65.0, 90.0, 100.0]); +} + +#[test] +fn value_edit_between_keys_only_adds_a_key() { + let (mut s, layer, prop) = setup(); + select_all(&mut s, layer, prop); + s.execute_checked("time.set", json!({"time": 0.5})).unwrap(); + s.execute_checked("prop.set", json!({"layer": layer, "path": "transform/opacity", "value": 40})).unwrap(); + assert_eq!(values(&s, layer, prop), vec![0.0, 40.0, 25.0, 50.0]); +} From 150e6a4e747402a472cdc5d5bfa4ef91638b6609 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:38:57 +0100 Subject: [PATCH 2/2] Offset selected keys only when the playhead is on the edited key (#1963) --- crates/engine/src/cmd/prop.rs | 4 ++-- crates/engine/src/tests/set_selected_keys.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index c5e7b500bd..5018608405 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -513,8 +513,8 @@ fn set(s: &mut Session, p: &Value) -> Result { } let target = at.unwrap_or(lt); // After Effects: editing the value with the CTI on one of several selected keys moves all - // the selected keys by the same change; between keys it only adds a key. - if selected.len() > 1 && selected.contains(&target) && key_at(&pr.keys, target).is_some() && nv.interpolates() { + // the selected keys by the same change; between keys, or at an explicit other time, it only edits that key. + if selected.len() > 1 && target == lt && selected.contains(&target) && key_at(&pr.keys, target).is_some() && nv.interpolates() { let delta: Vec = nv.components().iter().zip(pr.value_at(target).components()).map(|(n, o)| n - o).collect(); let range = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { Some((min, max)) } else { None }; for k in pr.keys.iter_mut().filter(|k| k.time != target && selected.contains(&k.time)) { diff --git a/crates/engine/src/tests/set_selected_keys.rs b/crates/engine/src/tests/set_selected_keys.rs index 5747e7fa23..226ed6ba09 100644 --- a/crates/engine/src/tests/set_selected_keys.rs +++ b/crates/engine/src/tests/set_selected_keys.rs @@ -51,3 +51,12 @@ fn value_edit_between_keys_only_adds_a_key() { s.execute_checked("prop.set", json!({"layer": layer, "path": "transform/opacity", "value": 40})).unwrap(); assert_eq!(values(&s, layer, prop), vec![0.0, 40.0, 25.0, 50.0]); } + +#[test] +fn explicit_time_off_the_playhead_only_edits_that_key() { + let (mut s, layer, prop) = setup(); + select_all(&mut s, layer, prop); + s.execute_checked("time.set", json!({"time": 0.5})).unwrap(); + s.execute_checked("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 1.0, "value": 40})).unwrap(); + assert_eq!(values(&s, layer, prop), vec![0.0, 40.0, 50.0]); +}