Skip to main content

ffcine_text/
anim.rs

1//! Text animations (`animation.in` / `animation.out`: fade, slide, pop, typewriter, wipe, by
2//! word, line or character with a stagger) and keyframed parameters (`keyframes.position`,
3//! `scale`, `opacity`, `colour`), baked into per-frame overlay placements ([`Motion`]).
4//!
5//! The text is rasterised once per clip; every frame then only moves, scales, crops and tints
6//! pieces of its masks, so animating costs no extra GPU pass.
7
8use std::sync::Arc;
9
10use ffcine_core::anim::{Clock, Track, seconds};
11use ffcine_render::{Mask, Motion, Overlay, OverlayPart};
12use serde_json::{Map, Value};
13
14use crate::layout::{Glyph, Rendered, Role};
15
16/// The shape of a move over its progress `u` (0..1): `ffcine_core::anim::Ease`.
17pub use ffcine_core::anim::Ease;
18
19/// What an entrance or exit does.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Kind {
22    Fade,
23    /// Moves in from (out towards) `direction`, fading.
24    Slide,
25    /// Scales up from `scale` with an overshoot, fading.
26    Pop,
27    /// Shows one unit after another, no fade; `duration` is the whole typing time.
28    Typewriter,
29    /// Reveals (hides) along `direction`.
30    Wipe,
31    None,
32}
33
34/// The pieces a text animates in, each after the previous by `stagger`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum By {
37    Char,
38    Word,
39    Line,
40    Whole,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Dir {
45    Up,
46    Down,
47    Left,
48    Right,
49    /// Wipes from the middle out.
50    Center,
51}
52
53impl Dir {
54    fn vector(self) -> [f32; 2] {
55        match self {
56            Self::Up => [0.0, -1.0],
57            Self::Down => [0.0, 1.0],
58            Self::Left => [-1.0, 0.0],
59            Self::Right => [1.0, 0.0],
60            Self::Center => [0.0, 0.0],
61        }
62    }
63}
64
65/// One of `animation.in` / `animation.out`.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct Anim {
68    pub kind: Kind,
69    /// Seconds per unit (typewriter: for all of them).
70    pub duration: f64,
71    pub by: By,
72    /// Seconds between units; `None` picks one for `by`.
73    pub stagger: Option<f64>,
74    pub ease: Ease,
75    /// The way the text moves (slide) or the reveal travels (wipe).
76    pub direction: Dir,
77    /// Slide distance, fraction of the frame height.
78    pub distance: f32,
79    /// Pop's starting scale.
80    pub scale: f32,
81}
82
83impl Anim {
84    /// A plain fade over `duration` seconds.
85    pub fn fade(duration: f64) -> Self {
86        Self {
87            kind: Kind::Fade,
88            duration,
89            by: By::Whole,
90            stagger: None,
91            ease: Ease::Linear,
92            direction: Dir::Up,
93            distance: 0.04,
94            scale: 0.6,
95        }
96    }
97
98    /// Reads `{kind, duration, by, stagger, ease, direction, distance, scale}`. Unknown kinds
99    /// (another app's names) fade.
100    pub fn parse(v: &Map<String, Value>) -> Self {
101        let kind = match v.get("kind").and_then(Value::as_str).unwrap_or("fade") {
102            "slide" | "slide_up" | "rise" => Kind::Slide,
103            "pop" | "scale" | "zoom" => Kind::Pop,
104            "typewriter" | "type" => Kind::Typewriter,
105            "wipe" | "reveal" => Kind::Wipe,
106            "none" | "cut" => Kind::None,
107            _ => Kind::Fade,
108        };
109        let by = match v.get("by").and_then(Value::as_str) {
110            Some("char" | "character" | "letter") => By::Char,
111            Some("word") => By::Word,
112            Some("line") => By::Line,
113            None if kind == Kind::Typewriter => By::Char,
114            _ => By::Whole,
115        };
116        let ease = v.get("ease").and_then(Ease::parse).unwrap_or(match kind {
117            Kind::Slide => Ease::Out,
118            Kind::Pop => Ease::Back,
119            Kind::Wipe => Ease::InOut,
120            _ => Ease::Linear,
121        });
122        let direction = match v.get("direction").and_then(Value::as_str) {
123            Some("down") => Dir::Down,
124            Some("left") => Dir::Left,
125            Some("right") => Dir::Right,
126            Some("center" | "centre") => Dir::Center,
127            Some("up") => Dir::Up,
128            _ if kind == Kind::Wipe => Dir::Right,
129            _ => Dir::Up,
130        };
131        #[allow(clippy::cast_possible_truncation)]
132        let num = |k: &str| v.get(k).and_then(Value::as_f64).map(|x| x as f32);
133        Self {
134            kind,
135            duration: v
136                .get("duration")
137                .and_then(Value::as_f64)
138                .unwrap_or(0.5)
139                .max(0.0),
140            by,
141            stagger: v.get("stagger").and_then(Value::as_f64),
142            ease,
143            direction,
144            distance: num("distance").unwrap_or(0.04),
145            scale: num("scale").unwrap_or(0.6),
146        }
147    }
148
149    fn stagger(&self, n: usize) -> f64 {
150        #[allow(clippy::cast_precision_loss)]
151        match (self.kind, self.stagger) {
152            (_, Some(s)) => s,
153            (Kind::Typewriter, None) => self.duration / n.max(1) as f64,
154            (_, None) => match self.by {
155                By::Char => 0.03,
156                By::Word => 0.1,
157                By::Line => 0.2,
158                By::Whole => 0.0,
159            },
160        }
161    }
162}
163
164/// A keyframed value: `[{t, v, interp}]` (the reframe keyframes' form), `t` seconds from the
165/// clip's start (negative: from its end), `interp` shaping the move to the next key. Also an
166/// expression over `lt` and `dur` (`docs/compositing.md#expressions`), per component in an
167/// array, or `{"keys", "expr"}`: an `ffcine_core::anim::Track` on [`Clock::Clip`].
168#[derive(Debug, Clone, PartialEq, Default)]
169pub struct Keys<const N: usize> {
170    track: Option<Track<[f64; N]>>,
171}
172
173impl<const N: usize> Keys<N> {
174    /// Reads keys whose values are numbers (`N` = 1 also takes a bare number; a number
175    /// for `N` = 2 fills both, for scale; colours may leave out alpha). Keys that don't read
176    /// are skipped; an expression that doesn't compile is dropped with a warning.
177    #[allow(clippy::many_single_char_names)]
178    fn parse(v: Option<&Value>) -> Self {
179        let Some(v) = v else {
180            return Self::default();
181        };
182        // A key's value: numbers only, others in an array skipped (as before tracks).
183        let value = |v: &Value| -> Option<Value> {
184            Some(match v {
185                Value::Number(n) => Value::Array(vec![Value::from(n.as_f64()?); N]),
186                Value::Array(a) => {
187                    let c: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
188                    match (N, c.len()) {
189                        (4, 3) => Value::from([c[0], c[1], c[2], 1.0].to_vec()),
190                        (n, l) if n == l => Value::from(c),
191                        _ => return None,
192                    }
193                }
194                _ => return None,
195            })
196        };
197        // A whole value: numbers or expressions per component, a colour's alpha defaulting to 1.
198        let whole = |v: &Value| -> Value {
199            match v {
200                Value::Number(_) => value(v).unwrap_or_else(|| v.clone()),
201                Value::Array(a) if N == 4 && a.len() == 3 => {
202                    let mut a = a.clone();
203                    a.push(1.0.into());
204                    Value::Array(a)
205                }
206                _ => v.clone(),
207            }
208        };
209        let keys = |a: &[Value]| -> Value {
210            a.iter()
211                .filter_map(|k| {
212                    let t = k.get("t").and_then(seconds)?;
213                    let v = value(k.get("v").or_else(|| k.get("value"))?)?;
214                    let mut o = Map::new();
215                    o.insert("t".into(), t.into());
216                    o.insert("v".into(), v);
217                    // Unknown names are linear, as before.
218                    if let Some(e) = k.get("interp").or_else(|| k.get("ease"))
219                        && Ease::parse(e).is_some()
220                    {
221                        o.insert("interp".into(), e.clone());
222                    }
223                    Some(Value::Object(o))
224                })
225                .collect()
226        };
227        // Any key object makes it keys; those that don't read are skipped, as before tracks.
228        let norm = match v {
229            Value::Array(a) if a.iter().any(Value::is_object) => keys(a),
230            Value::Object(o) if o.contains_key("expr") => {
231                let mut o = o.clone();
232                if let Some(Value::Array(a)) = o.get("keys") {
233                    let k = keys(a);
234                    o.insert("keys".into(), k);
235                }
236                Value::Object(o)
237            }
238            _ => whole(v),
239        };
240        if norm.as_array().is_some_and(Vec::is_empty) {
241            return Self::default();
242        }
243        match Track::parse(&norm, Clock::Clip, "text keyframes") {
244            Ok(t) => Self { track: Some(t) },
245            Err(e) => {
246                eprintln!("warning: {e}");
247                Self::default()
248            }
249        }
250    }
251
252    pub fn is_empty(&self) -> bool {
253        self.track.is_none()
254    }
255
256    /// The value at `t` seconds into a clip `len` seconds long.
257    pub fn at(&self, t: f64, len: f64) -> Option<[f64; N]> {
258        self.track.as_ref()?.at(Clock::Clip, t, len)
259    }
260
261    /// The largest component over a clip `len` seconds long: the keys' values, or an
262    /// expression sampled on every frame at `fps` (at most [`MAX_SAMPLES`] samples).
263    fn max(&self, len: f64, fps: f64) -> Option<f64> {
264        let t = self.track.as_ref()?;
265        match t {
266            Track::Const(c) => c.iter().copied().reduce(f64::max),
267            Track::Keys(k) => k.iter().flat_map(|k| k.v.iter().copied()).reduce(f64::max),
268            _ => {
269                #[allow(
270                    clippy::cast_possible_truncation,
271                    clippy::cast_sign_loss,
272                    clippy::cast_precision_loss
273                )]
274                let n = ((len.max(0.0) * fps.max(1.0)).ceil() as usize).clamp(1, MAX_SAMPLES);
275                #[allow(clippy::cast_precision_loss)]
276                (0..=n)
277                    .filter_map(|i| self.at(len * i as f64 / n as f64, len))
278                    .flatten()
279                    .reduce(f64::max)
280            }
281        }
282    }
283}
284
285/// Most frames an expression scale is sampled on for [`Animation::raster_scale_over`].
286const MAX_SAMPLES: usize = 100_000;
287
288/// A clip's animation: its entrance, exit and keyframed parameters.
289#[derive(Debug, Clone, PartialEq, Default)]
290pub struct Animation {
291    pub in_: Option<Anim>,
292    pub out: Option<Anim>,
293    /// Absolute `position` (fractions of the frame from its middle).
294    pub position: Keys<2>,
295    /// Scale about the anchor, `[x, y]`.
296    pub scale: Keys<2>,
297    /// Multiplies the alpha.
298    pub opacity: Keys<1>,
299    /// Replaces the text's colour.
300    pub colour: Keys<4>,
301}
302
303impl Animation {
304    /// From merged clip parameters: `animation.{in,out}` (else `fade_in` / `fade_out`) and
305    /// `keyframes.{position,scale,opacity,colour}`.
306    pub fn parse(m: &Map<String, Value>) -> Self {
307        let side = |dir: &str, flat: &str| {
308            match m.get("animation").and_then(|a| a.get(dir)) {
309                Some(Value::Object(o)) => Some(Anim::parse(o)),
310                _ => m.get(flat).and_then(Value::as_f64).map(Anim::fade),
311            }
312            .filter(|a| a.kind != Kind::None && a.duration > 0.0)
313        };
314        let k = m.get("keyframes");
315        Self {
316            in_: side("in", "fade_in"),
317            out: side("out", "fade_out"),
318            position: Keys::parse(k.and_then(|k| k.get("position"))),
319            scale: Keys::parse(k.and_then(|k| k.get("scale"))),
320            opacity: Keys::parse(k.and_then(|k| k.get("opacity"))),
321            colour: Keys::parse(k.and_then(|k| k.get("colour").or_else(|| k.get("color")))),
322        }
323    }
324
325    /// Whether the overlay's own linear fades draw it (no motion needed).
326    pub fn is_plain_fade(&self) -> bool {
327        let plain = |a: &Option<Anim>| {
328            a.is_none_or(|a| a.kind == Kind::Fade && a.by == By::Whole && a.ease == Ease::Linear)
329        };
330        plain(&self.in_)
331            && plain(&self.out)
332            && self.position.is_empty()
333            && self.scale.is_empty()
334            && self.opacity.is_empty()
335            && self.colour.is_empty()
336    }
337
338    /// The largest scale keyframed (at least 1, at most 4): rasterise at it so the text stays
339    /// sharp. An expression scale is read at the clip's start only: use
340    /// [`Animation::raster_scale_over`] when the clip's length is known.
341    pub fn raster_scale(&self) -> f32 {
342        self.raster_scale_over(0.0, 1.0)
343    }
344
345    /// [`Animation::raster_scale`] for a clip `len` seconds long at `fps`: an expression scale
346    /// is sampled on every frame.
347    #[allow(clippy::cast_possible_truncation)]
348    pub fn raster_scale_over(&self, len: f64, fps: f64) -> f32 {
349        self.scale
350            .max(len, fps)
351            .map_or(1.0, |s| s.clamp(1.0, 4.0) as f32)
352    }
353}
354
355/// A piece of the text: a crop of the masks (mask px) and its group under each animation.
356#[derive(Debug, Clone, Copy)]
357struct Unit {
358    crop: [f32; 4],
359    line: usize,
360    group: [usize; 2],
361}
362
363/// Splits the masks of `r` into units of `by`, cropping each line into adjacent pieces so the
364/// pieces tile the masks exactly.
365fn units(r: &Rendered, mask: [f32; 2], by: By) -> Vec<(Unit, usize)> {
366    let pad = r.pad;
367    let n_lines = r.lines.len().saturating_sub(1);
368    if by == By::Whole || r.glyphs.is_empty() || n_lines == 0 {
369        return vec![(
370            Unit {
371                crop: [0.0, 0.0, mask[0], mask[1]],
372                line: 0,
373                group: [0; 2],
374            },
375            0,
376        )];
377    }
378    let band = |l: usize| {
379        let y0 = if l == 0 { 0.0 } else { pad + r.lines[l] };
380        let y1 = if l + 1 >= n_lines {
381            mask[1]
382        } else {
383            pad + r.lines[l + 1]
384        };
385        (y0, y1)
386    };
387    if by == By::Line {
388        return (0..n_lines)
389            .map(|l| {
390                let (y0, y1) = band(l);
391                (
392                    Unit {
393                        crop: [0.0, y0, mask[0], y1],
394                        line: l,
395                        group: [0; 2],
396                    },
397                    l,
398                )
399            })
400            .collect();
401    }
402    // Pieces: characters, or words (a word keeps its trailing spaces).
403    let mut pieces: Vec<(usize, f32)> = Vec::new();
404    let mut prev: Option<&Glyph> = None;
405    for g in &r.glyphs {
406        let starts = match prev {
407            None => true,
408            Some(p) if p.line != g.line => true,
409            Some(p) => by == By::Char || (p.space && !g.space),
410        };
411        if starts {
412            pieces.push((g.line, pad + g.x[0]));
413        }
414        prev = Some(g);
415    }
416    (0..pieces.len())
417        .map(|i| {
418            let (line, x) = pieces[i];
419            let first = i == 0 || pieces[i - 1].0 != line;
420            let next = pieces.get(i + 1).filter(|p| p.0 == line);
421            let (y0, y1) = band(line);
422            (
423                Unit {
424                    crop: [
425                        if first { 0.0 } else { x },
426                        y0,
427                        next.map_or(mask[0], |p| p.1),
428                        y1,
429                    ],
430                    line,
431                    group: [0; 2],
432                },
433                i,
434            )
435        })
436        .collect()
437}
438
439/// The finest units both animations need, with each one's group index under each.
440fn partition(r: &Rendered, mask: [f32; 2], by: [By; 2]) -> (Vec<Unit>, [usize; 2]) {
441    let fine = by[0].min(by[1]);
442    let base = units(r, mask, fine);
443    let mut counts = [1usize; 2];
444    let mut out: Vec<Unit> = base.iter().map(|(u, _)| *u).collect();
445    for side in 0..2 {
446        if by[side] == fine {
447            for (o, (_, i)) in out.iter_mut().zip(&base) {
448                o.group[side] = *i;
449            }
450            counts[side] = base.len();
451            continue;
452        }
453        // Group fine units by the coarser unit containing their left edge.
454        let coarse = units(r, mask, by[side]);
455        for o in &mut out {
456            let cx = f32::midpoint(o.crop[0], o.crop[2]);
457            o.group[side] = coarse
458                .iter()
459                .find(|(c, _)| c.line == o.line && cx >= c.crop[0] && cx <= c.crop[2])
460                .or_else(|| {
461                    coarse
462                        .iter()
463                        .find(|(c, _)| by[side] == By::Whole || c.line == o.line)
464                })
465                .map_or(0, |(_, i)| *i);
466        }
467        counts[side] = coarse.len();
468    }
469    (out, counts)
470}
471
472/// Placement of a piece in one frame: scale `k` about `centre`, then offset `d` (raster px),
473/// a crop fraction from a wipe, and an alpha.
474#[derive(Debug, Clone, Copy, PartialEq)]
475struct Look {
476    k: f32,
477    d: [f32; 2],
478    alpha: f32,
479    /// Visible part along the wipe: `[start, end]` fractions of the crop, and its axis.
480    wipe: Option<([f32; 2], bool)>,
481}
482
483const REST: Look = Look {
484    k: 1.0,
485    d: [0.0, 0.0],
486    alpha: 1.0,
487    wipe: None,
488};
489
490/// The look of group `g` (of `n`) at `t` seconds into a clip showing for `len` seconds.
491#[allow(
492    clippy::cast_possible_truncation,
493    clippy::cast_precision_loss,
494    clippy::many_single_char_names
495)]
496fn look(anim: &Animation, g: [usize; 2], n: [usize; 2], t: f64, len: f64, fh: f32) -> Look {
497    let mut l = REST;
498    for (side, a) in [(0, &anim.in_), (1, &anim.out)] {
499        let Some(a) = a else { continue };
500        let s = a.stagger(n[side]);
501        let (gi, ni) = (g[side] as f64, n[side] as f64);
502        let raw = if a.kind == Kind::Typewriter {
503            let shown = if side == 0 {
504                t >= gi * s
505            } else {
506                // Backspacing: the last unit goes first.
507                t < len - a.duration + (ni - 1.0 - gi) * s
508            };
509            if shown { 1.0 } else { 0.0 }
510        } else {
511            let rel = if side == 0 {
512                t - gi * s
513            } else {
514                len - t - (ni - 1.0 - gi) * s
515            };
516            if a.duration > 0.0 {
517                (rel / a.duration).clamp(0.0, 1.0)
518            } else {
519                1.0
520            }
521        };
522        if raw >= 1.0 {
523            continue;
524        }
525        let p = a.ease.at(raw) as f32;
526        let raw = raw as f32;
527        match a.kind {
528            Kind::Fade => l.alpha *= p.clamp(0.0, 1.0),
529            Kind::Typewriter => l.alpha *= raw,
530            Kind::Slide => {
531                let v = a.direction.vector();
532                let sign = if side == 0 { -1.0 } else { 1.0 };
533                let dist = sign * (1.0 - p) * a.distance * fh;
534                l.d = [l.d[0] + v[0] * dist, l.d[1] + v[1] * dist];
535                l.alpha *= raw;
536            }
537            Kind::Pop => {
538                l.k *= a.scale + (1.0 - a.scale) * p;
539                l.alpha *= raw;
540            }
541            Kind::Wipe => {
542                let p = p.clamp(0.0, 1.0);
543                let horizontal = matches!(a.direction, Dir::Left | Dir::Right | Dir::Center);
544                // Exits keep the far side: the edge keeps travelling the same way.
545                let span = match (a.direction, side) {
546                    (Dir::Center, _) => [0.5 - p / 2.0, 0.5 + p / 2.0],
547                    (Dir::Right | Dir::Down, 0) | (Dir::Left | Dir::Up, 1) => [0.0, p],
548                    _ => [1.0 - p, 1.0],
549                };
550                let prev = l.wipe.map_or([0.0, 1.0], |w| w.0);
551                l.wipe = Some(([prev[0].max(span[0]), prev[1].min(span[1])], horizontal));
552            }
553            Kind::None => {}
554        }
555    }
556    l
557}
558
559/// Crops `src` (and the matching `dst`) to a wipe's visible span.
560fn wiped(
561    src: [f32; 4],
562    dst: [f32; 4],
563    w: Option<([f32; 2], bool)>,
564) -> Option<([f32; 4], [f32; 4])> {
565    let Some(([a, b], horizontal)) = w else {
566        return Some((src, dst));
567    };
568    if b <= a {
569        return None;
570    }
571    let cut = |r: [f32; 4], i: usize| {
572        let len = r[i + 2] - r[i];
573        let mut o = r;
574        o[i] = r[i] + a * len;
575        o[i + 2] = r[i] + b * len;
576        o
577    };
578    let i = usize::from(!horizontal);
579    Some((cut(src, i), cut(dst, i)))
580}
581
582/// Scales `r` by `k` about `c`, then moves it by `d`.
583fn transform(r: [f32; 4], k: [f32; 2], c: [f32; 2], d: [f32; 2]) -> [f32; 4] {
584    [
585        c[0] + (r[0] - c[0]) * k[0] + d[0],
586        c[1] + (r[1] - c[1]) * k[1] + d[1],
587        c[0] + (r[2] - c[0]) * k[0] + d[0],
588        c[1] + (r[3] - c[1]) * k[1] + d[1],
589    ]
590}
591
592/// The timing of a clip in a segment, in frames relative to the segment's first frame.
593#[derive(Debug, Clone, Copy)]
594pub struct Span {
595    /// First and end (exclusive) frame the text shows (after its `delay`).
596    pub from: i64,
597    pub to: i64,
598    /// Frames in the segment.
599    pub frames: i64,
600    pub rate: f64,
601    /// Seconds from the clip's start to `from` (keyframe times count from the clip's start).
602    pub delay: f64,
603}
604
605/// `r`'s layers as overlays whose placements follow `anim` frame by frame. `r` was laid out at
606/// `raster` times its size (see [`Animation::raster_scale`]) with its anchor on `a` (output
607/// px); `base` is the text's `position` and `frame` the output size.
608#[allow(
609    clippy::cast_possible_truncation,
610    clippy::cast_precision_loss,
611    clippy::too_many_lines,
612    clippy::many_single_char_names
613)]
614pub fn animate(
615    r: &Rendered,
616    anim: &Animation,
617    raster: f32,
618    a: [f32; 2],
619    base: [f32; 2],
620    frame: [f32; 2],
621    span: Span,
622) -> Vec<Overlay> {
623    let [fw, fh] = frame;
624    let Some(fill) = r
625        .layers
626        .iter()
627        .find(|l| l.role != Role::Box)
628        .or_else(|| r.layers.first())
629    else {
630        return Vec::new();
631    };
632    let at = fill.parts.first().map_or(r.origin, |p| p.pos);
633    let mask = [fill.mask.width as f32, fill.mask.height as f32];
634    let by = [
635        anim.in_.map_or(By::Whole, |a| a.by),
636        anim.out.map_or(By::Whole, |a| a.by),
637    ];
638    let solid = r.glyphs.is_empty();
639    let (units, counts) = partition(r, mask, by);
640    // Each group's centre (pops scale about it), raster px, from the fill's placement.
641    let centre = |side: usize, g: usize| {
642        let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
643        for u in units.iter().filter(|u| u.group[side] == g) {
644            b = [
645                b[0].min(u.crop[0]),
646                b[1].min(u.crop[1]),
647                b[2].max(u.crop[2]),
648                b[3].max(u.crop[3]),
649            ];
650        }
651        [
652            at[0] + f32::midpoint(b[0], b[2]),
653            at[1] + f32::midpoint(b[1], b[3]),
654        ]
655    };
656    // Pops scale about their own piece.
657    let side = usize::from(
658        anim.in_.is_none_or(|a| a.kind != Kind::Pop)
659            && anim.out.is_some_and(|a| a.kind == Kind::Pop),
660    );
661    let centres: Vec<[f32; 2]> = units.iter().map(|u| centre(side, u.group[side])).collect();
662    let box_centre = [r.origin[0] + r.size[0] / 2.0, r.origin[1] + r.size[1] / 2.0];
663    let len = (span.to - span.from) as f64 / span.rate;
664    let lo = span.from.max(0);
665    let hi = span.to.min(span.frames).max(lo + 1);
666    let mut frames: Vec<Vec<Vec<OverlayPart>>> = vec![Vec::new(); r.layers.len()];
667    for f in lo..hi {
668        let t = (f - span.from) as f64 / span.rate + 0.5 / span.rate;
669        let kt = t + span.delay;
670        let klen = len + span.delay;
671        let g = anim
672            .scale
673            .at(kt, klen)
674            .map_or([1.0, 1.0], |s| s.map(|v| v as f32));
675        let g = [g[0] / raster, g[1] / raster];
676        let move_ = anim.position.at(kt, klen).map_or([0.0, 0.0], |p| {
677            [(p[0] as f32 - base[0]) * fw, (p[1] as f32 - base[1]) * fh]
678        });
679        let opacity = anim.opacity.at(kt, klen).map_or(1.0, |o| o[0] as f32);
680        let colour = anim.colour.at(kt, klen).map(|c| c.map(|v| v as f32));
681        let looks: Vec<Look> = units
682            .iter()
683            .map(|u| look(anim, u.group, counts, t, len, fh * raster))
684            .collect();
685        let whole = by == [By::Whole; 2];
686        for (li, layer) in r.layers.iter().enumerate() {
687            let tint = |p: &OverlayPart, alpha: f32| {
688                let mut c = p.colour;
689                if let Some(k) = colour
690                    .filter(|_| layer.role == Role::Fill || (solid && layer.role == Role::Box))
691                {
692                    c = k;
693                }
694                c[3] *= alpha * opacity;
695                c
696            };
697            let mut parts: Vec<OverlayPart> = Vec::new();
698            for p in &layer.parts {
699                if layer.role == Role::Box {
700                    // The box (or a rect clip) moves with the text when it animates whole;
701                    // otherwise it shows as soon as any piece does.
702                    let (src, dst) = p.rects(&layer.mask);
703                    let l = if whole || solid {
704                        looks[0]
705                    } else {
706                        Look {
707                            alpha: looks.iter().map(|l| l.alpha).fold(0.0, f32::max),
708                            ..REST
709                        }
710                    };
711                    let Some((src, dst)) = wiped(src, dst, l.wipe) else {
712                        continue;
713                    };
714                    let dst = transform(dst, [l.k, l.k], box_centre, l.d);
715                    let dst = transform(dst, g, a, move_);
716                    let colour = tint(p, l.alpha);
717                    if colour[3] > 1.0 / 512.0 {
718                        parts.push(OverlayPart {
719                            pos: [dst[0], dst[1]],
720                            size: Some([dst[2] - dst[0], dst[3] - dst[1]]),
721                            // A stretched 1x1 solid: the crop changes nothing.
722                            src: p.src.map(|_| src),
723                            colour,
724                        });
725                    }
726                    continue;
727                }
728                // Merge neighbours on a line that look the same (typed text, settled words).
729                let mut run: Option<(Look, usize, [f32; 4])> = None;
730                let flush = |run: Option<(Look, usize, [f32; 4])>, parts: &mut Vec<OverlayPart>| {
731                    let Some((l, ui, crop)) = run else { return };
732                    let dst = [
733                        p.pos[0] + crop[0],
734                        p.pos[1] + crop[1],
735                        p.pos[0] + crop[2],
736                        p.pos[1] + crop[3],
737                    ];
738                    let Some((src, dst)) = wiped(crop, dst, l.wipe) else {
739                        return;
740                    };
741                    let dst = transform(dst, [l.k, l.k], centres[ui], l.d);
742                    let dst = transform(dst, g, a, move_);
743                    let colour = tint(p, l.alpha);
744                    if colour[3] > 1.0 / 512.0 {
745                        parts.push(OverlayPart {
746                            pos: [dst[0], dst[1]],
747                            size: Some([dst[2] - dst[0], dst[3] - dst[1]]),
748                            src: Some(src),
749                            colour,
750                        });
751                    }
752                };
753                for (ui, (u, l)) in units.iter().zip(&looks).enumerate() {
754                    match &mut run {
755                        Some((rl, rui, crop))
756                            if rl == l
757                                && (l.k - 1.0).abs() < f32::EPSILON
758                                && l.wipe.is_none()
759                                && units[*rui].line == u.line
760                                && (crop[2] - u.crop[0]).abs() < 1e-3 =>
761                        {
762                            crop[2] = u.crop[2];
763                        }
764                        _ => {
765                            flush(run.take(), &mut parts);
766                            run = Some((*l, ui, u.crop));
767                        }
768                    }
769                }
770                flush(run, &mut parts);
771            }
772            frames[li].push(parts);
773        }
774    }
775    r.layers
776        .iter()
777        .zip(frames)
778        .map(|(l, frames)| Overlay {
779            mask: Arc::clone(&l.mask),
780            parts: frames.last().cloned().unwrap_or_default(),
781            from: span.from,
782            to: span.to,
783            fade_in: 0.0,
784            fade_out: 0.0,
785            motion: Some(Arc::new(Motion { first: lo, frames })),
786            under: None,
787        })
788        .collect()
789}
790
791/// A filled rectangle `size` px with its `anchor` on `base` (a `rect` clip), as a layout.
792pub fn rect(
793    size: [f32; 2],
794    base: [f32; 2],
795    anchor: [f32; 2],
796    frame: [f32; 2],
797    colour: [f32; 4],
798) -> Rendered {
799    let a = [(base[0] + 0.5) * frame[0], (base[1] + 0.5) * frame[1]];
800    let origin = [a[0] - anchor[0] * size[0], a[1] - anchor[1] * size[1]];
801    Rendered {
802        layers: vec![crate::layout::Layer {
803            mask: Arc::new(Mask::solid()),
804            parts: vec![OverlayPart {
805                pos: origin,
806                size: Some(size),
807                src: None,
808                colour,
809            }],
810            role: Role::Box,
811        }],
812        origin,
813        size,
814        pad: 0.0,
815        lines: vec![0.0, size[1]],
816        glyphs: Vec::new(),
817    }
818}
819
820#[cfg(test)]
821#[allow(
822    clippy::cast_precision_loss,
823    clippy::cast_possible_truncation,
824    clippy::cast_sign_loss,
825    clippy::many_single_char_names
826)]
827mod tests {
828    use super::*;
829    use crate::layout::Layer;
830    use serde_json::json;
831
832    #[allow(clippy::needless_pass_by_value)]
833    fn obj(v: Value) -> Map<String, Value> {
834        v.as_object().cloned().unwrap_or_default()
835    }
836
837    /// A 100x20 text box at (50, 40) of five 20 px "glyphs", `ab cd` (one line), opaque.
838    fn text_box() -> Rendered {
839        let mask = Mask {
840            width: 100,
841            height: 20,
842            data: vec![255; 2000],
843        };
844        let glyphs = (0..5)
845            .map(|i| Glyph {
846                line: 0,
847                x: [i as f32 * 20.0, i as f32 * 20.0 + 20.0],
848                space: i == 2,
849            })
850            .collect();
851        Rendered {
852            layers: vec![Layer {
853                mask: Arc::new(mask),
854                parts: vec![OverlayPart {
855                    pos: [50.0, 40.0],
856                    colour: [1.0; 4],
857                    ..OverlayPart::default()
858                }],
859                role: Role::Fill,
860            }],
861            origin: [50.0, 40.0],
862            size: [100.0, 20.0],
863            pad: 0.0,
864            lines: vec![0.0, 20.0],
865            glyphs,
866        }
867    }
868
869    /// Alpha of a 200x100 frame with `ov` drawn (nearest sampling): the CPU reference of the
870    /// GPU pass, for checking frames.
871    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
872    fn coverage(ov: &[Overlay], frame: i64) -> Vec<f32> {
873        let (w, h) = (200usize, 100usize);
874        let mut out = vec![0.0f32; w * h];
875        for o in ov {
876            let fade = o.opacity(frame);
877            for p in o.parts_at(frame) {
878                let (s, d) = p.rects(&o.mask);
879                for y in 0..h {
880                    for x in 0..w {
881                        let (cx, cy) = (x as f32 + 0.5, y as f32 + 0.5);
882                        if cx < d[0] || cx >= d[2] || cy < d[1] || cy >= d[3] {
883                            continue;
884                        }
885                        let u = s[0] + (cx - d[0]) / (d[2] - d[0]) * (s[2] - s[0]);
886                        let v = s[1] + (cy - d[1]) / (d[3] - d[1]) * (s[3] - s[1]);
887                        let m = o.mask.data[(v as usize).min(o.mask.height as usize - 1)
888                            * o.mask.width as usize
889                            + (u as usize).min(o.mask.width as usize - 1)];
890                        let a = f32::from(m) / 255.0 * p.colour[3] * fade;
891                        let px = &mut out[y * w + x];
892                        *px = *px + a * (1.0 - *px);
893                    }
894                }
895            }
896        }
897        out
898    }
899
900    fn span(frames: i64) -> Span {
901        Span {
902            from: 0,
903            to: frames,
904            frames,
905            rate: 10.0,
906            delay: 0.0,
907        }
908    }
909
910    fn run(params: Value, frames: i64) -> Vec<Overlay> {
911        let a = Animation::parse(&obj(params));
912        animate(
913            &text_box(),
914            &a,
915            1.0,
916            [100.0, 50.0],
917            [0.0, 0.0],
918            [200.0, 100.0],
919            span(frames),
920        )
921    }
922
923    #[test]
924    fn eases() {
925        for e in [
926            Ease::Linear,
927            Ease::In,
928            Ease::Out,
929            Ease::InOut,
930            Ease::Ease,
931            Ease::Back,
932        ] {
933            assert!(
934                e.at(0.0).abs() < 1e-9 && (e.at(1.0) - 1.0).abs() < 1e-9,
935                "{e:?}"
936            );
937        }
938        assert!(Ease::Back.at(0.7) > 1.0, "overshoots");
939        assert!(Ease::Out.at(0.5) > 0.5 && Ease::In.at(0.5) < 0.5);
940        assert!((Ease::Bezier([0.0, 0.0, 1.0, 1.0]).at(0.3) - 0.3).abs() < 1e-4);
941        assert_eq!(
942            Ease::parse(&json!([0.4, 0, 0.2, 1])),
943            Some(Ease::Bezier([0.4, 0.0, 0.2, 1.0]))
944        );
945        assert!(Ease::Hold.at(0.99).abs() < 1e-9);
946    }
947
948    #[test]
949    fn keyframes_interpolate_and_count_from_the_end() {
950        let k: Keys<2> = Keys::parse(Some(&json!([
951            {"t": 0, "v": [0.0, 0.0], "interp": "linear"},
952            {"t": {"value": 24, "rate": 24}, "v": [1.0, 2.0]},
953            {"t": -1, "v": [3.0, 3.0]},
954        ])));
955        assert_eq!(k.at(-1.0, 10.0), Some([0.0, 0.0]));
956        assert_eq!(k.at(0.5, 10.0), Some([0.5, 1.0]));
957        assert_eq!(k.at(5.0, 10.0), Some([2.0, 2.5]));
958        assert_eq!(k.at(20.0, 10.0), Some([3.0, 3.0]));
959        let s: Keys<2> = Keys::parse(Some(&json!([{"t": 0, "v": 2}])));
960        assert_eq!(s.at(0.0, 1.0), Some([2.0, 2.0]));
961    }
962
963    #[test]
964    fn parses_and_tells_plain_fades() {
965        let a = Animation::parse(&obj(json!({"fade_in": 0.5})));
966        assert!(a.is_plain_fade());
967        let a = Animation::parse(&obj(
968            json!({"animation": {"in": {"kind": "typewriter", "duration": 1}}}),
969        ));
970        assert!(!a.is_plain_fade());
971        let t = a.in_.unwrap();
972        assert_eq!((t.kind, t.by), (Kind::Typewriter, By::Char));
973        let a = Animation::parse(&obj(json!({"animation": {"in": {"kind": "rotate3d"}}})));
974        assert_eq!(a.in_.map(|a| a.kind), Some(Kind::Fade));
975        let a = Animation::parse(&obj(
976            json!({"keyframes": {"scale": [{"t": 0, "v": 1}, {"t": 1, "v": 2.5}]}}),
977        ));
978        assert!((a.raster_scale() - 2.5).abs() < 1e-6);
979    }
980
981    #[test]
982    fn typewriter_types_left_to_right_and_merges() {
983        // 5 characters over 1 s at 10 fps: one every 2 frames.
984        let ov = run(
985            json!({"animation": {"in": {"kind": "typewriter", "duration": 1}}}),
986            30,
987        );
988        let c = coverage(&ov, 4);
989        let row = |c: &[f32], x: usize| c[50 * 200 + x];
990        // Frame 4 (0.45 s): 3 characters typed: x 50..110 shown, 110.. not yet.
991        assert!(row(&c, 55) > 0.99 && row(&c, 105) > 0.99);
992        assert!(row(&c, 115) < 0.01);
993        // Typed characters are one part, not one each.
994        assert_eq!(ov[0].parts_at(4).len(), 1);
995        assert!(row(&coverage(&ov, 20), 145) > 0.99);
996    }
997
998    #[test]
999    fn slide_by_word_staggers() {
1000        let ov = run(
1001            json!({"animation": {"in": {"kind": "slide", "by": "word", "duration": 1, "stagger": 0.5}}}),
1002            30,
1003        );
1004        assert_eq!(ov[0].parts_at(2).len(), 1, "the second word not yet");
1005        let p = ov[0].parts_at(7);
1006        assert_eq!(p.len(), 2, "two words");
1007        // The first word is further along: higher up (sliding up) and more opaque.
1008        assert!(p[0].pos[1] < p[1].pos[1]);
1009        assert!(p[0].colour[3] > p[1].colour[3]);
1010        assert!(p[0].pos[1] > 40.0, "still below its place");
1011        // Settled: whole again, in place.
1012        let p = ov[0].parts_at(20);
1013        assert_eq!(p.len(), 1);
1014        assert_eq!(p[0].pos, [50.0, 40.0]);
1015    }
1016
1017    #[test]
1018    fn pop_scales_about_the_centre_and_overshoots() {
1019        let ov = run(
1020            json!({"animation": {"in": {"kind": "pop", "duration": 1}}}),
1021            30,
1022        );
1023        let size = |f: i64| ov[0].parts_at(f)[0].size.unwrap();
1024        let centre = |f: i64| {
1025            let p = ov[0].parts_at(f)[0];
1026            p.pos[0] + p.size.unwrap()[0] / 2.0
1027        };
1028        assert!(size(1)[0] < 100.0);
1029        assert!(size(7)[0] > 100.0, "overshoot");
1030        assert!((centre(1) - 100.0).abs() < 1e-3);
1031        assert!((size(15)[0] - 100.0).abs() < 1e-3);
1032    }
1033
1034    #[test]
1035    fn wipe_and_exit() {
1036        let ov = run(
1037            json!({"animation": {"in": {"kind": "wipe", "duration": 1, "ease": "linear"},
1038                                 "out": {"kind": "slide", "direction": "down", "duration": 1}}}),
1039            30,
1040        );
1041        let p = ov[0].parts_at(4)[0];
1042        assert_eq!(p.src.map(|s| s[2]), Some(45.0));
1043        assert_eq!(p.size.map(|s| s[0]), Some(45.0));
1044        let p = ov[0].parts_at(25)[0];
1045        assert!(p.pos[1] > 40.0 && p.colour[3] < 1.0, "leaving downwards");
1046    }
1047
1048    #[test]
1049    fn keyframes_move_scale_and_tint() {
1050        let a = Animation::parse(&obj(json!({"keyframes": {
1051            "position": [{"t": 0, "v": [0.0, 0.0]}, {"t": 1, "v": [0.25, 0.0]}],
1052            "scale": [{"t": 0, "v": 1}, {"t": 1, "v": 2}],
1053            "opacity": [{"t": 0, "v": 0.5}],
1054            "colour": [{"t": 0, "v": [1, 0, 0]}],
1055        }})));
1056        let r = text_box();
1057        // Laid out at raster 2: the box is twice the size around the anchor.
1058        let mut big = r.clone();
1059        big.layers[0].parts[0].pos = [0.0, 30.0];
1060        big.layers[0].parts[0].src = None;
1061        big.layers[0].mask = Arc::new(Mask {
1062            width: 200,
1063            height: 40,
1064            data: vec![255; 8000],
1065        });
1066        let ov = animate(
1067            &big,
1068            &a,
1069            2.0,
1070            [100.0, 50.0],
1071            [0.0, 0.0],
1072            [200.0, 100.0],
1073            span(20),
1074        );
1075        let p = ov[0].parts_at(0)[0];
1076        assert!((p.size.unwrap()[0] - 105.0).abs() < 1e-3, "{p:?}");
1077        let p = ov[0].parts_at(15)[0];
1078        // Scale 2 about the anchor (100, 50), moved a quarter frame right.
1079        assert!((p.size.unwrap()[0] - 200.0).abs() < 1e-3);
1080        assert!((p.pos[0] - 50.0).abs() < 1e-3);
1081        assert_eq!(p.colour, [1.0, 0.0, 0.0, 0.5]);
1082        let _ = r;
1083    }
1084
1085    #[test]
1086    fn rect_wipes_from_the_centre() {
1087        let r = rect(
1088            [100.0, 4.0],
1089            [0.0, 0.0],
1090            [0.5, 0.5],
1091            [200.0, 100.0],
1092            [1.0; 4],
1093        );
1094        let a = Animation::parse(&obj(
1095            json!({"animation": {"in": {"kind": "wipe", "direction": "center", "duration": 1, "ease": "linear"}}}),
1096        ));
1097        let ov = animate(
1098            &r,
1099            &a,
1100            1.0,
1101            [100.0, 50.0],
1102            [0.0, 0.0],
1103            [200.0, 100.0],
1104            span(20),
1105        );
1106        let p = ov[0].parts_at(4)[0];
1107        assert_eq!(p.pos, [77.5, 48.0]);
1108        assert_eq!(p.size, Some([45.0, 4.0]));
1109        assert_eq!(p.src, None);
1110    }
1111
1112    #[test]
1113    fn keyframes_take_expressions() {
1114        let o: Keys<1> = Keys::parse(Some(&json!("lt / dur")));
1115        assert_eq!(o.at(2.5, 10.0), Some([0.25]));
1116        let p: Keys<2> = Keys::parse(Some(&json!([0.1, "0.3 - 0.05 * lt"])));
1117        let v = p.at(2.0, 10.0).unwrap();
1118        assert!((v[0] - 0.1).abs() < 1e-12 && (v[1] - 0.2).abs() < 1e-12);
1119        let k: Keys<1> = Keys::parse(Some(&json!({
1120            "keys": [{"t": 0, "v": 0}, {"t": -1, "v": 1}], "expr": "value * 0.5"
1121        })));
1122        assert_eq!(k.at(4.5, 10.0), Some([0.25]));
1123        // An expression that doesn't compile is dropped.
1124        assert!(Keys::<1>::parse(Some(&json!("st * 2"))).is_empty());
1125    }
1126
1127    #[test]
1128    fn keyframes_stay_lenient() {
1129        // `value` for `v`, as before tracks.
1130        let o: Keys<1> = Keys::parse(Some(&json!([{"t": 0, "value": 0}, {"t": 2, "value": 1}])));
1131        assert_eq!(o.at(1.0, 10.0), Some([0.5]));
1132        // A key that doesn't read is skipped, even the first.
1133        let o: Keys<1> = Keys::parse(Some(&json!([
1134            {"t": "soon", "v": 5}, {"t": 0, "v": 0}, {"t": 2, "v": 1}
1135        ])));
1136        assert_eq!(o.at(1.0, 10.0), Some([0.5]));
1137        // A colour mixing an expression with numbers keeps the expression.
1138        let c: Keys<4> = Keys::parse(Some(&json!(["lt / dur", 0.5, 0.5, 1])));
1139        assert_eq!(c.at(2.5, 10.0), Some([0.25, 0.5, 0.5, 1.0]));
1140        let c: Keys<4> = Keys::parse(Some(&json!(["lt / dur", 0.5, 0.5])));
1141        assert_eq!(c.at(2.5, 10.0), Some([0.25, 0.5, 0.5, 1.0]));
1142    }
1143
1144    #[test]
1145    fn expression_scale_rasterises_at_its_peak() {
1146        let a = Animation::parse(&obj(json!({"keyframes": {"scale": ["1 + lt / dur", 1]}})));
1147        assert!((a.raster_scale_over(4.0, 30.0) - 2.0).abs() < 1e-6);
1148        let a = Animation::parse(&obj(json!({"keyframes": {"scale": [
1149            "1 + 2 * between(lt, 1, 1.1)", 1
1150        ]}})));
1151        assert!((a.raster_scale_over(4.0, 30.0) - 3.0).abs() < 1e-6);
1152        let a = Animation::parse(&obj(json!({"keyframes": {"scale": {
1153            "keys": [{"t": 0, "v": 1}, {"t": -1, "v": 2}], "expr": "value * 1.5"
1154        }}})));
1155        assert!((a.raster_scale_over(4.0, 30.0) - 3.0).abs() < 1e-6);
1156        // Capped at 4.
1157        let a = Animation::parse(&obj(json!({"keyframes": {"scale": 10}})));
1158        assert!((a.raster_scale_over(4.0, 30.0) - 4.0).abs() < 1e-6);
1159    }
1160}