Files
deckcraft/crates/anim/src/timeline.rs
T

486 lines
19 KiB
Rust

//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
use deckcraft_model::anim::TextBuild;
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, 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_PARAGRAPHS: usize = 1_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,
}
#[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,
spec: 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();
// 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);
}
}
}
for seq in seqs {
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;
}
if tl.effects.len() >= MAX_EFFECTS {
break;
}
let shape = slide.shape(a.shape);
// Paragraph targets of this animation.
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
(Some(p), _) => vec![Some(p as usize)],
(None, TextBuild::ByParagraph) => {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut owner: Option<usize> = None;
for (i, p) in t.paragraphs.iter().enumerate() {
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
&& !tl.groups.contains(&(a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
}
}
}
if v.is_empty() { vec![None] } else { v }
}
_ => 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 = 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() {
s.duration = s.duration.max(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: spec.clone(),
after: after.clone(),
});
}
}
match seq {
Sequence::Main => tl.main = steps,
Sequence::Trigger(s) => tl.triggers.push((s, steps)),
}
}
// Base colours of animated shapes.
for e in &tl.effects {
let id = e.timing.shape;
if tl.colors.iter().any(|(s, _)| *s == id) {
continue;
}
let c = match slide.shape(id).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)
}
/// 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)
}
/// 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 {
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.
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))
}
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,
option: 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,
}
}