From 0e49df4f738c2cb19faa5d24639ffaece9437f35 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 1/9] Timeline: a property's chain link can start off (#2485) --- crates/ui-egui/src/state.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3a96d3c50..ed642fd62 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -307,6 +307,21 @@ impl Default for ViewerState { } impl TimelineState { + /// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (Audio Levels) + /// records it in `linked`. + pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool { + if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) } + } + + /// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]). + pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) { + let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked }; + if !set.remove(&uid) { + set.insert(uid); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -414,6 +429,9 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, + /// Audio Levels properties whose channel link (off by default) was turned on (#2485). + #[serde(default)] + pub linked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -466,6 +484,7 @@ impl Default for TimelineState { expr_closed: BTreeSet::new(), expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), + linked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, scroll_to: None, From 2702ca18d50096c29440c21a2adf1c855b717802 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 2/9] Audio: linked Audio Levels move both channels by the same dB (#2485) --- crates/ui-egui/src/panels/timeline.rs | 18 +++++++++++------- crates/ui-egui/src/widgets.rs | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..c8bf30580 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5494,16 +5494,19 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> // Mask Feather: two linked values, never negative (as in After Effects, #203). let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; + // Audio Levels link their two channels too, off by default, by the same dB change (#2485). + let levels = prop.match_id == "levels" && layer.props.parent_of(uid).is_some_and(|g| g.match_id == "audio"); // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). - let linkable = feather || (pct && prop.match_id == "scale"); - let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + let linkable = feather || levels || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !levels); if linkable { let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); - let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions")); + let label = if levels { "Link Channels" } else { "Constrain Proportions" }; + let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr(label)); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); - app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); - if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { - app.ui.timeline.unlinked.insert(uid); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, label); + if resp.clicked() { + app.ui.timeline.toggle_link(uid, !levels); } x += 18.0; } @@ -5524,7 +5527,8 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub). let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + let link = if levels { widgets::shifted } else { widgets::constrained }; + for (e, v) in link(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..0f0cbc413 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -820,6 +820,13 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { .collect() } +/// Linked values after component `d` of `c` became `v`: the others move by the same amount, so +/// linked Audio Levels keep their stereo balance in dB (#2485). +pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter().enumerate().map(|(e, &x)| if e == d { v } else { x + (v - old) }).collect() +} + #[cfg(test)] mod tests { use super::*; From 3f71915fc07bb223bdcc36436d66619fefdd60ec Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 17:40:15 -0400 Subject: [PATCH 3/9] Tests: linked Audio Levels keep their stereo balance (#2485) --- crates/ui-egui/tests/it/main.rs | 1 + .../tests/it/ui_timeline_audio_levels.rs | 107 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 crates/ui-egui/tests/it/ui_timeline_audio_levels.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..8573f83b2 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod ui_text_edit; mod ui_text_range_handles; mod ui_theme_favorites; mod ui_time_zoom_controls; +mod ui_timeline_audio_levels; mod ui_timeline_effect_label; mod ui_timeline_pan; mod ui_timeline_shapes; diff --git a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs new file mode 100644 index 000000000..e1c626489 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -0,0 +1,107 @@ +//! Audio Levels' channel link in the Timeline (#2485): off by default; linked, an edit to one +//! channel moves the other by the same dB, so the stereo balance stays, typed or scrubbed, on a +//! static or animated property, and one Undo takes it back. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +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 on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + drag(h, p, p, 1); +} + +fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) { + click(h, id); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn linked_audio_levels_move_both_channels_by_the_same_db() { + 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 l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid; + s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]); + h.state_mut().ui.timeline.reveal_props.insert(uid); + h.run_steps(3); + let levels = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone(); + let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| { + h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": v})).unwrap(); + h.run_steps(2); + }; + let undo = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + }; + let (left, right, link) = (format!("timeline.prop.{uid}.value.0"), format!("timeline.prop.{uid}.value.1"), format!("timeline.prop.{uid}.link")); + assert_eq!(h.state().auto.find(&link).unwrap().label, "Link Channels"); + + type_value(&mut h, &left, "-3"); + assert_eq!(levels(&h).value.components(), vec![-3.0, -12.0], "unlinked by default, one channel moves alone"); + set(&mut h, [-6.0, -12.0]); + + click(&mut h, &link); + type_value(&mut h, &left, "-3"); + assert_eq!(levels(&h).value.components(), vec![-3.0, -9.0], "linked, both rise 3 dB and keep their 6 dB balance"); + undo(&mut h); + assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back both channels"); + + type_value(&mut h, &right, "0"); + assert_eq!(levels(&h).value.components(), vec![6.0, 0.0], "the right channel leads just the same"); + set(&mut h, [0.0, 0.0]); + type_value(&mut h, &left, "-6"); + assert_eq!(levels(&h).value.components(), vec![-6.0, -6.0], "equal channels at 0 dB stay equal"); + + set(&mut h, [-6.0, -12.0]); + let from = center(&h, &left); + drag(&mut h, from, from + vec2(30.0, 0.0), 6); + let c = levels(&h).value.components(); + assert!(c[0] > -6.0, "the scrub raised the left channel: {c:?}"); + assert!((c[0] - c[1] - 6.0).abs() < 1e-9, "a linked scrub keeps the balance: {c:?}"); + undo(&mut h); + assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back the whole scrub"); + + // Animated: the edit at the current time changes that key's two channels together. + h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 0.0, "value": [-6.0, -12.0]})).unwrap(); + h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 1.0, "value": [0.0, 0.0]})).unwrap(); + h.run_steps(2); + type_value(&mut h, &left, "-10"); + let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys, vec![vec![-10.0, -16.0], vec![0.0, 0.0]], "the key at the current time moves both channels"); + + click(&mut h, &link); + type_value(&mut h, &left, "-4"); + let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays"); +} From a90b58a8850103c9bb8318a4fb22599b3327f0c8 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 4/9] 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"); +} From e26dc46b61806efbe46fc8c1702994dca8c62e63 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 5/9] Audio: linked typing keeps each selected layer's own stereo balance (#2485) --- crates/ui-egui/src/panels/timeline.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c8bf30580..b052d32d2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5536,8 +5536,15 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> ve.set(actions, json!(nc)); // Typed into these components only: other selected layers keep the rest of // their own value (#1434). + // Linked Audio Levels: the typed channel, the other following by that layer's own + // change, so each keeps its balance (#2485). if let Some((_, a)) = actions.last_mut() { - a["dims"] = json!((from..from + shown).collect::>()); + if levels { + a["dims"] = json!([d]); + a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::>()); + } else { + a["dims"] = json!((from..from + shown).collect::>()); + } } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; From 2314e8923f45b6d5d7c01c803e32dadd13771131 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:09 -0400 Subject: [PATCH 6/9] Timeline: chain links don't carry into a new or opened project (#2485) --- crates/ui-egui/src/app/frame.rs | 1 + crates/ui-egui/src/state.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index c0f9c7bd1..98bcfb45f 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -286,6 +286,7 @@ impl EffectcraftApp { /// `eframe::App::ui` and by tests with the root `Ui`. pub fn draw(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { panels::viewer_text::begin_ui(ui.ctx()); + self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.draw_inner(ui, frame); // All exits, including fonts/device loss, observe this pass's real custom availability. panels::viewer_text::finish_ime(self, ui); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ed642fd62..d10676a5c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -322,6 +322,16 @@ impl TimelineState { } } + /// Forget the chain links when the project was replaced (new or opened): they are keyed by + /// property uid, which the next project reuses, so a link turned on must not carry into it. + pub fn scope_links(&mut self, project_epoch: u64) { + if self.link_epoch != project_epoch { + self.link_epoch = project_epoch; + self.unlinked.clear(); + self.linked.clear(); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -432,6 +442,9 @@ pub struct TimelineState { /// Audio Levels properties whose channel link (off by default) was turned on (#2485). #[serde(default)] pub linked: BTreeSet, + /// The project (its epoch) `unlinked` and `linked` belong to. + #[serde(skip)] + pub link_epoch: u64, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -485,6 +498,7 @@ impl Default for TimelineState { expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), linked: BTreeSet::new(), + link_epoch: 0, columns: default_tl_columns(), source_name: false, scroll_to: None, From fca40323b3cfc7a15f77e279bfa9f048f128ea87 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 19:54:10 -0400 Subject: [PATCH 7/9] Tests: linked Audio Levels across selected layers and a new project (#2485) --- .../tests/it/ui_timeline_audio_levels.rs | 73 +++++++++++++++++-- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs index e1c626489..bc0b9a7f9 100644 --- a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -41,19 +41,39 @@ fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) { h.run_steps(3); } -#[test] -fn linked_audio_levels_move_both_channels_by_the_same_db() { - let mut s = Session::default(); +/// A precomp layer (which has Audio Levels) in comp Main at `levels`; its layer id and the +/// property's uid. +fn add_sound_layer(s: &mut Session, levels: [f64; 2]) -> (u64, u64) { + let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": levels})).unwrap(); + (l, s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid) +} + +/// Comps Sound and Main. +fn comps(s: &mut Session) { 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 l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap(); - let uid = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid; - s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap(); - let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); +} + +/// Twirl layer `l` open to its property `uid`. +fn reveal(h: &mut Harness<'_, EffectcraftApp>, l: u64, uid: u64) { h.state_mut().ui.timeline.open_layers.insert(l); h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]); h.state_mut().ui.timeline.reveal_props.insert(uid); h.run_steps(3); +} + +fn levels_of(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().value.components() +} + +#[test] +fn linked_audio_levels_move_both_channels_by_the_same_db() { + let mut s = Session::default(); + comps(&mut s); + let (l, uid) = add_sound_layer(&mut s, [-6.0, -12.0]); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, l, uid); let levels = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone(); let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| { @@ -105,3 +125,42 @@ fn linked_audio_levels_move_both_channels_by_the_same_db() { let keys: Vec> = levels(&h).keys.iter().map(|k| k.value.components()).collect(); assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays"); } + +/// With several layers selected, a linked typed Left sets each layer's Left and moves its Right +/// by that layer's own change: every layer keeps its own balance. +#[test] +fn linked_typing_keeps_each_selected_layers_own_balance() { + let mut s = Session::default(); + comps(&mut s); + let (a, uid) = add_sound_layer(&mut s, [-6.0, -12.0]); + let (b, other) = add_sound_layer(&mut s, [-20.0, -30.0]); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, a, uid); + reveal(&mut h, b, other); + click(&mut h, &format!("timeline.prop.{uid}.link")); + type_value(&mut h, &format!("timeline.prop.{uid}.value.0"), "-3"); + assert_eq!(levels_of(&h, a), vec![-3.0, -9.0]); + assert_eq!(levels_of(&h, b), vec![-3.0, -13.0], "B rises 17 dB on both channels, keeping its 10 dB balance"); +} + +/// A link turned on does not carry into a new project whose Audio Levels reuse the property uid. +#[test] +fn a_new_project_starts_with_its_audio_levels_unlinked() { + let mut s = Session::default(); + comps(&mut s); + let (l, uid) = add_sound_layer(&mut s, [0.0, 0.0]); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + reveal(&mut h, l, uid); + click(&mut h, &format!("timeline.prop.{uid}.link")); + assert!(h.state().ui.timeline.is_linked(uid, false)); + + h.state_mut().session.execute("file.newProject", json!({})).unwrap(); + comps(&mut h.state_mut().session); + let (l, again) = add_sound_layer(&mut h.state_mut().session, [0.0, 0.0]); + assert_eq!(again, uid, "the new project reuses the property uid"); + reveal(&mut h, l, again); + assert!(!h.state().ui.timeline.is_linked(again, false), "the new project's levels start unlinked"); + type_value(&mut h, &format!("timeline.prop.{again}.value.0"), "-3"); + assert_eq!(levels_of(&h, l), vec![-3.0, 0.0]); +} From b9808d5a24ffab97b3693e672fb4084723177053 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 20:28:10 -0400 Subject: [PATCH 8/9] Engine: a typed prop.set measures each layer's follow change where it keys (#2485) --- crates/engine/src/cmd/prop.rs | 10 ++++++++-- crates/engine/src/tests/timeline.rs | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 17dee1898..3a8419765 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -267,8 +267,13 @@ fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value { /// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate /// Dimensions: its X / Y / Z Position). fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option> { + components_at(s, cid, lid, uid, None) +} + +/// [`current_components`] at layer time `at` (the CTI when `None`). +fn components_at(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, at: Option) -> Option> { let l = s.project.comp(cid)?.layer(lid)?; - let lt = l.layer_time(s.time_of(cid)); + let lt = at.unwrap_or_else(|| l.layer_time(s.time_of(cid))); if let Some(tr) = l.transform() && tr.get("position").is_some_and(|q| q.uid == uid) && tr.get("positionX").is_some() @@ -329,9 +334,10 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { }; 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(); + // Each layer's own value where `set` writes it (a `time` other than the CTI keys there). 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 Some(mut c) = components_at(s, cid, olid, ouid, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten()) { let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0); for &d in &follow { diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index fa9117e37..75d1b71be 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1449,4 +1449,10 @@ fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() { 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"); + + // Keyed at a time other than the CTI: B's change is measured on its key there. + animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap(); + let keys: Vec> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect(); + assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels"); } From b758d7173e2eb24ca8f80ca8af52e2c0e61bc009 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 28 Oct 2026 21:24:20 -0400 Subject: [PATCH 9/9] Timeline: chain links are rescoped after a shortcut or menu replaces the project (#2485) --- crates/ui-egui/src/app/frame.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 98bcfb45f..534d3863d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -286,7 +286,6 @@ impl EffectcraftApp { /// `eframe::App::ui` and by tests with the root `Ui`. pub fn draw(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { panels::viewer_text::begin_ui(ui.ctx()); - self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.draw_inner(ui, frame); // All exits, including fonts/device loss, observe this pass's real custom availability. panels::viewer_text::finish_ime(self, ui); @@ -394,6 +393,8 @@ impl EffectcraftApp { let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h)); header::show(self, ui, header); let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0)); + // After the shortcuts, menus and header, which can replace the project (New, Open). + self.ui.timeline.scope_links(self.session.script_ui.project_epoch); self.dock_area(ui, body); #[cfg(not(target_arch = "wasm32"))] prefs_live::dropped_files(self, ui, full);