From de098cfa5266fae4e7aeca3e0beb8c938d744f99 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 20:39:57 +0300 Subject: [PATCH] Anim: group animations by sequence in one pass (#410) Timeline::with found trigger sequences with Vec::contains and then rescanned every animation once per sequence, and checked MAX_EFFECTS only after a match, so a slide with many distinct triggers cost quadratic work before the effect cap could stop it. Animations are now grouped once (an index by sequence, first-appearance order kept, main first), the build stops at MAX_EFFECTS before the next sequence, and paragraph groups are deduplicated with a set instead of Vec::contains. Co-authored-by: Cursor --- crates/anim/src/tests.rs | 35 ++++++++++++++++++++++++++++++++++ crates/anim/src/timeline.rs | 38 ++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..05ceb740 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -789,3 +789,38 @@ fn show_follows_links() { assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); assert_eq!(s.slide, 3); } + +#[test] +fn trigger_sequences_keep_first_appearance_order_when_interleaved() { + let mut anims = vec![]; + for (i, tr) in [7u32, 5, 7, 0, 9, 5].iter().enumerate() { + let mut a = anim(1 + (i as u32 % 4), AnimClass::Entrance, "fade", AnimStart::OnClick); + a.trigger = (*tr != 0).then_some(ShapeId(*tr)); + anims.push(a); + } + let tl = Timeline::new(&slide_with(anims), W, H); + assert_eq!(tl.trigger_shapes(), vec![ShapeId(7), ShapeId(5), ShapeId(9)]); + assert_eq!(tl.trigger_steps(ShapeId(7)), 2); + assert_eq!(tl.trigger_steps(ShapeId(5)), 2); + assert_eq!(tl.trigger_steps(ShapeId(9)), 1); + assert_eq!(tl.steps(), 1); +} + +#[test] +fn many_distinct_triggers_past_the_effect_cap_build_in_order() { + // One sequence per animation: grouping used to rescan every animation per trigger + // (~1.2e10 steps here), now it is one pass, and nothing past the cap is looked at. + let n = 110_000u32; + let anims: Vec = (0..n) + .map(|i| { + let mut a = anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick); + a.trigger = Some(ShapeId(1000 + i)); + a + }) + .collect(); + let tl = Timeline::new(&slide_with(anims), W, H); + assert_eq!(tl.effects().len(), 100_000); + let shapes = tl.trigger_shapes(); + assert_eq!(shapes.len(), 100_000); + assert!(shapes.iter().enumerate().all(|(i, s)| *s == ShapeId(1000 + i as u32))); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index e73acfd9..9692b884 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -1,5 +1,7 @@ //! A slide's animation sequence: click steps, effect start times and per-shape state over time. +use std::collections::{HashMap, HashSet}; + use deckcraft_model::anim::TextBuild; use deckcraft_model::style::Fill; use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm}; @@ -151,25 +153,31 @@ impl Timeline { let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let mut tl = Timeline::default(); - // Sequences in order of first appearance: main first. - let mut seqs: Vec = vec![Sequence::Main]; - for a in &slide.animations { - if let Some(tr) = a.trigger { - let s = Sequence::Trigger(tr); - if !seqs.contains(&s) { - seqs.push(s); - } + // Group the animations by sequence in one pass, sequences in order of first appearance + // (main first), so many distinct triggers cost linear work, not a scan per trigger. + let mut seqs: Vec<(Sequence, Vec)> = vec![(Sequence::Main, Vec::new())]; + let mut seq_index: HashMap = HashMap::from([(Sequence::Main, 0)]); + for (ai, a) in slide.animations.iter().enumerate() { + let s = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main); + let i = *seq_index.entry(s).or_insert_with(|| { + seqs.push((s, Vec::new())); + seqs.len() - 1 + }); + if let Some((_, members)) = seqs.get_mut(i) { + members.push(ai); } } - for seq in seqs { + // Paragraph groups already recorded (the list itself keeps their order). + let mut grouped: HashSet<(ShapeId, usize, usize)> = HashSet::new(); + for (seq, members) in seqs { + if tl.effects.len() >= MAX_EFFECTS { + break; + } let mut steps: Vec = Vec::new(); let mut group_begin = 0.0f64; let mut max_end = 0.0f64; - for (ai, a) in slide.animations.iter().enumerate() { - let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main); - if aseq != seq { - continue; - } + for ai in members { + let Some(a) = slide.animations.get(ai) else { continue }; if tl.effects.len() >= MAX_EFFECTS { break; } @@ -188,7 +196,7 @@ impl Timeline { owner = Some(i); v.push(Some(i)); } else if let Some(o) = owner - && !tl.groups.contains(&(a.shape, i, o)) + && grouped.insert((a.shape, i, o)) { tl.groups.push((a.shape, i, o)); }