From 31da676415e2022c1cd417d4d3b395148742e01f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:10:10 +0100 Subject: [PATCH] #2104: bound audio comparisons across reused precomps --- crates/ui-egui/src/audio.rs | 18 +++++++++++------ crates/ui-egui/src/audio/live_tests.rs | 28 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 015794305..a8cb54d6d 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -18,7 +18,7 @@ //! **Scrubbing:** Ctrl/Cmd-dragging the current-time indicator plays a short snippet of the mix //! at each new frame ([`AudioScrub`], `playback.scrubAudio`), as in After Effects. -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -338,11 +338,17 @@ impl Drop for AudioPlayback { /// Effects switch, Audio Levels or effects, or the same for a nested comp. Other edits (video /// properties, names, labels) do not change the mix unless an audio expression can read them. fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { - fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize) -> bool { - let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; - if depth >= 16 { + audio_changed_inner(old, new, comp, &mut HashSet::new()) +} + +/// Each shared precomp is compared once per depth. A depth limit alone still visits an +/// exponentially large number of paths in a small graph of reused precomps, blocking the UI. +fn audio_changed_inner(old: &Project, new: &Project, comp: ItemId, seen: &mut HashSet<(ItemId, usize)>) -> bool { + fn comps_differ(old: &Project, new: &Project, comp: ItemId, depth: usize, seen: &mut HashSet<(ItemId, usize)>) -> bool { + if depth >= 16 || !seen.insert((comp, depth)) { return false; } + let (Some(a), Some(b)) = (old.comp(comp), new.comp(comp)) else { return old.comp(comp).is_some() != new.comp(comp).is_some() }; a.layers.len() != b.layers.len() || a.layers.iter().zip(&b.layers).any(|(x, y)| { x.id != y.id @@ -360,7 +366,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { || audio_has_expression(y) || match &x.source { LayerSource::Footage { item } => footage_differs(old, new, *item), - LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1), + LayerSource::Comp { item } => comps_differ(old, new, *item, depth + 1, seen), _ => false, } }) @@ -386,7 +392,7 @@ fn audio_changed(old: &Project, new: &Project, comp: ItemId) -> bool { }; f(old) != f(new) } - comps_differ(old, new, comp, 0) + comps_differ(old, new, comp, 0, seen) } /// Mix one block at the feeder's cursor into the feed and move the cursor on (wrapping or diff --git a/crates/ui-egui/src/audio/live_tests.rs b/crates/ui-egui/src/audio/live_tests.rs index 6be2909cd..28ea3be3e 100644 --- a/crates/ui-egui/src/audio/live_tests.rs +++ b/crates/ui-egui/src/audio/live_tests.rs @@ -179,3 +179,31 @@ fn video_edits_keep_the_queue_unless_audio_expressions_can_read_them() { } } } + +#[test] +fn shared_nested_comps_are_compared_once_per_depth() { + let mut project = Project::default(); + let mut child = None; + for _ in 0..10 { + let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + if let Some(item) = child { + for _ in 0..10 { + let mut layer = build::layer(&mut project, &comp, "Nested", LayerSource::Comp { item }, (64, 36), None); + layer.switches.audio = false; + comp.layers.push(layer); + } + } + child = Some(project.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(comp.into()))); + } + let root = child.unwrap(); + let mut edited = project.clone(); + edited.comp_mut(root).unwrap().layers[0].name = "Renamed".into(); + let mut seen = std::collections::HashSet::new(); + assert!(!super::audio_changed_inner(&project, &edited, root, &mut seen)); + assert_eq!(seen.len(), 10, "compare the ten comps, not a billion paths through them"); + let leaf = *seen.iter().find(|(_, depth)| *depth == 9).map(|(item, _)| item).unwrap(); + let mut layer = project.comp(root).unwrap().layers[0].clone(); + layer.source = LayerSource::Null; + edited.comp_mut(leaf).unwrap().layers.push(layer); + assert!(super::audio_changed(&project, &edited, root), "memoization must still find a nested edit"); +}