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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 20:39:57 +03:00
co-authored by Cursor
parent 457406a7a5
commit de098cfa52
2 changed files with 58 additions and 15 deletions
+35
View File
@@ -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<Animation> = (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)));
}
+23 -15
View File
@@ -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<Sequence> = 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<usize>)> = vec![(Sequence::Main, Vec::new())];
let mut seq_index: HashMap<Sequence, usize> = 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<StepInfo> = 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));
}