mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 08:11:40 +00:00
Merge pull request #2562 from tylerjenningsw/enhance/audio-levels-link
Audio: link stereo level edits while preserving channel balance
This commit is contained in:
8 files changed
+259
-61
No files matched your search
@@ -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<Vec<f64>> {
|
||||
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<Tick>) -> Option<Vec<f64>> {
|
||||
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<Value> {
|
||||
(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<usize> =
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<Vec<f64>> = 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");
|
||||
}
|
||||
@@ -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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>());
|
||||
if levels {
|
||||
a["dims"] = json!([d]);
|
||||
a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::<Vec<_>>());
|
||||
} else {
|
||||
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
|
||||
|
||||
@@ -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<String>) {
|
||||
@@ -429,10 +439,13 @@ pub struct TimelineState {
|
||||
/// was turned off.
|
||||
#[serde(default)]
|
||||
pub unlinked: BTreeSet<u64>,
|
||||
/// 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<u64>,
|
||||
/// 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,
|
||||
|
||||
@@ -984,6 +984,13 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
|
||||
.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<f64> {
|
||||
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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f64> {
|
||||
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<Vec<f64>> = 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<Vec<f64>> = 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]);
|
||||
}
|
||||
Reference in new issue
Block a user