Files
deckcraft/crates/anim/src/timeline.rs
T
Dylan M. TaylorandClaude Sonnet 5.5 6a1c7354c1 security(anim): bound custom-show and timeline lookups, share motion paths across paragraph effects (#1341)
* security(anim): index slides and shapes once, share paragraph targets and motion paths

Custom-show resolution, timeline target lookup and by-paragraph builds scaled with the product
of two document-controlled counts, and paragraph expansion deep-copied the parsed motion path
into every effect.

- show_order / custom_indices resolve slide ids through one id->index map.
- Timeline::with indexes shapes once (same first-wins order as Slide::shape) for animation
  targets and base colours, and records each animated shape's colour with a set.
- Paragraph targets are worked out once per shape and shared across its animations.
- Effects share their EffectSpec (and so the motion path) by Rc instead of cloning it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(anim): use Arc for EffectSpec to satisfy Sync and early return on empty animations

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:19:37 -04:00

590 lines
24 KiB
Rust

//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
use crate::AnimState;
use crate::effects::{EffectSpec, exit_base, motion_path};
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
pub const PARAGRAPH_STAGGER: f64 = 0.5;
const MAX_EFFECTS: usize = 100_000;
const MAX_ITERATIONS: f64 = 1.0e6;
/// Which sequence an effect belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Sequence {
/// The slide's main click sequence.
Main,
/// An interactive sequence started by clicking this shape.
Trigger(ShapeId),
}
/// Timing of one (sub-)effect, for the Animation Pane timeline and the UI.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectTiming {
/// Index into `slide.animations`.
pub anim: usize,
/// Animated shape and paragraph (`None` = the whole shape).
pub shape: ShapeId,
pub paragraph: Option<usize>,
pub sequence: Sequence,
/// Click step within its sequence.
pub step: usize,
/// Start within the step (seconds, delay included).
pub start: f64,
/// One iteration (seconds).
pub duration: f64,
/// Repeat count (fractional allowed); 1 = once.
pub iterations: f64,
/// Repeats until the next click.
pub until_next_click: bool,
pub auto_reverse: bool,
pub rewind: bool,
/// Time from start to end (all iterations, reverse included; one iteration if until-click).
pub span: f64,
pub class: AnimClass,
}
/// Playhead of a triggered sequence: `step` clicks on the trigger fully played, the current one
/// playing for `t` seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TriggerPlay {
pub trigger: ShapeId,
pub step: usize,
pub t: f64,
}
/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects
/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again
/// by another ends up as the later of the two left it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StepStamp {
pub trigger: ShapeId,
pub step: usize,
pub at: u64,
}
#[derive(Clone, Debug, Default)]
struct StepInfo {
auto: bool,
duration: f64,
}
#[derive(Clone, Debug)]
enum After {
None,
Hide,
HideOnNextClick,
Dim(Rgba),
}
#[derive(Clone, Debug)]
struct Effect {
timing: EffectTiming,
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
/// per paragraph.
spec: Arc<EffectSpec>,
after: After,
}
/// A slide's animations laid out in time.
#[derive(Clone, Debug, Default)]
pub struct Timeline {
effects: Vec<Effect>,
main: Vec<StepInfo>,
triggers: Vec<(ShapeId, Vec<StepInfo>)>,
/// Base fill colour per shape (for colour emphasis).
colors: Vec<(ShapeId, Rgba)>,
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>,
}
enum Status {
NotStarted,
Active(f64),
Done,
}
fn secs(ms: u32) -> f64 {
ms as f64 / 1000.0
}
fn finite_or(v: f64, d: f64) -> f64 {
if v.is_finite() { v } else { d }
}
fn sanitize_xfrm(x: Xfrm, sw: f64, sh: f64) -> Xfrm {
Xfrm {
x: finite_or(x.x, 0.0).clamp(-1.0e7, 1.0e7),
y: finite_or(x.y, 0.0).clamp(-1.0e7, 1.0e7),
w: finite_or(x.w, sw / 2.0).clamp(0.0, 1.0e7),
h: finite_or(x.h, sh / 2.0).clamp(0.0, 1.0e7),
..x
}
}
fn default_scheme() -> Option<ColorScheme> {
deckcraft_model::theme::builtin_color_schemes().into_iter().next()
}
impl Timeline {
/// Build the timeline of `slide` (slide size in points). Placeholders without their own box
/// get a centred default box; colours resolve against the default theme.
pub fn new(slide: &Slide, slide_w: f64, slide_h: f64) -> Timeline {
Timeline::with(slide, slide_w, slide_h, None, &|_| None)
}
/// Build with a colour scheme for theme colours and a lookup for shape boxes (placeholders
/// inherit their box from the layout; the lookup wins over `shape.xfrm`).
pub fn with(slide: &Slide, slide_w: f64, slide_h: f64, scheme: Option<&ColorScheme>, xfrm_of: &dyn Fn(ShapeId) -> Option<Xfrm>) -> Timeline {
let sw = finite_or(slide_w, 960.0).clamp(1.0, 1.0e7);
let sh = finite_or(slide_h, 540.0).clamp(1.0, 1.0e7);
let owned;
let scheme = match scheme {
Some(s) => Some(s),
None => {
owned = default_scheme();
owned.as_ref()
}
};
let resolve = |c: &deckcraft_model::ColorRef| -> Rgba {
match scheme {
Some(s) => c.resolve(s, None),
None => match &c.base {
deckcraft_model::ColorBase::Rgb { rgb } => *rgb,
_ => Rgba::rgb(0x15, 0x60, 0x82),
},
}
};
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut tl = Timeline::default();
if slide.animations.is_empty() {
return tl;
}
// Every shape by id, built once: resolving each animation's target by tree search would
// cost shapes x animations.
let shapes = shape_index(&slide.shapes);
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
let mut para_cache: HashMap<ShapeId, Arc<Vec<Option<usize>>>> = HashMap::new();
// 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);
}
}
// 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 in members {
let Some(a) = slide.animations.get(ai) else { continue };
if tl.effects.len() >= MAX_EFFECTS {
break;
}
let shape = shapes.get(&a.shape).copied();
// Paragraph targets of this animation.
let paras: Arc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
(Some(p), _) => Arc::new(vec![Some(p as usize)]),
(None, TextBuild::ByParagraph) => {
let cached = para_cache.entry(a.shape).or_insert_with(|| {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
match role {
BuildRole::Top => v.push(Some(i)),
BuildRole::Member(o) => {
if grouped.insert((a.shape, i, o)) {
tl.groups.push((a.shape, i, o));
}
}
BuildRole::Unowned => {}
}
}
}
if v.is_empty() {
v.push(None)
}
Arc::new(v)
});
Arc::clone(cached)
}
_ => Arc::new(vec![None]),
};
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
let xf = sanitize_xfrm(xf, sw, sh);
let spec = Arc::new(build_spec(a, xf, sw, sh, &resolve));
let dur = secs(a.duration_ms);
let delay = secs(a.delay_ms);
let until_next_click = a.repeat == u32::MAX;
let iterations = if until_next_click || a.repeat <= 1 { 1.0 } else { (a.repeat as f64).min(MAX_ITERATIONS) };
let span = dur * if a.auto_reverse { 2.0 } else { 1.0 } * iterations;
let after = match a.after.as_deref() {
Some("hide") => After::Hide,
Some("hideOnNextClick") => After::HideOnNextClick,
Some(c) => Rgba::from_hex(c).map(After::Dim).unwrap_or(After::None),
None => After::None,
};
let mut first_start = 0.0;
for (k, para) in paras.iter().enumerate() {
let mode = if k == 0 {
a.start
} else if a.start == AnimStart::OnClick {
AnimStart::OnClick
} else {
AnimStart::WithPrevious
};
let first_of_seq = steps.is_empty();
let mode = if first_of_seq && matches!(seq, Sequence::Trigger(_)) { AnimStart::OnClick } else { mode };
match mode {
AnimStart::OnClick => {
steps.push(StepInfo { auto: false, duration: 0.0 });
group_begin = 0.0;
max_end = 0.0;
}
AnimStart::WithPrevious => {
if first_of_seq {
steps.push(StepInfo { auto: true, duration: 0.0 });
}
}
AnimStart::AfterPrevious => {
if first_of_seq {
steps.push(StepInfo { auto: true, duration: 0.0 });
}
group_begin = max_end;
}
}
let start = if k > 0 && a.start != AnimStart::OnClick { first_start + k as f64 * PARAGRAPH_STAGGER } else { group_begin + delay };
if k == 0 {
first_start = start;
}
let end = start + span;
max_end = max_end.max(end);
let step = steps.len().saturating_sub(1);
if let Some(s) = steps.last_mut() {
// Media plays on by itself once started: it holds back an After Previous
// effect (`max_end`), but not the next click (#116).
s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end });
}
tl.effects.push(Effect {
timing: EffectTiming {
anim: ai,
shape: a.shape,
paragraph: *para,
sequence: seq,
step,
start,
duration: dur,
iterations,
until_next_click,
auto_reverse: a.auto_reverse,
rewind: a.rewind,
span,
class: a.class,
},
spec: Arc::clone(&spec),
after: after.clone(),
});
}
}
match seq {
Sequence::Main => tl.main = steps,
Sequence::Trigger(s) => tl.triggers.push((s, steps)),
}
}
// Base colours of animated shapes.
let mut colored: HashSet<ShapeId> = HashSet::new();
for e in &tl.effects {
let id = e.timing.shape;
if !colored.insert(id) {
continue;
}
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
Some(Fill::Solid { color }) => resolve(color),
_ => accent,
};
tl.colors.push((id, c));
}
tl
}
/// Number of steps in the main sequence (clicks, plus an automatic first step when the first
/// effect starts with/after previous).
pub fn steps(&self) -> usize {
self.main.len()
}
/// Does main step `i` start by itself (no click), as the slide appears?
pub fn step_is_auto(&self, i: usize) -> bool {
self.main.get(i).is_some_and(|s| s.auto)
}
/// Duration of main step `i` in seconds (0 when out of range).
pub fn step_duration(&self, i: usize) -> f64 {
self.main.get(i).map(|s| s.duration).unwrap_or(0.0)
}
/// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to`
/// seconds into it, in order: what a show running the step from `from` to `to` sets off.
pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec<EffectTiming> {
let mut v: Vec<EffectTiming> = self
.effects
.iter()
.map(|e| &e.timing)
.filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to)
.cloned()
.collect();
v.sort_by(|a, b| a.start.total_cmp(&b.start));
v
}
/// Total duration of the main sequence played without pauses.
pub fn total_duration(&self) -> f64 {
self.main.iter().map(|s| s.duration).sum()
}
/// Map a time on the "play everything" clock to `(step, t)` for [`Timeline::state`].
pub fn locate(&self, time: f64) -> (usize, f64) {
let mut t = finite_or(time, 0.0).max(0.0);
for (i, s) in self.main.iter().enumerate() {
if t < s.duration || (t == 0.0 && s.duration == 0.0 && i == 0) {
return (i, t);
}
t -= s.duration;
}
(self.main.len(), 0.0)
}
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
pub fn start_of(&self, anim: usize) -> Option<f64> {
let e = self
.effects
.iter()
.map(|e| &e.timing)
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
Some(before + e.start)
}
/// Shapes that start an interactive sequence when clicked.
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
self.triggers.iter().map(|(s, _)| *s).collect()
}
/// Steps (clicks on the trigger) of a triggered sequence.
pub fn trigger_steps(&self, trigger: ShapeId) -> usize {
self.triggers.iter().find(|(s, _)| *s == trigger).map(|(_, v)| v.len()).unwrap_or(0)
}
pub fn trigger_step_duration(&self, trigger: ShapeId, i: usize) -> f64 {
self.triggers.iter().find(|(s, _)| *s == trigger).and_then(|(_, v)| v.get(i)).map(|s| s.duration).unwrap_or(0.0)
}
/// All effect timings in sequence order.
pub fn effects(&self) -> Vec<EffectTiming> {
self.effects.iter().map(|e| e.timing.clone()).collect()
}
/// Is anything animated on this slide?
pub fn is_empty(&self) -> bool {
self.effects.is_empty()
}
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
v.sort_unstable();
v.dedup();
v
}
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut v: Vec<ShapeId> = Vec::new();
for e in &self.effects {
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
/// fully played and step `step` has been playing for `t` seconds (`t <= 0`: not started;
/// `step >= steps()`: everything done). Paragraph states are relative to the shape's own state
/// (compose them). Triggered sequences are considered not played.
pub fn state(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64) -> AnimState {
self.state_with(shape, para, step, t, &[])
}
/// [`Timeline::state`] with the playheads of triggered sequences.
pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
self.state_ordered(shape, para, step, t, triggers, &[])
}
/// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps
/// were started (`stamps`), after those of the main sequence. Without stamps they apply in
/// Animation Pane order.
pub fn state_ordered(
&self,
shape: ShapeId,
para: Option<usize>,
step: usize,
t: f64,
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
ordered.sort_by_key(|e| e.timing.anim);
if let Some(e0) = ordered.first()
&& e0.timing.class == AnimClass::Entrance
{
st.visible = false;
}
// Then play in time order: main sequence first, then triggered sequences as they were clicked.
if !stamps.is_empty() {
let when = |e: &Effect| match e.timing.sequence {
Sequence::Main => 0,
Sequence::Trigger(tr) => {
stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1))
}
};
ordered.sort_by_key(|e| (when(e), e.timing.anim));
}
for e in &ordered {
let (pstep, pt) = match e.timing.sequence {
Sequence::Main => (step, t),
Sequence::Trigger(tr) => triggers.iter().find(|p| p.trigger == tr).map(|p| (p.step, p.t)).unwrap_or((0, 0.0)),
};
let cur = st.tint.unwrap_or(base_color);
let revert = e.timing.rewind || e.timing.auto_reverse;
match status(&e.timing, pstep, pt) {
Status::NotStarted => {}
Status::Active(p) => {
if e.timing.class == AnimClass::Entrance || e.timing.class == AnimClass::Exit {
st.visible = true;
}
st = st.compose(&e.spec.eval(p, cur));
}
Status::Done => {
match e.timing.class {
AnimClass::Entrance => st.visible = !revert,
AnimClass::Exit => st.visible = revert,
AnimClass::Emphasis | AnimClass::Path if !revert => {
let end = e.spec.end_state(cur);
st = st.compose(&end);
}
_ => {}
}
match e.after {
After::Hide => st.visible = false,
After::HideOnNextClick if pstep > e.timing.step => st.visible = false,
After::Dim(c) if pstep > e.timing.step => st.tint = Some(c),
_ => {}
}
}
}
}
st.sanitized()
}
/// State on the "play everything" clock (Animation Pane ▸ Play All, preview).
pub fn state_at(&self, shape: ShapeId, para: Option<usize>, time: f64) -> AnimState {
let (s, t) = self.locate(time);
self.state(shape, para, s, t)
}
}
fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
if e.step < step {
return Status::Done;
}
if e.step > step {
return Status::NotStarted;
}
let t = finite_or(t, 0.0);
if t <= 0.0 {
return Status::NotStarted;
}
let local = t - e.start;
if local < 0.0 {
return Status::NotStarted;
}
let dur = e.duration;
if dur.is_nan() || dur <= 0.0 || !dur.is_finite() {
return Status::Done;
}
if !e.until_next_click && local >= e.span {
return Status::Done;
}
let period = if e.auto_reverse { 2.0 * dur } else { dur };
let c = local % period;
let c = if c.is_finite() { c } else { 0.0 };
let p = if c > dur { 2.0 - c / dur } else { c / dur };
Status::Active(p.clamp(0.0, 1.0))
}
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
/// 64 deep); the first shape with an id wins a duplicate.
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
if depth > 64 {
return;
}
for s in shapes {
out.entry(s.id).or_insert(s);
walk(s.children(), depth + 1, out);
}
}
let mut out = HashMap::new();
walk(shapes, 0, &mut out);
out
}
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
EffectSpec {
class: a.class,
effect,
// Sink Down is Rise Up played backwards, leaving downward.
option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() },
accel: a.smooth_start,
decel: a.smooth_end,
bounce: a.bounce_end,
amount: a.amount,
color: a.color.as_ref().map(resolve),
path,
xf,
slide_w: sw,
slide_h: sh,
}
}