diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ceda2d34f..fb4b525a2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -286,8 +286,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() @@ -319,7 +324,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). @@ -344,10 +351,19 @@ 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(); + // 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 { + 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; @@ -1388,7 +1404,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 e0cd88490..55475eef3 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1530,3 +1530,29 @@ 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"); + + // 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"); +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7c53f95ea..ffbf57257 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -98,7 +98,7 @@ impl EffectcraftApp { self.ui.timeline.pps = None; } effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)), - effectcraft_engine::Event::OpenUrl(url) => open_url(ctx, url), + effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), // Frames are keyed by content: an edit keeps those of comps it doesn't touch. effectcraft_engine::Event::ProjectChanged { .. } => {} effectcraft_engine::Event::Frontend { command, params } => { @@ -411,6 +411,8 @@ impl EffectcraftApp { 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)); crate::color_picker::note_dialog(&ctx, self.dialog); + // 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); @@ -581,48 +583,3 @@ impl EffectcraftApp { } } } - -/// Opens `url`: on Windows a `file://` folder (Reveal in Explorer) in File Explorer, since the -/// system browser would get it otherwise (#1961); anything else in the browser. -fn open_url(ctx: &egui::Context, url: String) { - #[cfg(windows)] - if let Some(path) = file_url_path(&url) { - // The engine only sends folders it checked are plain folders (`reveal_url`). - if std::process::Command::new("explorer").arg(&path).spawn().is_ok() { - return; - } - } - ctx.open_url(egui::OpenUrl::new_tab(url)); -} - -/// The Windows path of a `file:///C:/…` (or `file:////server/share/…`) URL, percent-decoded. -#[cfg(any(windows, test))] -fn file_url_path(url: &str) -> Option { - let rest = url.strip_prefix("file:///")?; - let mut bytes = Vec::with_capacity(rest.len()); - let mut it = rest.bytes(); - while let Some(b) = it.next() { - if b == b'%' { - let hex = [it.next()?, it.next()?]; - bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?); - } else { - bytes.push(b); - } - } - let path = String::from_utf8(bytes).ok()?.replace('/', "\\"); - // `file:////server/share` is the UNC path `\\server\share`. - Some(if path.starts_with('\\') { format!("\\{path}") } else { path }) -} - -#[cfg(test)] -mod tests { - /// #1961: Reveal's `file://` URLs map back to the folder Explorer opens. - #[test] - fn file_urls_map_to_windows_paths() { - assert_eq!(super::file_url_path("file:///C:/Footage/My%20Shots/").as_deref(), Some("C:\\Footage\\My Shots\\")); - assert_eq!(super::file_url_path("file:///C:/Clips/%E6%98%A0%E5%83%8F").as_deref(), Some("C:\\Clips\\映像")); - assert_eq!(super::file_url_path("file:////server/share/x").as_deref(), Some("\\\\server\\share\\x")); - assert_eq!(super::file_url_path("https://getartcraft.com"), None); - assert_eq!(super::file_url_path("file:///C:/bad%2"), None); - } -} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ff7493d10..19a8d9fc2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5683,18 +5683,21 @@ 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). // Rectangle and Ellipse Path Size have it too, off by default (#2489). let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse")); - let linkable = feather || size || (pct && prop.match_id == "scale"); - let linked = linkable && app.ui.timeline.is_linked(uid, !size); + let linkable = feather || size || levels || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !(size || 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"); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, label); if resp.clicked() { - app.ui.timeline.toggle_link(uid, !size); + app.ui.timeline.toggle_link(uid, !(size || levels)); } x += 18.0; } @@ -5715,7 +5718,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; } @@ -5723,8 +5727,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 }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c1320054a..6abe76b2f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -308,8 +308,8 @@ 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 (shape Size) - /// records it in `linked`. + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size, + /// 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) } } @@ -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) { @@ -429,10 +439,13 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, - /// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was - /// turned on (#2489). + /// Shape Rectangle and Ellipse Path Size (#2489) and Audio Levels (#2485) properties whose + /// chain link (off by default) was turned on. #[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). @@ -486,6 +499,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, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 92bd132be..6b7a2720b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -984,6 +984,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::*; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 0539b56ac..7624aeac8 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -76,6 +76,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..bc0b9a7f9 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -0,0 +1,166 @@ +//! 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); +} + +/// 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(); +} + +/// 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]| { + 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"); +} + +/// 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]); +}