diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index b4432dd2f..9c2515dd8 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -501,6 +501,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())))?; @@ -510,7 +511,23 @@ 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, measured from the stored key (even a muted one); 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 stored = key_at(&pr.keys, target).and_then(|i| pr.keys.get(i)).map(|k| k.value.components()).unwrap_or_default(); + let delta: Vec = nv.components().iter().zip(&stored).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/tests.rs b/crates/engine/src/tests.rs index 013d2f24c..9497dbad6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -45,6 +45,7 @@ pub(crate) mod roto; mod scale_comp; mod sequence; mod session; +mod set_selected_keys; mod settings; mod shared_layers; mod 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 000000000..03ce6b2dd --- /dev/null +++ b/crates/engine/src/tests/set_selected_keys.rs @@ -0,0 +1,115 @@ +//! `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 one_undo_restores_every_offset_key() { + 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(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(values(&s, layer, prop), vec![0.0, 25.0, 50.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]); +} + +#[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]); +} + +#[test] +fn value_edit_leaves_unselected_keys_unchanged() { + let (mut s, layer, prop) = setup(); + let times = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.iter().take(2).map(|k| k.time).collect::>(); + s.state.selected_keys = times.into_iter().map(|time| KeyRef { layer: LayerId(layer), prop, time }).collect(); + 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, 50.0]); +} + +#[test] +fn offset_is_measured_from_a_muted_target_key() { + 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, v) in [(0, 0), (1, 10), (2, 50)] { + s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute_checked("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.setMuted", json!({"muted": true})).unwrap(); + 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![30.0, 40.0, 80.0]); +} + +#[test] +fn value_edit_offsets_every_component_of_a_vector() { + 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, v) in [(0, [10, 20]), (1, [20, 40]), (2, [30, 60])] { + s.execute_checked("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": t, "value": v})).unwrap(); + } + let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid; + 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/position", "value": [25, 45]})).unwrap(); + let keys = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.iter().map(|k| k.value.as_vec2()).collect::>(); + assert_eq!(keys, vec![[15.0, 25.0], [25.0, 45.0], [35.0, 65.0]]); +}