From a90b58a8850103c9bb8318a4fb22599b3327f0c8 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH] Engine: a typed prop.set can move follow components by each layer's own change (#2485) --- crates/engine/src/cmd/prop.rs | 14 ++++++++++++-- crates/engine/src/tests/timeline.rs | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..17dee1898 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -300,7 +300,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with /// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` /// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, -/// which keep the rest of their own value (#1434). With `offset` and +/// which keep the rest of their own value (#1434); `follow` components move on each layer by the +/// change its first typed component takes (linked Audio Levels keep each layer's balance, #2485). +/// With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -325,10 +327,18 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), _ => None, }; + let follow: Vec = + p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect(); let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { if let (Some(dims), Some(n)) = (&dims, &new) && let Some(mut c) = current_components(s, cid, olid, ouid) { + let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0); + for &d in &follow { + if let Some(slot) = c.get_mut(d) { + *slot += change; + } + } for &d in dims { if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { *slot = *v; @@ -1310,7 +1320,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 33dac7795..fa9117e37 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1430,3 +1430,23 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() { s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); } + +/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and +/// its Right moves by its own change, so every layer keeps its own stereo balance. +#[test] +fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let uid = prop(&s, a, "audio/levels").uid; + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap(); + assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]); + assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); +}