Skip to main content

oxideav_ass/
animate.rs

1//! Typed extraction + time-evaluation of ASS *animated* override tags.
2//!
3//! The base parser in [`crate`] preserves animated tags as opaque
4//! [`Segment::Raw`] blocks so the round-trip back to text stays
5//! bit-faithful. This module adds a *renderer-facing* view: it walks
6//! those `Raw` blocks (and any inline `\frz` / `\blur` / `\fscx` /
7//! `\fscy` / `\clip` / `\fad` / `\move` / `\t` tags found in the
8//! original dialogue text) and produces a typed [`CueAnimation`]
9//! struct that downstream rasterizers can sample at any timestamp.
10//!
11//! The set of tags supported in this round:
12//!
13//! * `\fad(t1, t2)` — fade in over `t1` ms, fade out over `t2` ms,
14//!   modulating the cue alpha multiplier.
15//! * `\fade(a1, a2, a3, t1, t2, t3, t4)` — full 7-arg variant; alpha
16//!   `a1` until `t1`, ramps to `a2` by `t2`, holds `a2` until `t3`,
17//!   ramps to `a3` by `t4`. Alpha values use the ASS convention
18//!   (`0` = opaque, `255` = transparent).
19//! * `\pos(x, y)` — set the static line position (script-resolution
20//!   coordinates). The non-moving counterpart of `\move`; both write
21//!   [`RenderState::translate`]. Static, not animatable.
22//! * `\move(x1, y1, x2, y2[, t1, t2])` — translate the rendered text
23//!   from `(x1, y1)` at `t1` to `(x2, y2)` at `t2` (defaults: t1 = 0,
24//!   t2 = cue duration).
25//! * `\frz(angle)` — rotate around the Z axis by `angle` degrees.
26//! * `\blur(strength)` — Gaussian blur sigma in pixels (`0` = no
27//!   blur).
28//! * `\be(strength)` — iterative box-blur strength (integer, edge-only
29//!   softening). Distinct from `\blur` per Aegisub spec — exposed in
30//!   [`RenderState::be_strength`] without merging into `blur_sigma`.
31//! * `\bord(w)` / `\xbord(w)` / `\ybord(w)` — text border width (px).
32//!   `\bord` sets both axes, `\xbord` and `\ybord` set X or Y only.
33//!   Per Aegisub spec, a `\bord` after `\xbord`/`\ybord` overrides
34//!   both axes again.
35//! * `\shad(d)` / `\xshad(d)` / `\yshad(d)` — text shadow distance
36//!   (px). `\shad` sets both axes uniformly (non-negative); the
37//!   `\xshad`/`\yshad` per-axis tags permit negative values, which
38//!   place the shadow to the top or left of the text.
39//! * `\fax(f)` / `\fay(f)` — shear (perspective-distortion) factor on
40//!   the X / Y axis. Applied after rotation, on rotated coordinates.
41//! * `\fsp(spacing)` — letter-spacing in script-resolution pixels.
42//!   Spacing may be negative or decimal; default `0` (no additional
43//!   advance between letters). Animatable per the Aegisub / TCAX
44//!   spec.
45//! * `\q(style)` — wrap style override for the line. `0`/`1`/`2`/`3`
46//!   map to the SSA spec wrap modes (smart-top / EOL / no-wrap /
47//!   smart-bottom). Static, not animatable.
48//! * `\an(pos)` — line alignment, numpad layout per the Aegisub spec:
49//!   `1`/`2`/`3` = bottom-left/center/right, `4`/`5`/`6` = middle-
50//!   left/center/right, `7`/`8`/`9` = top-left/center/right. Surfaces
51//!   on [`RenderState::alignment`] as the same numpad value 1..=9 so
52//!   the renderer can anchor the cue's `\pos`/`\move` translate at the
53//!   correct corner. Static, not animatable per spec.
54//! * `\a(pos)` — legacy SubStation-Alpha alignment code (still
55//!   recognised by Aegisub). Calculation per spec: low nibble `1`/`2`/
56//!   `3` for left/center/right; add `4` for top, add `8` for mid. The
57//!   parser converts to the equivalent numpad value and writes the
58//!   same [`RenderState::alignment`] field — so a cue with `\a6`
59//!   surfaces as `alignment = Some(8)` (top-center), matching `\an8`.
60//! * `\2c(&Hbbggrr&)` / `\3c(&Hbbggrr&)` / `\4c(&Hbbggrr&)` —
61//!   secondary fill, border, and shadow colours. `\1c` (alias `\c`) is
62//!   already in this set; the four together cover the four colour
63//!   components an ASS glyph carries.
64//! * `\alpha(&Haa&)` / `\1a(&Haa&)` / `\2a(&Haa&)` / `\3a(&Haa&)` /
65//!   `\4a(&Haa&)` — per-component alpha overrides. ASS uses 0 = opaque,
66//!   255 = transparent; renderers translate to their own opacity
67//!   convention. `\alpha` sets all four channels at once; `\1a` /
68//!   `\2a` / `\3a` / `\4a` set the primary / secondary / border /
69//!   shadow alpha individually. These per-component alphas are
70//!   independent of the cue-level `\fad` / `\fade` envelope (which
71//!   keeps multiplying [`RenderState::alpha_mul`]).
72//! * `\clip(x1, y1, x2, y2)` — restrict rendering to the rectangle
73//!   `[x1..x2] x [y1..y2]`. The drawing-path form is recognised but
74//!   stored verbatim (round 2).
75//! * `\iclip(x1, y1, x2, y2)` — *inverse* rectangular clip: the cue
76//!   is hidden inside the rectangle. Vector-drawing form is also
77//!   accepted and stored verbatim in [`RenderState::iclip_drawing`].
78//! * `\fscx(percent)` / `\fscy(percent)` — non-uniform scale.
79//! * `\t(t1, t2, [accel,] tags)` — interpolate the inner tags over
80//!   `[t1, t2]` within the cue. Inner tags supported in this round:
81//!   `\fscx`, `\fscy`, `\frz`, `\c` / `\1c` / `\2c` / `\3c` / `\4c`,
82//!   `\alpha` / `\1a` / `\2a` / `\3a` / `\4a`, `\fs`, `\blur`,
83//!   `\bord`, `\xbord`, `\ybord`, `\shad`, `\xshad`, `\yshad`, `\fax`,
84//!   `\fay`, `\fsp`. Other inner tags are stored verbatim and applied
85//!   as a static override for `t >= t1`. `\q` is a static (non-
86//!   animated) line-level setting per spec; it is parsed at the cue
87//!   level and ignored inside `\t(...)`.
88//!
89//! Times in `\fad`, `\move`, `\t` are milliseconds *from the cue
90//! start*. The ASS spec uses "ms from cue start" as the canonical
91//! reference for every animation tag.
92
93use oxideav_core::{Segment, SubtitleCue, Transform2D};
94
95/// Which member of the `\k` karaoke-timing family produced a syllable
96/// marker.
97///
98/// Per the Aegisub override-tag reference, the `\k` family marks up a
99/// dialogue line for karaoke by giving the duration of each syllable;
100/// the four members differ only in the *visual* transition they ask the
101/// renderer for, not in the timing they encode:
102///
103/// * [`Fill`](KaraokeKind::Fill) (`\k`) — before the syllable's
104///   highlight the glyphs use the secondary colour + alpha; when the
105///   syllable starts, the fill switches *instantly* to the primary
106///   colour + alpha.
107/// * [`Sweep`](KaraokeKind::Sweep) (`\kf`, and the identical `\K`) — the
108///   fill starts secondary and sweeps left-to-right from secondary to
109///   primary across the syllable's duration, finishing exactly when the
110///   syllable time is over.
111/// * [`Outline`](KaraokeKind::Outline) (`\ko`) — like `\k`, except the
112///   glyph border/outline is *removed* before highlight and appears
113///   instantly when the syllable starts.
114///
115/// The base parser collapses all three (plus `\K`) into a single
116/// `oxideav_core::Segment::Karaoke` marker that does not record which
117/// member was used, so the kind is only recoverable when parsing raw
118/// override text directly (e.g. through [`parse_overrides`]). Karaoke
119/// markers recovered from already-parsed `Segment::Karaoke` segments
120/// therefore report [`KaraokeKind::Fill`] as the conservative default.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum KaraokeKind {
123    /// `\k` — instant fill switch at the syllable boundary.
124    Fill,
125    /// `\kf` / `\K` — left-to-right secondary→primary sweep across the
126    /// syllable.
127    Sweep,
128    /// `\ko` — outline removed before highlight, appears instantly.
129    Outline,
130}
131
132/// One karaoke syllable's resolved timing span within a cue.
133///
134/// Produced by [`CueAnimation::karaoke_spans`]. Times are milliseconds
135/// from the cue start; each span runs `[start_ms, end_ms)` and the next
136/// syllable begins exactly where the previous one ends (the `\k`
137/// durations are cumulative per the Aegisub spec, which gives each
138/// syllable's duration in centiseconds).
139#[derive(Clone, Copy, Debug, PartialEq)]
140pub struct KaraokeSpan {
141    /// Which `\k` member produced this syllable.
142    pub kind: KaraokeKind,
143    /// Start of the syllable, ms from cue start.
144    pub start_ms: u32,
145    /// End of the syllable (= start of the next syllable), ms from cue
146    /// start.
147    pub end_ms: u32,
148}
149
150impl KaraokeSpan {
151    /// Fraction (`0.0..=1.0`) of the way through this syllable at
152    /// `t_in_cue_ms`, milliseconds from the cue start.
153    ///
154    /// `0.0` before the syllable starts, `1.0` at or after its end. For
155    /// a [`KaraokeKind::Sweep`] syllable this is the left-to-right wipe
156    /// position; for [`KaraokeKind::Fill`] / [`KaraokeKind::Outline`]
157    /// the renderer only needs to know whether the value crossed `0.0`
158    /// (i.e. whether the syllable has started), since those switch
159    /// instantly.
160    pub fn progress(&self, t_in_cue_ms: i32) -> f32 {
161        if t_in_cue_ms <= self.start_ms as i32 {
162            return 0.0;
163        }
164        if t_in_cue_ms >= self.end_ms as i32 || self.end_ms <= self.start_ms {
165            return 1.0;
166        }
167        (t_in_cue_ms - self.start_ms as i32) as f32 / (self.end_ms - self.start_ms) as f32
168    }
169}
170
171/// One typed animated-tag occurrence found in a cue.
172#[derive(Clone, Debug, PartialEq)]
173pub enum AnimatedTag {
174    /// `\fad(t1, t2)` — alpha 0 → 255 over `t1` ms then 255 → 0 over
175    /// `t2` ms (ASS alpha; converted to a `0.0..=1.0` multiplier in
176    /// the evaluator).
177    Fad { t1_ms: u32, t2_ms: u32 },
178    /// `\fade(a1, a2, a3, t1, t2, t3, t4)` — full variant.
179    Fade {
180        a1: u8,
181        a2: u8,
182        a3: u8,
183        t1_ms: i32,
184        t2_ms: i32,
185        t3_ms: i32,
186        t4_ms: i32,
187    },
188    /// `\pos(x, y)` — set the static position of the line. Per the
189    /// Aegisub spec the coordinates are in the script-resolution
190    /// coordinate system and the line's alignment point is anchored
191    /// there. Static (not animatable); it is the non-moving
192    /// counterpart of [`AnimatedTag::Move`] and writes the same
193    /// [`RenderState::translate`] field.
194    Pos { x: f32, y: f32 },
195    /// `\move(x1, y1, x2, y2[, t1, t2])`. `t1`/`t2` default to the cue
196    /// span when omitted.
197    Move {
198        x1: f32,
199        y1: f32,
200        x2: f32,
201        y2: f32,
202        t1_ms: Option<i32>,
203        t2_ms: Option<i32>,
204    },
205    /// `\frz(degrees)` — rotation around Z, applied as a static
206    /// override at all times unless wrapped in `\t`.
207    Frz(f32),
208    /// `\blur(sigma)` — Gaussian blur sigma in px.
209    Blur(f32),
210    /// `\fscx(percent)` — horizontal scale, 100 = identity.
211    Fscx(f32),
212    /// `\fscy(percent)` — vertical scale, 100 = identity.
213    Fscy(f32),
214    /// `\c&Hbbggrr&` / `\1c...` — primary colour as RGB.
215    Color1((u8, u8, u8)),
216    /// `\fs(size)` — font size override (ignored by the evaluator
217    /// transform, but exposed for scale recovery).
218    Fs(f32),
219    /// `\clip(x1, y1, x2, y2)` rectangle.
220    ClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
221    /// `\clip(drawing)` — drawing path form, stored verbatim. The
222    /// renderer parses this through [`crate::drawing::parse_drawing`]
223    /// into an `oxideav_core::Path` and uses it as a `Group::clip`
224    /// mask.
225    ClipDrawing(String),
226    /// `\frx(degrees)` — rotation around the X axis (3D). Combined
227    /// with `\frz`/`\fry` and projected to 2D via a perspective
228    /// camera in the renderer.
229    Frx(f32),
230    /// `\fry(degrees)` — rotation around the Y axis (3D).
231    Fry(f32),
232    /// `\org(x, y)` — pivot for `\frx` / `\fry` / `\frz`. Without
233    /// `\org`, the pivot is the cue's alignment point.
234    Org { x: f32, y: f32 },
235    /// `\bord(w)` — text border width in px (sets both X and Y).
236    Bord(f32),
237    /// `\xbord(w)` — X-axis border width (px).
238    Xbord(f32),
239    /// `\ybord(w)` — Y-axis border width (px).
240    Ybord(f32),
241    /// `\shad(d)` — text shadow distance in px (sets both axes, must
242    /// be non-negative per the Aegisub spec).
243    Shad(f32),
244    /// `\xshad(d)` — X-axis shadow distance (px, may be negative).
245    Xshad(f32),
246    /// `\yshad(d)` — Y-axis shadow distance (px, may be negative).
247    Yshad(f32),
248    /// `\be(strength)` — iterative box-blur strength (integer). Edge-
249    /// softening filter, kept separate from `\blur` per Aegisub spec.
250    Be(u8),
251    /// `\fax(factor)` — X-axis shear (perspective distortion).
252    Fax(f32),
253    /// `\fay(factor)` — Y-axis shear.
254    Fay(f32),
255    /// `\iclip(x1, y1, x2, y2)` — inverse rectangular clip; the cue
256    /// is hidden inside the rectangle.
257    IClipRect { x1: f32, y1: f32, x2: f32, y2: f32 },
258    /// `\iclip(drawing)` — inverse vector-drawing clip, stored
259    /// verbatim. Parse with [`crate::drawing::parse_drawing`] if a
260    /// path is needed.
261    IClipDrawing(String),
262    /// `\2c&Hbbggrr&` — secondary fill colour (RGB).
263    Color2((u8, u8, u8)),
264    /// `\3c&Hbbggrr&` — border / outline colour (RGB).
265    Color3((u8, u8, u8)),
266    /// `\4c&Hbbggrr&` — shadow colour (RGB).
267    Color4((u8, u8, u8)),
268    /// `\alpha&Haa&` — sets the alpha of all four colour components at
269    /// once (primary / secondary / border / shadow). ASS convention:
270    /// 0 = opaque, 255 = transparent.
271    Alpha(u8),
272    /// `\1a&Haa&` — primary fill alpha. ASS convention.
273    Alpha1(u8),
274    /// `\2a&Haa&` — secondary fill alpha (pre-highlight karaoke).
275    Alpha2(u8),
276    /// `\3a&Haa&` — border alpha.
277    Alpha3(u8),
278    /// `\4a&Haa&` — shadow alpha.
279    Alpha4(u8),
280    /// `\fsp(spacing)` — additional advance between letters in
281    /// script-resolution pixels. May be negative or decimal; default
282    /// `0`. Animatable.
283    Fsp(f32),
284    /// `\q(style)` — wrap style for the line. Values per SSA spec:
285    /// `0` = smart wrap balanced top-wider, `1` = end-of-line wrap,
286    /// `2` = no wrapping, `3` = smart wrap balanced bottom-wider.
287    /// Static (not animatable).
288    Q(u8),
289    /// `\an<pos>` — line alignment using "numpad" values per the
290    /// Aegisub spec:
291    ///
292    /// * `1` = bottom-left,  `2` = bottom-center,  `3` = bottom-right
293    /// * `4` = middle-left,  `5` = middle-center,  `6` = middle-right
294    /// * `7` = top-left,     `8` = top-center,     `9` = top-right
295    ///
296    /// Out-of-range values are dropped by the parser (the static
297    /// override path then keeps the script-style alignment). Static,
298    /// not animatable per spec.
299    An(u8),
300    /// `\a<pos>` — legacy SubStation-Alpha alignment code. The parser
301    /// converts each recognised legacy code to its numpad equivalent
302    /// (`1`/`2`/`3` = bottom row; `+4` = top row; `+8` = middle row)
303    /// so the renderer only ever has to inspect
304    /// [`RenderState::alignment`]'s 1..=9 surface. Unrecognised codes
305    /// are dropped.
306    A(u8),
307    /// `\k` / `\K` / `\kf` / `\ko` — a karaoke syllable timing marker.
308    /// `cs` is the syllable's duration in **centiseconds** (the unit the
309    /// `\k` family uses; `100` = one second), and `kind` records which
310    /// member of the family produced it. These markers appear once per
311    /// syllable in document order; [`CueAnimation::karaoke_spans`]
312    /// resolves them into cumulative millisecond [`KaraokeSpan`]s.
313    ///
314    /// Unlike the transform / colour tags this is a timeline-level
315    /// concept rather than a per-frame state, so [`apply_tag`] treats it
316    /// as a no-op on [`RenderState`]; renderers walk the spans instead.
317    Karaoke { kind: KaraokeKind, cs: u32 },
318    /// `\t([t1,t2,[accel,]] inner_tags)` — interpolate the inner tags
319    /// over `[t1, t2]`. When `t1`/`t2` are omitted ASS treats them as
320    /// `[0, cue_duration]`. `accel` defaults to 1.0 (linear).
321    T {
322        t1_ms: Option<i32>,
323        t2_ms: Option<i32>,
324        accel: f32,
325        inner: Vec<AnimatedTag>,
326    },
327}
328
329/// All animated tags found in a single cue, in the order parsed.
330#[derive(Clone, Debug, Default, PartialEq)]
331pub struct CueAnimation {
332    pub tags: Vec<AnimatedTag>,
333}
334
335impl CueAnimation {
336    /// `true` iff there are no tags.
337    pub fn is_empty(&self) -> bool {
338        self.tags.is_empty()
339    }
340
341    /// Resolve the cue's `\k` family markers into cumulative
342    /// [`KaraokeSpan`]s, milliseconds from the cue start.
343    ///
344    /// Every [`AnimatedTag::Karaoke`] in `tags` (in document order)
345    /// becomes one span; each span begins where the previous one ended,
346    /// so the centisecond durations the `\k` tags carry add up into a
347    /// continuous syllable timeline. Cues with no karaoke markers yield
348    /// an empty vector. The centisecond → millisecond conversion is
349    /// exact (`cs * 10`).
350    pub fn karaoke_spans(&self) -> Vec<KaraokeSpan> {
351        let mut spans = Vec::new();
352        let mut cursor_ms: u32 = 0;
353        for tag in &self.tags {
354            if let AnimatedTag::Karaoke { kind, cs } = tag {
355                let end_ms = cursor_ms.saturating_add(cs.saturating_mul(10));
356                spans.push(KaraokeSpan {
357                    kind: *kind,
358                    start_ms: cursor_ms,
359                    end_ms,
360                });
361                cursor_ms = end_ms;
362            }
363        }
364        spans
365    }
366}
367
368/// Resolved state of the cue at a particular timestamp.
369///
370/// All quantities are expressed in the cue's local coordinate space
371/// (the same space `\pos` / `\move` use). `transform` composes
372/// `move` ∘ `scale` ∘ `rotate` so the rotation pivot is the
373/// translation point.
374#[derive(Clone, Debug, PartialEq)]
375pub struct RenderState {
376    /// `1.0` = fully opaque, `0.0` = fully transparent.
377    pub alpha_mul: f32,
378    /// Combined affine transform to apply to the rendered text glyph
379    /// group.
380    pub transform: Transform2D,
381    /// `\frz` rotation in radians (also baked into `transform` but
382    /// exposed for renderers that compose their own matrix).
383    pub rotate_radians: f32,
384    /// `\frx` rotation in radians (X axis, 3D). Renderers project
385    /// this to 2D via a perspective camera anchored at `pivot`.
386    pub rotate_x_radians: f32,
387    /// `\fry` rotation in radians (Y axis, 3D).
388    pub rotate_y_radians: f32,
389    /// `(sx, sy)` scale factors, where `1.0` = 100%.
390    pub scale: (f32, f32),
391    /// `(tx, ty)` translation. `None` when neither `\pos` nor `\move`
392    /// applied, in which case the renderer falls back to the cue's
393    /// style margins.
394    pub translate: Option<(f32, f32)>,
395    /// Gaussian blur sigma in pixels. `0.0` = no blur.
396    pub blur_sigma: f32,
397    /// Active rectangular clip in cue local coordinates, if any.
398    pub clip_rect: Option<ClipRect>,
399    /// `\clip(drawing)` raw drawing string, if active. Parse through
400    /// [`crate::drawing::parse_drawing`] for a vector path mask.
401    pub clip_drawing: Option<String>,
402    /// `\c` primary-colour override, if active.
403    pub primary_color: Option<(u8, u8, u8)>,
404    /// `\fs` size override, if active.
405    pub font_size: Option<f32>,
406    /// `\org(x, y)` pivot point for `\frz` / `\frx` / `\fry`. `None`
407    /// means "use the alignment point" (the renderer fills it in).
408    pub pivot: Option<(f32, f32)>,
409    /// `(x_border, y_border)` per-axis text border width in px from
410    /// `\bord` / `\xbord` / `\ybord`. `None` = fall back to style.
411    pub border: Option<(f32, f32)>,
412    /// `(x_shadow, y_shadow)` per-axis shadow distance in px from
413    /// `\shad` / `\xshad` / `\yshad`. `None` = fall back to style.
414    /// Per-axis values may be negative; `\shad` itself is clamped to
415    /// non-negative per spec.
416    pub shadow: Option<(f32, f32)>,
417    /// `\be(N)` iterative box-blur strength (0 = off). Distinct from
418    /// `blur_sigma` (`\blur`).
419    pub be_strength: u8,
420    /// `(fax, fay)` shear factors applied after rotation. `(0.0, 0.0)`
421    /// = no shear.
422    pub shear: (f32, f32),
423    /// Active inverse rectangular clip from `\iclip(x1,y1,x2,y2)`.
424    /// Renderers should hide pixels *inside* this rectangle.
425    pub iclip_rect: Option<ClipRect>,
426    /// `\iclip(drawing)` raw drawing string; renderer parses to a
427    /// path and masks against its inverse.
428    pub iclip_drawing: Option<String>,
429    /// `\2c` secondary fill colour override, if active.
430    pub secondary_color: Option<(u8, u8, u8)>,
431    /// `\3c` border / outline colour override, if active.
432    pub outline_color: Option<(u8, u8, u8)>,
433    /// `\4c` shadow colour override, if active.
434    pub shadow_color: Option<(u8, u8, u8)>,
435    /// `\1a` primary fill alpha (0 = opaque, 255 = transparent), if
436    /// set. `None` means "fall back to style alpha". Independent of
437    /// [`Self::alpha_mul`], which is the `\fad` / `\fade` cue-level
438    /// envelope. Renderers compose:
439    /// `final_primary_alpha = primary_alpha.unwrap_or(style) * alpha_mul`.
440    pub primary_alpha: Option<u8>,
441    /// `\2a` secondary fill alpha, if set.
442    pub secondary_alpha: Option<u8>,
443    /// `\3a` border / outline alpha, if set.
444    pub outline_alpha: Option<u8>,
445    /// `\4a` shadow alpha, if set.
446    pub shadow_alpha: Option<u8>,
447    /// `\fsp` additional letter-spacing in script-resolution pixels,
448    /// if set. `None` = use the style's `Spacing` field. May be
449    /// negative or decimal.
450    pub letter_spacing: Option<f32>,
451    /// `\q` wrap-style override for the line, if set. `None` = use
452    /// the script's `WrapStyle` header. Values per SSA spec:
453    /// `0` smart-top / `1` EOL / `2` no-wrap / `3` smart-bottom.
454    /// Not animatable per spec.
455    pub wrap_style: Option<u8>,
456    /// `\an<pos>` (or its legacy `\a<pos>` form, converted to numpad)
457    /// alignment override for the line, if set. `None` = fall back to
458    /// the cue's style `Alignment`. Values are the Aegisub numpad
459    /// codes 1..=9:
460    ///
461    /// * `1`/`2`/`3` — bottom-left / bottom-center / bottom-right
462    /// * `4`/`5`/`6` — middle-left / middle-center / middle-right
463    /// * `7`/`8`/`9` — top-left  / top-center  / top-right
464    ///
465    /// The alignment doubles as the anchor point for `\pos` / `\move`
466    /// translation per the Aegisub spec, so renderers should look here
467    /// to decide which glyph corner sits on the `translate` point.
468    /// Static, not animatable.
469    pub alignment: Option<u8>,
470}
471
472impl RenderState {
473    /// State with no animated overrides.
474    pub fn identity() -> Self {
475        Self {
476            alpha_mul: 1.0,
477            transform: Transform2D::identity(),
478            rotate_radians: 0.0,
479            rotate_x_radians: 0.0,
480            rotate_y_radians: 0.0,
481            scale: (1.0, 1.0),
482            translate: None,
483            blur_sigma: 0.0,
484            clip_rect: None,
485            clip_drawing: None,
486            primary_color: None,
487            font_size: None,
488            pivot: None,
489            border: None,
490            shadow: None,
491            be_strength: 0,
492            shear: (0.0, 0.0),
493            iclip_rect: None,
494            iclip_drawing: None,
495            secondary_color: None,
496            outline_color: None,
497            shadow_color: None,
498            primary_alpha: None,
499            secondary_alpha: None,
500            outline_alpha: None,
501            shadow_alpha: None,
502            letter_spacing: None,
503            wrap_style: None,
504            alignment: None,
505        }
506    }
507}
508
509impl Default for RenderState {
510    fn default() -> Self {
511        Self::identity()
512    }
513}
514
515/// Active rectangular clip region, normalised so x1 <= x2 and
516/// y1 <= y2.
517#[derive(Clone, Copy, Debug, PartialEq)]
518pub struct ClipRect {
519    pub x1: f32,
520    pub y1: f32,
521    pub x2: f32,
522    pub y2: f32,
523}
524
525impl CueAnimation {
526    /// Sample the cue at `t_in_cue_ms` (milliseconds from cue start).
527    ///
528    /// `cue_duration_ms` is needed because `\move` and `\t` accept
529    /// `t1`/`t2` arguments that default to "the entire cue".
530    pub fn evaluate_at(&self, t_in_cue_ms: i32, cue_duration_ms: i32) -> RenderState {
531        let mut st = RenderState::identity();
532        for tag in &self.tags {
533            apply_tag(&mut st, tag, t_in_cue_ms, cue_duration_ms);
534        }
535        st.transform = compose_transform(&st);
536        st
537    }
538}
539
540fn compose_transform(st: &RenderState) -> Transform2D {
541    let (sx, sy) = st.scale;
542    let mut t = Transform2D::identity();
543    if (sx - 1.0).abs() > f32::EPSILON || (sy - 1.0).abs() > f32::EPSILON {
544        t = t.compose(&Transform2D::scale(sx, sy));
545    }
546    if st.rotate_radians.abs() > f32::EPSILON {
547        t = Transform2D::rotate(st.rotate_radians).compose(&t);
548    }
549    if let Some((tx, ty)) = st.translate {
550        t = Transform2D::translate(tx, ty).compose(&t);
551    }
552    t
553}
554
555fn apply_tag(st: &mut RenderState, tag: &AnimatedTag, t_ms: i32, dur_ms: i32) {
556    match tag {
557        AnimatedTag::Fad { t1_ms, t2_ms } => {
558            st.alpha_mul *= fad_alpha(*t1_ms as i32, *t2_ms as i32, t_ms, dur_ms);
559        }
560        AnimatedTag::Fade {
561            a1,
562            a2,
563            a3,
564            t1_ms,
565            t2_ms,
566            t3_ms,
567            t4_ms,
568        } => {
569            let a = fade_alpha(*a1, *a2, *a3, *t1_ms, *t2_ms, *t3_ms, *t4_ms, t_ms);
570            st.alpha_mul *= ass_alpha_to_mul(a);
571        }
572        AnimatedTag::Pos { x, y } => {
573            // \pos is the static counterpart of \move; both write the
574            // line position into `translate`. Last writer wins, matching
575            // the rest of this module's static-override model — so a
576            // later \move (or \pos) overrides an earlier \pos.
577            st.translate = Some((*x, *y));
578        }
579        AnimatedTag::Move {
580            x1,
581            y1,
582            x2,
583            y2,
584            t1_ms,
585            t2_ms,
586        } => {
587            let t1 = t1_ms.unwrap_or(0);
588            let t2 = t2_ms.unwrap_or(dur_ms);
589            let p = lerp_xy((*x1, *y1), (*x2, *y2), t1, t2, t_ms);
590            st.translate = Some(p);
591        }
592        AnimatedTag::Frz(deg) => {
593            st.rotate_radians = deg.to_radians();
594        }
595        AnimatedTag::Blur(sigma) => {
596            st.blur_sigma = sigma.max(0.0);
597        }
598        AnimatedTag::Fscx(pct) => {
599            st.scale.0 = pct / 100.0;
600        }
601        AnimatedTag::Fscy(pct) => {
602            st.scale.1 = pct / 100.0;
603        }
604        AnimatedTag::Color1(rgb) => {
605            st.primary_color = Some(*rgb);
606        }
607        AnimatedTag::Fs(size) => {
608            st.font_size = Some(*size);
609        }
610        AnimatedTag::ClipRect { x1, y1, x2, y2 } => {
611            let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
612            let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
613            st.clip_rect = Some(ClipRect {
614                x1: lo_x,
615                y1: lo_y,
616                x2: hi_x,
617                y2: hi_y,
618            });
619        }
620        AnimatedTag::ClipDrawing(s) => {
621            st.clip_drawing = Some(s.clone());
622        }
623        AnimatedTag::Frx(deg) => {
624            st.rotate_x_radians = deg.to_radians();
625        }
626        AnimatedTag::Fry(deg) => {
627            st.rotate_y_radians = deg.to_radians();
628        }
629        AnimatedTag::Org { x, y } => {
630            st.pivot = Some((*x, *y));
631        }
632        AnimatedTag::Bord(w) => {
633            // \bord sets both axes — per Aegisub spec, "if you use
634            // \bord after \xbord or \ybord, it will [override] them".
635            let w = w.max(0.0);
636            st.border = Some((w, w));
637        }
638        AnimatedTag::Xbord(w) => {
639            let w = w.max(0.0);
640            let (_, y) = st.border.unwrap_or((0.0, 0.0));
641            st.border = Some((w, y));
642        }
643        AnimatedTag::Ybord(w) => {
644            let w = w.max(0.0);
645            let (x, _) = st.border.unwrap_or((0.0, 0.0));
646            st.border = Some((x, w));
647        }
648        AnimatedTag::Shad(d) => {
649            // \shad is non-negative per spec.
650            let d = d.max(0.0);
651            st.shadow = Some((d, d));
652        }
653        AnimatedTag::Xshad(d) => {
654            // \xshad / \yshad may be negative.
655            let (_, y) = st.shadow.unwrap_or((0.0, 0.0));
656            st.shadow = Some((*d, y));
657        }
658        AnimatedTag::Yshad(d) => {
659            let (x, _) = st.shadow.unwrap_or((0.0, 0.0));
660            st.shadow = Some((x, *d));
661        }
662        AnimatedTag::Be(n) => {
663            st.be_strength = *n;
664        }
665        AnimatedTag::Fax(f) => {
666            st.shear.0 = *f;
667        }
668        AnimatedTag::Fay(f) => {
669            st.shear.1 = *f;
670        }
671        AnimatedTag::IClipRect { x1, y1, x2, y2 } => {
672            let (lo_x, hi_x) = if x1 <= x2 { (*x1, *x2) } else { (*x2, *x1) };
673            let (lo_y, hi_y) = if y1 <= y2 { (*y1, *y2) } else { (*y2, *y1) };
674            st.iclip_rect = Some(ClipRect {
675                x1: lo_x,
676                y1: lo_y,
677                x2: hi_x,
678                y2: hi_y,
679            });
680        }
681        AnimatedTag::IClipDrawing(s) => {
682            st.iclip_drawing = Some(s.clone());
683        }
684        AnimatedTag::Color2(rgb) => {
685            st.secondary_color = Some(*rgb);
686        }
687        AnimatedTag::Color3(rgb) => {
688            st.outline_color = Some(*rgb);
689        }
690        AnimatedTag::Color4(rgb) => {
691            st.shadow_color = Some(*rgb);
692        }
693        AnimatedTag::Alpha(a) => {
694            // \alpha sets all four channels at once.
695            st.primary_alpha = Some(*a);
696            st.secondary_alpha = Some(*a);
697            st.outline_alpha = Some(*a);
698            st.shadow_alpha = Some(*a);
699        }
700        AnimatedTag::Alpha1(a) => {
701            st.primary_alpha = Some(*a);
702        }
703        AnimatedTag::Alpha2(a) => {
704            st.secondary_alpha = Some(*a);
705        }
706        AnimatedTag::Alpha3(a) => {
707            st.outline_alpha = Some(*a);
708        }
709        AnimatedTag::Alpha4(a) => {
710            st.shadow_alpha = Some(*a);
711        }
712        AnimatedTag::Fsp(s) => {
713            st.letter_spacing = Some(*s);
714        }
715        AnimatedTag::Q(mode) => {
716            // Clamp to the four spec values; out-of-range modes fall
717            // back to the script header's WrapStyle (no override).
718            if *mode <= 3 {
719                st.wrap_style = Some(*mode);
720            }
721        }
722        AnimatedTag::An(n) => {
723            // ASS numpad alignment: 1..=9 valid; values outside drop
724            // the override (renderer keeps the style's Alignment).
725            if (1..=9).contains(n) {
726                st.alignment = Some(*n);
727            }
728        }
729        AnimatedTag::A(n) => {
730            // Legacy SSA alignment: convert to the equivalent numpad
731            // code per the Aegisub spec. Low nibble = L/C/R, +4 = top,
732            // +8 = mid (= ASS bot/mid/top rows are 1-3 / 7-9 / 4-6 on
733            // the numpad). Unrecognised codes drop the override.
734            if let Some(numpad) = ssa_alignment_to_numpad(*n) {
735                st.alignment = Some(numpad);
736            }
737        }
738        AnimatedTag::Karaoke { .. } => {
739            // Timeline-level concept: the per-syllable highlight timing
740            // lives on the cue, not on the single-instant RenderState.
741            // Renderers walk CueAnimation::karaoke_spans() to find which
742            // syllable is active and how far its highlight has advanced.
743            // Nothing to apply to the affine / colour / alpha state here.
744        }
745        AnimatedTag::T {
746            t1_ms,
747            t2_ms,
748            accel,
749            inner,
750        } => {
751            apply_t(st, *t1_ms, *t2_ms, *accel, inner, t_ms, dur_ms);
752        }
753    }
754}
755
756fn apply_t(
757    st: &mut RenderState,
758    t1: Option<i32>,
759    t2: Option<i32>,
760    accel: f32,
761    inner: &[AnimatedTag],
762    t_ms: i32,
763    dur_ms: i32,
764) {
765    let start = t1.unwrap_or(0);
766    let end = t2.unwrap_or(dur_ms);
767    // Snapshot pre-transition state for interpolation source.
768    let pre = st.clone();
769    // Apply each inner tag to get the post-state.
770    let mut post = pre.clone();
771    for tag in inner {
772        apply_tag(&mut post, tag, t_ms, dur_ms);
773    }
774    // Compute the interpolation factor in [0,1].
775    let raw = if end <= start {
776        if t_ms >= end {
777            1.0
778        } else {
779            0.0
780        }
781    } else if t_ms <= start {
782        0.0
783    } else if t_ms >= end {
784        1.0
785    } else {
786        (t_ms - start) as f32 / (end - start) as f32
787    };
788    let k = if accel.abs() < f32::EPSILON {
789        raw
790    } else {
791        raw.powf(accel)
792    };
793    // Interpolate every field that the inner tags could have touched.
794    st.scale.0 = lerp_f32(pre.scale.0, post.scale.0, k);
795    st.scale.1 = lerp_f32(pre.scale.1, post.scale.1, k);
796    st.rotate_radians = lerp_f32(pre.rotate_radians, post.rotate_radians, k);
797    st.rotate_x_radians = lerp_f32(pre.rotate_x_radians, post.rotate_x_radians, k);
798    st.rotate_y_radians = lerp_f32(pre.rotate_y_radians, post.rotate_y_radians, k);
799    st.blur_sigma = lerp_f32(pre.blur_sigma, post.blur_sigma, k).max(0.0);
800    st.alpha_mul = lerp_f32(pre.alpha_mul, post.alpha_mul, k);
801    if let Some(c) = post.primary_color {
802        let from = pre.primary_color.unwrap_or(c);
803        st.primary_color = Some(lerp_rgb(from, c, k));
804    }
805    if let Some(s) = post.font_size {
806        let from = pre.font_size.unwrap_or(s);
807        st.font_size = Some(lerp_f32(from, s, k));
808    }
809    if let Some((px, py)) = post.translate {
810        let (fx, fy) = pre.translate.unwrap_or((px, py));
811        st.translate = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
812    }
813    // Border / shadow / be / shear interpolation. \bord and \shad
814    // ramp linearly per axis; for \be the integer strength is
815    // round-clamped at each sample.
816    if let Some((px, py)) = post.border {
817        let (fx, fy) = pre.border.unwrap_or((px, py));
818        st.border = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
819    }
820    if let Some((px, py)) = post.shadow {
821        let (fx, fy) = pre.shadow.unwrap_or((px, py));
822        st.shadow = Some((lerp_f32(fx, px, k), lerp_f32(fy, py, k)));
823    }
824    if post.be_strength != pre.be_strength {
825        let from = pre.be_strength as f32;
826        let to = post.be_strength as f32;
827        st.be_strength = lerp_f32(from, to, k).clamp(0.0, 255.0).round() as u8;
828    }
829    st.shear.0 = lerp_f32(pre.shear.0, post.shear.0, k);
830    st.shear.1 = lerp_f32(pre.shear.1, post.shear.1, k);
831    // Per-component colours \2c / \3c / \4c interpolate just like \1c.
832    if let Some(c) = post.secondary_color {
833        let from = pre.secondary_color.unwrap_or(c);
834        st.secondary_color = Some(lerp_rgb(from, c, k));
835    }
836    if let Some(c) = post.outline_color {
837        let from = pre.outline_color.unwrap_or(c);
838        st.outline_color = Some(lerp_rgb(from, c, k));
839    }
840    if let Some(c) = post.shadow_color {
841        let from = pre.shadow_color.unwrap_or(c);
842        st.shadow_color = Some(lerp_rgb(from, c, k));
843    }
844    // Per-component alphas \1a..\4a interpolate as u8 linearly.
845    if let Some(a) = post.primary_alpha {
846        let from = pre.primary_alpha.unwrap_or(a);
847        st.primary_alpha = Some(lerp_u8(from, a, k));
848    }
849    if let Some(a) = post.secondary_alpha {
850        let from = pre.secondary_alpha.unwrap_or(a);
851        st.secondary_alpha = Some(lerp_u8(from, a, k));
852    }
853    if let Some(a) = post.outline_alpha {
854        let from = pre.outline_alpha.unwrap_or(a);
855        st.outline_alpha = Some(lerp_u8(from, a, k));
856    }
857    if let Some(a) = post.shadow_alpha {
858        let from = pre.shadow_alpha.unwrap_or(a);
859        st.shadow_alpha = Some(lerp_u8(from, a, k));
860    }
861    // \fsp ramps linearly per spec; falls back to pre when post has no
862    // override.
863    if let Some(s) = post.letter_spacing {
864        let from = pre.letter_spacing.unwrap_or(s);
865        st.letter_spacing = Some(lerp_f32(from, s, k));
866    }
867    // \q is non-animatable: snap to the post-state value at t >= t1
868    // (k > 0), keep pre below.
869    if post.wrap_style != pre.wrap_style {
870        st.wrap_style = if k > 0.0 {
871            post.wrap_style
872        } else {
873            pre.wrap_style
874        };
875    }
876    // \an / \a are non-animatable per spec — snap on the same k > 0
877    // boundary as \q.
878    if post.alignment != pre.alignment {
879        st.alignment = if k > 0.0 {
880            post.alignment
881        } else {
882            pre.alignment
883        };
884    }
885}
886
887/// Convert a legacy SSA `\a<pos>` code to the equivalent ASS numpad
888/// (`\an<N>`) value, per the Aegisub spec:
889///
890/// > Use 1 for left-alignment, 2 for center alignment and 3 for
891/// > right-alignment. … To get top-titles, add 4 to the number, to
892/// > get mid-titles add 8 to the number.
893///
894/// Returns `None` for codes that do not match a documented legacy
895/// alignment slot.
896fn ssa_alignment_to_numpad(n: u8) -> Option<u8> {
897    // Sub-titles (bottom row): 1, 2, 3 → numpad 1, 2, 3.
898    // Top-titles (+4):         5, 6, 7 → numpad 7, 8, 9.
899    // Mid-titles (+8):         9, 10, 11 → numpad 4, 5, 6.
900    match n {
901        1 => Some(1),
902        2 => Some(2),
903        3 => Some(3),
904        5 => Some(7),
905        6 => Some(8),
906        7 => Some(9),
907        9 => Some(4),
908        10 => Some(5),
909        11 => Some(6),
910        _ => None,
911    }
912}
913
914fn lerp_u8(a: u8, b: u8, k: f32) -> u8 {
915    let v = a as f32 + (b as f32 - a as f32) * k;
916    v.clamp(0.0, 255.0).round() as u8
917}
918
919fn fad_alpha(t1: i32, t2: i32, t: i32, dur: i32) -> f32 {
920    let t = t.max(0);
921    let dur = dur.max(0);
922    let mul_in = if t1 <= 0 {
923        1.0
924    } else if t < t1 {
925        t as f32 / t1 as f32
926    } else {
927        1.0
928    };
929    let fade_out_start = (dur - t2).max(0);
930    let mul_out = if t2 <= 0 {
931        1.0
932    } else if t >= dur {
933        0.0
934    } else if t > fade_out_start {
935        ((dur - t) as f32 / t2 as f32).clamp(0.0, 1.0)
936    } else {
937        1.0
938    };
939    (mul_in * mul_out).clamp(0.0, 1.0)
940}
941
942#[allow(clippy::too_many_arguments)]
943fn fade_alpha(a1: u8, a2: u8, a3: u8, t1: i32, t2: i32, t3: i32, t4: i32, t: i32) -> u8 {
944    let lerp_u8 = |from: u8, to: u8, k: f32| -> u8 {
945        let v = from as f32 + (to as f32 - from as f32) * k;
946        v.clamp(0.0, 255.0) as u8
947    };
948    if t < t1 {
949        a1
950    } else if t < t2 {
951        let span = (t2 - t1).max(1);
952        lerp_u8(a1, a2, (t - t1) as f32 / span as f32)
953    } else if t < t3 {
954        a2
955    } else if t < t4 {
956        let span = (t4 - t3).max(1);
957        lerp_u8(a2, a3, (t - t3) as f32 / span as f32)
958    } else {
959        a3
960    }
961}
962
963fn ass_alpha_to_mul(a: u8) -> f32 {
964    // ASS: 0 = opaque, 255 = transparent. Our mul: 1.0 = opaque.
965    1.0 - (a as f32 / 255.0)
966}
967
968fn lerp_f32(a: f32, b: f32, k: f32) -> f32 {
969    a + (b - a) * k
970}
971
972fn lerp_rgb(a: (u8, u8, u8), b: (u8, u8, u8), k: f32) -> (u8, u8, u8) {
973    let lerp_c = |from: u8, to: u8| -> u8 {
974        let v = from as f32 + (to as f32 - from as f32) * k;
975        v.clamp(0.0, 255.0) as u8
976    };
977    (lerp_c(a.0, b.0), lerp_c(a.1, b.1), lerp_c(a.2, b.2))
978}
979
980fn lerp_xy(a: (f32, f32), b: (f32, f32), t1: i32, t2: i32, t: i32) -> (f32, f32) {
981    let k = if t2 <= t1 {
982        if t >= t2 {
983            1.0
984        } else {
985            0.0
986        }
987    } else if t <= t1 {
988        0.0
989    } else if t >= t2 {
990        1.0
991    } else {
992        (t - t1) as f32 / (t2 - t1) as f32
993    };
994    (lerp_f32(a.0, b.0, k), lerp_f32(a.1, b.1, k))
995}
996
997// ---------------------------------------------------------------------------
998// Extraction from a SubtitleCue.
999
1000/// Walk `cue.segments` and pull out every animated tag stored in
1001/// `Segment::Raw` blocks.
1002///
1003/// The raw blocks were emitted by the parser as `{\fad(...)}` /
1004/// `{\move(...)}` / etc. so we re-parse them here to surface typed
1005/// values without losing the original text (the round-trip path keeps
1006/// using the `Raw` segments directly).
1007pub fn extract_cue_animation(cue: &SubtitleCue) -> CueAnimation {
1008    let mut tags: Vec<AnimatedTag> = Vec::new();
1009    walk_segments(&cue.segments, &mut tags);
1010    CueAnimation { tags }
1011}
1012
1013fn walk_segments(segs: &[Segment], out: &mut Vec<AnimatedTag>) {
1014    for s in segs {
1015        match s {
1016            Segment::Raw(raw) => parse_raw_block(raw, out),
1017            Segment::Bold(c) | Segment::Italic(c) | Segment::Underline(c) | Segment::Strike(c) => {
1018                walk_segments(c, out)
1019            }
1020            Segment::Color { children, .. }
1021            | Segment::Font { children, .. }
1022            | Segment::Voice { children, .. }
1023            | Segment::Class { children, .. } => walk_segments(children, out),
1024            Segment::Karaoke { cs, children } => {
1025                // The base parser collapses `\k` / `\K` / `\kf` / `\ko`
1026                // into this marker without keeping which member it was,
1027                // so the kind is reported as the conservative Fill
1028                // default. The centisecond duration survives, which is
1029                // what `karaoke_spans` needs for the syllable timeline.
1030                out.push(AnimatedTag::Karaoke {
1031                    kind: KaraokeKind::Fill,
1032                    cs: *cs,
1033                });
1034                walk_segments(children, out);
1035            }
1036            _ => {}
1037        }
1038    }
1039}
1040
1041fn parse_raw_block(raw: &str, out: &mut Vec<AnimatedTag>) {
1042    // Strip the wrapping `{` `}` if present; the parser emits both
1043    // `{\fad(...)}` and bare `\fad(...)` so handle both.
1044    let inner = raw.trim();
1045    let inner = inner.strip_prefix('{').unwrap_or(inner);
1046    let inner = inner.strip_suffix('}').unwrap_or(inner);
1047    parse_overrides(inner, out);
1048}
1049
1050/// Parse animated overrides from a single override block (the bit
1051/// between `{` and `}` in a Dialogue line).
1052///
1053/// Tags this module doesn't recognise are silently skipped; the
1054/// round-trip text path retains them via the existing `Segment::Raw`
1055/// store.
1056pub fn parse_overrides(block: &str, out: &mut Vec<AnimatedTag>) {
1057    let bytes = block.as_bytes();
1058    let mut i = 0;
1059    while i < bytes.len() {
1060        if bytes[i] != b'\\' {
1061            i += 1;
1062            continue;
1063        }
1064        i += 1;
1065        // Tag name: optional leading digit then alphabetic.
1066        let name_start = i;
1067        if i < bytes.len() && bytes[i].is_ascii_digit() {
1068            i += 1;
1069            while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
1070                i += 1;
1071            }
1072        } else {
1073            while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
1074                i += 1;
1075            }
1076        }
1077        let name = &block[name_start..i];
1078        if name.is_empty() {
1079            continue;
1080        }
1081        let (param, advance) = read_param(&block[i..]);
1082        i += advance;
1083        let name_lc = name.to_ascii_lowercase();
1084        // `name` (original case) is passed alongside the lowercased form
1085        // because the karaoke family is case-sensitive: `\K` (uppercase)
1086        // is the secondary→primary sweep, identical to `\kf`, while `\k`
1087        // (lowercase) is the instant fill switch.
1088        if let Some(t) = parse_one(&name_lc, name, &param) {
1089            out.push(t);
1090        }
1091    }
1092}
1093
1094/// Read a tag's parameter starting at `s` (after the tag name).
1095/// Returns `(param_text, bytes_consumed)`. Handles parenthesised
1096/// groups (which may contain commas + nested `\` for `\t(...)`).
1097fn read_param(s: &str) -> (String, usize) {
1098    let bytes = s.as_bytes();
1099    if bytes.first() == Some(&b'(') {
1100        // Parenthesised — find the matching `)` accounting for
1101        // nesting (`\t(0,500,\fscx(120))`).
1102        let mut depth: i32 = 0;
1103        let mut idx = 0;
1104        for (k, &b) in bytes.iter().enumerate() {
1105            if b == b'(' {
1106                depth += 1;
1107            } else if b == b')' {
1108                depth -= 1;
1109                if depth == 0 {
1110                    idx = k;
1111                    break;
1112                }
1113            }
1114        }
1115        if idx == 0 {
1116            // Unterminated — take to end.
1117            return (s[1..].to_string(), bytes.len());
1118        }
1119        (s[1..idx].to_string(), idx + 1)
1120    } else {
1121        // Bare parameter — until next `\` or end.
1122        let mut k = 0;
1123        while k < bytes.len() && bytes[k] != b'\\' {
1124            k += 1;
1125        }
1126        (s[..k].to_string(), k)
1127    }
1128}
1129
1130fn parse_one(name_lc: &str, name_orig: &str, param: &str) -> Option<AnimatedTag> {
1131    match name_lc {
1132        "fad" => {
1133            let nums = parse_int_list(param);
1134            if nums.len() >= 2 {
1135                Some(AnimatedTag::Fad {
1136                    t1_ms: nums[0].max(0) as u32,
1137                    t2_ms: nums[1].max(0) as u32,
1138                })
1139            } else {
1140                None
1141            }
1142        }
1143        "fade" => {
1144            let nums = parse_int_list(param);
1145            if nums.len() >= 7 {
1146                Some(AnimatedTag::Fade {
1147                    a1: nums[0].clamp(0, 255) as u8,
1148                    a2: nums[1].clamp(0, 255) as u8,
1149                    a3: nums[2].clamp(0, 255) as u8,
1150                    t1_ms: nums[3],
1151                    t2_ms: nums[4],
1152                    t3_ms: nums[5],
1153                    t4_ms: nums[6],
1154                })
1155            } else {
1156                None
1157            }
1158        }
1159        "move" => {
1160            let nums = parse_float_list(param);
1161            match nums.len() {
1162                4 => Some(AnimatedTag::Move {
1163                    x1: nums[0],
1164                    y1: nums[1],
1165                    x2: nums[2],
1166                    y2: nums[3],
1167                    t1_ms: None,
1168                    t2_ms: None,
1169                }),
1170                6 => Some(AnimatedTag::Move {
1171                    x1: nums[0],
1172                    y1: nums[1],
1173                    x2: nums[2],
1174                    y2: nums[3],
1175                    t1_ms: Some(nums[4] as i32),
1176                    t2_ms: Some(nums[5] as i32),
1177                }),
1178                _ => None,
1179            }
1180        }
1181        "frz" | "fr" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frz),
1182        "frx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Frx),
1183        "fry" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fry),
1184        "pos" => {
1185            // `\pos(x, y)` — static line position. The spec requires
1186            // integer coordinates, but decimal values appear in the
1187            // wild, so parse as floats like \move / \org do.
1188            let n = parse_float_list(param);
1189            if n.len() == 2 {
1190                Some(AnimatedTag::Pos { x: n[0], y: n[1] })
1191            } else {
1192                None
1193            }
1194        }
1195        "org" => {
1196            let n = parse_float_list(param);
1197            if n.len() == 2 {
1198                Some(AnimatedTag::Org { x: n[0], y: n[1] })
1199            } else {
1200                None
1201            }
1202        }
1203        "blur" => param.trim().parse::<f32>().ok().map(AnimatedTag::Blur),
1204        "be" => {
1205            // `\be(N)` — iterative box-blur; the spec requires an
1206            // integer strength. Accept floats from the wild and round.
1207            let n = param.trim().parse::<f32>().ok()?;
1208            let n = n.clamp(0.0, 255.0).round() as u8;
1209            Some(AnimatedTag::Be(n))
1210        }
1211        "bord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Bord),
1212        "xbord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xbord),
1213        "ybord" => param.trim().parse::<f32>().ok().map(AnimatedTag::Ybord),
1214        "shad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Shad),
1215        "xshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Xshad),
1216        "yshad" => param.trim().parse::<f32>().ok().map(AnimatedTag::Yshad),
1217        "fax" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fax),
1218        "fay" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fay),
1219        "fscx" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscx),
1220        "fscy" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fscy),
1221        "fs" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fs),
1222        "fsp" => param.trim().parse::<f32>().ok().map(AnimatedTag::Fsp),
1223        "q" => {
1224            // `\q<mode>` — 0/1/2/3 per spec. Values outside that
1225            // range are skipped (the renderer falls back to the
1226            // script's WrapStyle header).
1227            let n: i32 = param.trim().parse().ok()?;
1228            if (0..=3).contains(&n) {
1229                Some(AnimatedTag::Q(n as u8))
1230            } else {
1231                None
1232            }
1233        }
1234        "an" => {
1235            // `\an<pos>` — numpad alignment 1..=9. Other values are
1236            // dropped (the renderer falls back to the style's
1237            // Alignment field).
1238            let n: i32 = param.trim().parse().ok()?;
1239            if (1..=9).contains(&n) {
1240                Some(AnimatedTag::An(n as u8))
1241            } else {
1242                None
1243            }
1244        }
1245        "a" => {
1246            // `\a<pos>` — legacy SubStation-Alpha alignment code. We
1247            // store the original code unchanged; the evaluator does
1248            // the numpad conversion (so the typed tag is still useful
1249            // for callers that want to inspect "was the legacy form
1250            // used?"). Negative values can never match a legacy slot
1251            // so they're rejected up front.
1252            let n: i32 = param.trim().parse().ok()?;
1253            if (0..=255).contains(&n) {
1254                Some(AnimatedTag::A(n as u8))
1255            } else {
1256                None
1257            }
1258        }
1259        "k" | "kf" | "ko" => {
1260            // `\k` family — per-syllable karaoke duration in
1261            // centiseconds. `\K` (uppercase) lowercases to `k` here, so
1262            // resolve the kind from the original-cased name: lowercase
1263            // `\k` = instant fill, `\K` = sweep (identical to `\kf`).
1264            // Negative durations clamp to 0. `\kt` is deliberately not
1265            // handled (Aegisub: "rarely useful … not documented").
1266            let cs = param.trim().parse::<f32>().ok()?;
1267            let cs = cs.max(0.0).round() as u32;
1268            let kind = match name_lc {
1269                "kf" => KaraokeKind::Sweep,
1270                "ko" => KaraokeKind::Outline,
1271                // bare "k": uppercase `\K` is the sweep variant.
1272                _ if name_orig == "K" => KaraokeKind::Sweep,
1273                _ => KaraokeKind::Fill,
1274            };
1275            Some(AnimatedTag::Karaoke { kind, cs })
1276        }
1277        "c" | "1c" => parse_color_rgb(param).map(AnimatedTag::Color1),
1278        "2c" => parse_color_rgb(param).map(AnimatedTag::Color2),
1279        "3c" => parse_color_rgb(param).map(AnimatedTag::Color3),
1280        "4c" => parse_color_rgb(param).map(AnimatedTag::Color4),
1281        "alpha" => parse_alpha_byte(param).map(AnimatedTag::Alpha),
1282        "1a" => parse_alpha_byte(param).map(AnimatedTag::Alpha1),
1283        "2a" => parse_alpha_byte(param).map(AnimatedTag::Alpha2),
1284        "3a" => parse_alpha_byte(param).map(AnimatedTag::Alpha3),
1285        "4a" => parse_alpha_byte(param).map(AnimatedTag::Alpha4),
1286        "clip" => parse_clip(param, false),
1287        "iclip" => parse_clip(param, true),
1288        "t" => parse_t(param),
1289        _ => None,
1290    }
1291}
1292
1293/// Parse an ASS alpha byte: `&HFF&` (preferred), `&HFF`, `H80`, `0xFF`,
1294/// or a bare hex string. Returns `0..=255`.
1295///
1296/// ASS only ever specifies alpha as hexadecimal (per Aegisub spec:
1297/// "in <a href='hexadecimal'>hexadecimal</a> ... `\1a&HFF&`"). Any
1298/// `&H` / `H` / `0x` prefix and `&` envelope are tolerated; the
1299/// underlying value is always parsed base-16.
1300fn parse_alpha_byte(s: &str) -> Option<u8> {
1301    let mut t = s.trim();
1302    t = t.trim_matches('&');
1303    t = t.trim_start_matches(['H', 'h']);
1304    t = t.trim_start_matches("0x");
1305    t = t.trim_matches('&').trim();
1306    if t.is_empty() {
1307        return None;
1308    }
1309    let v = u32::from_str_radix(t, 16).ok()?;
1310    Some(v.clamp(0, 255) as u8)
1311}
1312
1313fn parse_int_list(s: &str) -> Vec<i32> {
1314    s.split(',')
1315        .map(|p| p.trim().parse::<i32>().ok())
1316        .collect::<Option<Vec<_>>>()
1317        .unwrap_or_default()
1318}
1319
1320fn parse_float_list(s: &str) -> Vec<f32> {
1321    s.split(',')
1322        .map(|p| p.trim().parse::<f32>().ok())
1323        .collect::<Option<Vec<_>>>()
1324        .unwrap_or_default()
1325}
1326
1327fn parse_color_rgb(s: &str) -> Option<(u8, u8, u8)> {
1328    // Reuse the same scheme as the main parser: `&Hbbggrr&`.
1329    let s = s.trim().trim_matches('&');
1330    let s = s.trim_start_matches(['H', 'h']);
1331    let s = s.trim_start_matches("0x");
1332    let s = s.trim_end_matches('&').trim();
1333    if s.is_empty() {
1334        return None;
1335    }
1336    let v: u32 = u32::from_str_radix(s, 16).ok()?;
1337    let b = ((v >> 16) & 0xFF) as u8;
1338    let g = ((v >> 8) & 0xFF) as u8;
1339    let r = (v & 0xFF) as u8;
1340    Some((r, g, b))
1341}
1342
1343fn parse_clip(param: &str, inverse: bool) -> Option<AnimatedTag> {
1344    // `\clip(x1, y1, x2, y2)` rectangle (4 numeric args) or
1345    // `\clip([scale,] drawing)` path. `\iclip(...)` is the inverse
1346    // form: visible *outside* the rectangle / path.
1347    let parts: Vec<&str> = param.split(',').map(|s| s.trim()).collect();
1348    if parts.len() == 4 {
1349        let n: Vec<Option<f32>> = parts.iter().map(|p| p.parse::<f32>().ok()).collect();
1350        if n.iter().all(|x| x.is_some()) {
1351            let n: Vec<f32> = n.into_iter().map(|x| x.unwrap()).collect();
1352            return Some(if inverse {
1353                AnimatedTag::IClipRect {
1354                    x1: n[0],
1355                    y1: n[1],
1356                    x2: n[2],
1357                    y2: n[3],
1358                }
1359            } else {
1360                AnimatedTag::ClipRect {
1361                    x1: n[0],
1362                    y1: n[1],
1363                    x2: n[2],
1364                    y2: n[3],
1365                }
1366            });
1367        }
1368    }
1369    Some(if inverse {
1370        AnimatedTag::IClipDrawing(param.to_string())
1371    } else {
1372        AnimatedTag::ClipDrawing(param.to_string())
1373    })
1374}
1375
1376fn parse_t(param: &str) -> Option<AnimatedTag> {
1377    // Possible shapes:
1378    //   \t(tags)
1379    //   \t(accel, tags)
1380    //   \t(t1, t2, tags)
1381    //   \t(t1, t2, accel, tags)
1382    // The "tags" segment may contain commas (e.g. `\clip(..)`), so we
1383    // can't naively split on `,`. Strategy: numeric-prefix parsing —
1384    // peel off leading numbers, then everything else is the tags
1385    // string.
1386    let (nums, tags_str) = peel_leading_numbers(param);
1387    let mut inner: Vec<AnimatedTag> = Vec::new();
1388    parse_overrides(tags_str, &mut inner);
1389    let (t1, t2, accel) = match nums.len() {
1390        0 => (None, None, 1.0_f32),
1391        1 => (None, None, nums[0]),
1392        2 => (Some(nums[0] as i32), Some(nums[1] as i32), 1.0),
1393        _ => (Some(nums[0] as i32), Some(nums[1] as i32), nums[2]),
1394    };
1395    Some(AnimatedTag::T {
1396        t1_ms: t1,
1397        t2_ms: t2,
1398        accel,
1399        inner,
1400    })
1401}
1402
1403/// Peel leading comma-separated decimal numbers off `s`. Returns the
1404/// numbers and the remainder (with the leading comma stripped).
1405///
1406/// Stops at the first comma-separated token that doesn't parse as a
1407/// float, OR at a `\` (start of an inner tag), whichever comes first.
1408fn peel_leading_numbers(s: &str) -> (Vec<f32>, &str) {
1409    let mut nums = Vec::new();
1410    let mut cursor = s.trim_start();
1411    loop {
1412        // Find next `,` or `\` boundary.
1413        let bytes = cursor.as_bytes();
1414        let mut k = 0;
1415        while k < bytes.len() && bytes[k] != b',' && bytes[k] != b'\\' {
1416            k += 1;
1417        }
1418        let head = cursor[..k].trim();
1419        // If the head starts a tag (`\`), we're done with numbers.
1420        if head.is_empty() {
1421            // Empty leading token (e.g. starts with `,`) → done.
1422            if k == 0 {
1423                break;
1424            }
1425        }
1426        match head.parse::<f32>() {
1427            Ok(n) => {
1428                nums.push(n);
1429                if k >= bytes.len() {
1430                    cursor = "";
1431                    break;
1432                }
1433                if bytes[k] == b'\\' {
1434                    cursor = &cursor[k..];
1435                    break;
1436                }
1437                // bytes[k] == b','
1438                cursor = &cursor[k + 1..];
1439                cursor = cursor.trim_start();
1440            }
1441            Err(_) => break,
1442        }
1443    }
1444    (nums, cursor)
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    fn parse_block(s: &str) -> Vec<AnimatedTag> {
1452        let mut out = Vec::new();
1453        parse_overrides(s, &mut out);
1454        out
1455    }
1456
1457    #[test]
1458    fn parses_fad() {
1459        let v = parse_block(r"\fad(200,300)");
1460        assert_eq!(
1461            v,
1462            vec![AnimatedTag::Fad {
1463                t1_ms: 200,
1464                t2_ms: 300,
1465            }]
1466        );
1467    }
1468
1469    #[test]
1470    fn parses_fade7() {
1471        let v = parse_block(r"\fade(255,0,255,0,500,1500,2000)");
1472        assert_eq!(
1473            v,
1474            vec![AnimatedTag::Fade {
1475                a1: 255,
1476                a2: 0,
1477                a3: 255,
1478                t1_ms: 0,
1479                t2_ms: 500,
1480                t3_ms: 1500,
1481                t4_ms: 2000,
1482            }]
1483        );
1484    }
1485
1486    #[test]
1487    fn parses_move4_and_move6() {
1488        let v = parse_block(r"\move(10,20,100,200)");
1489        assert_eq!(v.len(), 1);
1490        match &v[0] {
1491            AnimatedTag::Move {
1492                x1,
1493                y1,
1494                x2,
1495                y2,
1496                t1_ms,
1497                t2_ms,
1498            } => {
1499                assert_eq!(*x1, 10.0);
1500                assert_eq!(*y1, 20.0);
1501                assert_eq!(*x2, 100.0);
1502                assert_eq!(*y2, 200.0);
1503                assert!(t1_ms.is_none());
1504                assert!(t2_ms.is_none());
1505            }
1506            _ => panic!(),
1507        }
1508
1509        let v = parse_block(r"\move(10,20,100,200,500,1500)");
1510        match &v[0] {
1511            AnimatedTag::Move { t1_ms, t2_ms, .. } => {
1512                assert_eq!(*t1_ms, Some(500));
1513                assert_eq!(*t2_ms, Some(1500));
1514            }
1515            _ => panic!(),
1516        }
1517    }
1518
1519    #[test]
1520    fn parses_frz_blur_fscx_fscy() {
1521        let v = parse_block(r"\frz45\blur2.5\fscx150\fscy75");
1522        assert_eq!(v.len(), 4);
1523        assert!(matches!(v[0], AnimatedTag::Frz(45.0)));
1524        assert!(matches!(v[1], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
1525        assert!(matches!(v[2], AnimatedTag::Fscx(150.0)));
1526        assert!(matches!(v[3], AnimatedTag::Fscy(75.0)));
1527    }
1528
1529    #[test]
1530    fn parses_clip_rect() {
1531        let v = parse_block(r"\clip(10,20,100,200)");
1532        assert_eq!(
1533            v,
1534            vec![AnimatedTag::ClipRect {
1535                x1: 10.0,
1536                y1: 20.0,
1537                x2: 100.0,
1538                y2: 200.0,
1539            }]
1540        );
1541    }
1542
1543    #[test]
1544    fn parses_clip_drawing_passthrough() {
1545        let v = parse_block(r"\clip(m 0 0 l 100 0 l 100 100 l 0 100)");
1546        assert_eq!(v.len(), 1);
1547        assert!(matches!(v[0], AnimatedTag::ClipDrawing(_)));
1548    }
1549
1550    #[test]
1551    fn parses_t_full() {
1552        let v = parse_block(r"\t(0,1000,1.5,\fscx200\frz90)");
1553        assert_eq!(v.len(), 1);
1554        match &v[0] {
1555            AnimatedTag::T {
1556                t1_ms,
1557                t2_ms,
1558                accel,
1559                inner,
1560            } => {
1561                assert_eq!(*t1_ms, Some(0));
1562                assert_eq!(*t2_ms, Some(1000));
1563                assert!((accel - 1.5).abs() < 1e-6);
1564                assert_eq!(inner.len(), 2);
1565                assert!(matches!(inner[0], AnimatedTag::Fscx(200.0)));
1566                assert!(matches!(inner[1], AnimatedTag::Frz(90.0)));
1567            }
1568            _ => panic!(),
1569        }
1570    }
1571
1572    #[test]
1573    fn parses_t_no_times() {
1574        let v = parse_block(r"\t(\frz360)");
1575        match &v[0] {
1576            AnimatedTag::T {
1577                t1_ms,
1578                t2_ms,
1579                accel,
1580                inner,
1581            } => {
1582                assert!(t1_ms.is_none());
1583                assert!(t2_ms.is_none());
1584                assert!((accel - 1.0).abs() < 1e-6);
1585                assert_eq!(inner.len(), 1);
1586            }
1587            _ => panic!(),
1588        }
1589    }
1590
1591    #[test]
1592    fn parses_t_two_times_no_accel() {
1593        let v = parse_block(r"\t(0,500,\frz45)");
1594        match &v[0] {
1595            AnimatedTag::T {
1596                t1_ms,
1597                t2_ms,
1598                accel,
1599                inner,
1600            } => {
1601                assert_eq!(*t1_ms, Some(0));
1602                assert_eq!(*t2_ms, Some(500));
1603                assert!((accel - 1.0).abs() < 1e-6);
1604                assert_eq!(inner.len(), 1);
1605            }
1606            _ => panic!(),
1607        }
1608    }
1609
1610    #[test]
1611    fn parses_color() {
1612        let v = parse_block(r"\c&H0000FF&");
1613        assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 0))]);
1614        let v = parse_block(r"\1c&HFF00FF&");
1615        assert_eq!(v, vec![AnimatedTag::Color1((255, 0, 255))]);
1616    }
1617
1618    #[test]
1619    fn fad_alpha_curve() {
1620        // Cue 0..2000 ms, fade in 200, fade out 300.
1621        let dur = 2000;
1622        assert!((fad_alpha(200, 300, 0, dur) - 0.0).abs() < 1e-6);
1623        assert!((fad_alpha(200, 300, 100, dur) - 0.5).abs() < 1e-6);
1624        assert!((fad_alpha(200, 300, 200, dur) - 1.0).abs() < 1e-6);
1625        assert!((fad_alpha(200, 300, 1000, dur) - 1.0).abs() < 1e-6);
1626        assert!((fad_alpha(200, 300, 1700, dur) - 1.0).abs() < 1e-6);
1627        // Halfway through fade-out: dur-t = 150, t2 = 300 → 0.5
1628        assert!((fad_alpha(200, 300, 1850, dur) - 0.5).abs() < 1e-6);
1629        assert!((fad_alpha(200, 300, 2000, dur) - 0.0).abs() < 1e-6);
1630    }
1631
1632    #[test]
1633    fn evaluate_static_overrides() {
1634        let cue_anim = CueAnimation {
1635            tags: vec![
1636                AnimatedTag::Fscx(200.0),
1637                AnimatedTag::Fscy(50.0),
1638                AnimatedTag::Frz(90.0),
1639                AnimatedTag::Blur(3.0),
1640            ],
1641        };
1642        let st = cue_anim.evaluate_at(500, 1000);
1643        assert_eq!(st.scale, (2.0, 0.5));
1644        assert!((st.rotate_radians - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
1645        assert_eq!(st.blur_sigma, 3.0);
1646    }
1647
1648    #[test]
1649    fn evaluate_move() {
1650        let cue_anim = CueAnimation {
1651            tags: vec![AnimatedTag::Move {
1652                x1: 0.0,
1653                y1: 0.0,
1654                x2: 100.0,
1655                y2: 200.0,
1656                t1_ms: Some(0),
1657                t2_ms: Some(1000),
1658            }],
1659        };
1660        let st0 = cue_anim.evaluate_at(0, 1000);
1661        assert_eq!(st0.translate, Some((0.0, 0.0)));
1662        let st_mid = cue_anim.evaluate_at(500, 1000);
1663        assert_eq!(st_mid.translate, Some((50.0, 100.0)));
1664        let st_end = cue_anim.evaluate_at(1000, 1000);
1665        assert_eq!(st_end.translate, Some((100.0, 200.0)));
1666        // Past end clamps.
1667        let st_after = cue_anim.evaluate_at(2000, 1000);
1668        assert_eq!(st_after.translate, Some((100.0, 200.0)));
1669    }
1670
1671    #[test]
1672    fn evaluate_move_default_times() {
1673        // No t1/t2 given → animate over the whole cue.
1674        let cue_anim = CueAnimation {
1675            tags: vec![AnimatedTag::Move {
1676                x1: 0.0,
1677                y1: 0.0,
1678                x2: 100.0,
1679                y2: 100.0,
1680                t1_ms: None,
1681                t2_ms: None,
1682            }],
1683        };
1684        let st = cue_anim.evaluate_at(500, 1000);
1685        assert_eq!(st.translate, Some((50.0, 50.0)));
1686    }
1687
1688    #[test]
1689    fn parses_pos() {
1690        let v = parse_block(r"\pos(320,240)");
1691        assert_eq!(v, vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }]);
1692        // Decimals tolerated even though the spec asks for integers.
1693        let v = parse_block(r"\pos(12.5,-3.0)");
1694        assert_eq!(v, vec![AnimatedTag::Pos { x: 12.5, y: -3.0 }]);
1695        // Wrong arity → dropped (round-trip text path still keeps it raw).
1696        assert!(parse_block(r"\pos(320)").is_empty());
1697        assert!(parse_block(r"\pos(1,2,3)").is_empty());
1698    }
1699
1700    #[test]
1701    fn evaluate_pos_is_static() {
1702        // \pos sets a constant position the renderer can anchor to; it
1703        // does not vary with time.
1704        let cue_anim = CueAnimation {
1705            tags: vec![AnimatedTag::Pos { x: 320.0, y: 240.0 }],
1706        };
1707        assert_eq!(
1708            cue_anim.evaluate_at(0, 1000).translate,
1709            Some((320.0, 240.0))
1710        );
1711        assert_eq!(
1712            cue_anim.evaluate_at(500, 1000).translate,
1713            Some((320.0, 240.0))
1714        );
1715        assert_eq!(
1716            cue_anim.evaluate_at(1000, 1000).translate,
1717            Some((320.0, 240.0))
1718        );
1719    }
1720
1721    #[test]
1722    fn move_after_pos_overrides() {
1723        // \move and \pos both target the line position; the later tag
1724        // wins (last-writer-wins, matching the rest of the module).
1725        let cue_anim = CueAnimation {
1726            tags: vec![
1727                AnimatedTag::Pos { x: 10.0, y: 10.0 },
1728                AnimatedTag::Move {
1729                    x1: 0.0,
1730                    y1: 0.0,
1731                    x2: 100.0,
1732                    y2: 100.0,
1733                    t1_ms: Some(0),
1734                    t2_ms: Some(1000),
1735                },
1736            ],
1737        };
1738        // The \move drives translate, not the earlier \pos.
1739        assert_eq!(
1740            cue_anim.evaluate_at(500, 1000).translate,
1741            Some((50.0, 50.0))
1742        );
1743    }
1744
1745    #[test]
1746    fn evaluate_fad() {
1747        let cue_anim = CueAnimation {
1748            tags: vec![AnimatedTag::Fad {
1749                t1_ms: 200,
1750                t2_ms: 300,
1751            }],
1752        };
1753        let dur = 2000;
1754        assert!((cue_anim.evaluate_at(0, dur).alpha_mul - 0.0).abs() < 1e-6);
1755        assert!((cue_anim.evaluate_at(100, dur).alpha_mul - 0.5).abs() < 1e-6);
1756        assert!((cue_anim.evaluate_at(1000, dur).alpha_mul - 1.0).abs() < 1e-6);
1757        assert!((cue_anim.evaluate_at(1850, dur).alpha_mul - 0.5).abs() < 1e-6);
1758    }
1759
1760    #[test]
1761    fn evaluate_t_interpolates_scale() {
1762        // Initial fscx is implicit 100% (=1.0 scale). \t over [0,1000]
1763        // ramps to 200% (=2.0 scale).
1764        let cue_anim = CueAnimation {
1765            tags: vec![AnimatedTag::T {
1766                t1_ms: Some(0),
1767                t2_ms: Some(1000),
1768                accel: 1.0,
1769                inner: vec![AnimatedTag::Fscx(200.0)],
1770            }],
1771        };
1772        assert_eq!(cue_anim.evaluate_at(0, 1000).scale.0, 1.0);
1773        assert!((cue_anim.evaluate_at(500, 1000).scale.0 - 1.5).abs() < 1e-6);
1774        assert_eq!(cue_anim.evaluate_at(1000, 1000).scale.0, 2.0);
1775        assert_eq!(cue_anim.evaluate_at(1500, 1000).scale.0, 2.0);
1776    }
1777
1778    #[test]
1779    fn evaluate_t_interpolates_rotate() {
1780        let cue_anim = CueAnimation {
1781            tags: vec![AnimatedTag::T {
1782                t1_ms: Some(0),
1783                t2_ms: Some(1000),
1784                accel: 1.0,
1785                inner: vec![AnimatedTag::Frz(90.0)],
1786            }],
1787        };
1788        let st_mid = cue_anim.evaluate_at(500, 1000);
1789        // 45 degrees in radians.
1790        assert!((st_mid.rotate_radians - std::f32::consts::FRAC_PI_4).abs() < 1e-5);
1791    }
1792
1793    #[test]
1794    fn evaluate_t_interpolates_color() {
1795        let cue_anim = CueAnimation {
1796            tags: vec![
1797                AnimatedTag::Color1((255, 0, 0)), // start red
1798                AnimatedTag::T {
1799                    t1_ms: Some(0),
1800                    t2_ms: Some(1000),
1801                    accel: 1.0,
1802                    inner: vec![AnimatedTag::Color1((0, 0, 255))], // blue
1803                },
1804            ],
1805        };
1806        let st = cue_anim.evaluate_at(500, 1000);
1807        let rgb = st.primary_color.unwrap();
1808        // Halfway → roughly (127, 0, 127).
1809        assert!((rgb.0 as i32 - 127).abs() <= 1);
1810        assert_eq!(rgb.1, 0);
1811        assert!((rgb.2 as i32 - 127).abs() <= 1);
1812    }
1813
1814    #[test]
1815    fn evaluate_t_no_times_uses_cue_span() {
1816        let cue_anim = CueAnimation {
1817            tags: vec![AnimatedTag::T {
1818                t1_ms: None,
1819                t2_ms: None,
1820                accel: 1.0,
1821                inner: vec![AnimatedTag::Fscy(200.0)],
1822            }],
1823        };
1824        // Halfway through a 2000ms cue: scale.1 should be 1.5.
1825        let st = cue_anim.evaluate_at(1000, 2000);
1826        assert!((st.scale.1 - 1.5).abs() < 1e-6);
1827    }
1828
1829    #[test]
1830    fn clip_rect_applies() {
1831        let cue_anim = CueAnimation {
1832            tags: vec![AnimatedTag::ClipRect {
1833                x1: 10.0,
1834                y1: 20.0,
1835                x2: 100.0,
1836                y2: 200.0,
1837            }],
1838        };
1839        let st = cue_anim.evaluate_at(0, 1000);
1840        let c = st.clip_rect.unwrap();
1841        assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
1842    }
1843
1844    #[test]
1845    fn clip_rect_normalises_swapped_corners() {
1846        let cue_anim = CueAnimation {
1847            tags: vec![AnimatedTag::ClipRect {
1848                x1: 100.0,
1849                y1: 200.0,
1850                x2: 10.0,
1851                y2: 20.0,
1852            }],
1853        };
1854        let st = cue_anim.evaluate_at(0, 1000);
1855        let c = st.clip_rect.unwrap();
1856        assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
1857    }
1858
1859    #[test]
1860    fn extract_from_cue_segments() {
1861        // Build a fake cue using the parser's output shape.
1862        let cue = SubtitleCue {
1863            start_us: 0,
1864            end_us: 1_000_000,
1865            style_ref: None,
1866            positioning: None,
1867            segments: vec![
1868                Segment::Raw(r"{\fad(100,200)\frz30}".into()),
1869                Segment::Text("hello".into()),
1870                Segment::Raw(r"{\move(0,0,100,100)}".into()),
1871            ],
1872        };
1873        let anim = extract_cue_animation(&cue);
1874        assert_eq!(anim.tags.len(), 3);
1875        assert!(matches!(
1876            anim.tags[0],
1877            AnimatedTag::Fad {
1878                t1_ms: 100,
1879                t2_ms: 200
1880            }
1881        ));
1882        assert!(matches!(anim.tags[1], AnimatedTag::Frz(30.0)));
1883        assert!(matches!(anim.tags[2], AnimatedTag::Move { .. }));
1884    }
1885
1886    #[test]
1887    fn extract_skips_non_animated_raw() {
1888        // Unknown tag like `\xyz` should not yield anything.
1889        let cue = SubtitleCue {
1890            start_us: 0,
1891            end_us: 1_000_000,
1892            style_ref: None,
1893            positioning: None,
1894            segments: vec![Segment::Raw(r"{\xyz(1,2)}".into())],
1895        };
1896        let anim = extract_cue_animation(&cue);
1897        assert!(anim.is_empty());
1898    }
1899
1900    #[test]
1901    fn extract_recurses_into_color_children() {
1902        let cue = SubtitleCue {
1903            start_us: 0,
1904            end_us: 0,
1905            style_ref: None,
1906            positioning: None,
1907            segments: vec![Segment::Color {
1908                rgb: (1, 2, 3),
1909                children: vec![Segment::Raw(r"{\fad(50,50)}".into())],
1910            }],
1911        };
1912        let anim = extract_cue_animation(&cue);
1913        assert_eq!(anim.tags.len(), 1);
1914        assert!(matches!(
1915            anim.tags[0],
1916            AnimatedTag::Fad {
1917                t1_ms: 50,
1918                t2_ms: 50
1919            }
1920        ));
1921    }
1922
1923    #[test]
1924    fn transform_composition_includes_translate() {
1925        let cue_anim = CueAnimation {
1926            tags: vec![
1927                AnimatedTag::Move {
1928                    x1: 100.0,
1929                    y1: 200.0,
1930                    x2: 100.0,
1931                    y2: 200.0,
1932                    t1_ms: None,
1933                    t2_ms: None,
1934                },
1935                AnimatedTag::Fscx(200.0),
1936            ],
1937        };
1938        let st = cue_anim.evaluate_at(0, 1000);
1939        // Apply transform to origin → should land at (100, 200).
1940        let p = st.transform.apply(oxideav_core::Point { x: 0.0, y: 0.0 });
1941        assert!((p.x - 100.0).abs() < 1e-5);
1942        assert!((p.y - 200.0).abs() < 1e-5);
1943        // Apply transform to (1, 0) → scale 2x in x then translate.
1944        let p1 = st.transform.apply(oxideav_core::Point { x: 1.0, y: 0.0 });
1945        assert!((p1.x - 102.0).abs() < 1e-5);
1946        assert!((p1.y - 200.0).abs() < 1e-5);
1947    }
1948
1949    // -----------------------------------------------------------------
1950    // r76 typed tag coverage: \bord/\xbord/\ybord, \shad/\xshad/\yshad,
1951    // \be (distinct from \blur), \fax/\fay, \iclip.
1952
1953    #[test]
1954    fn parses_bord_uniform() {
1955        let v = parse_block(r"\bord3.5");
1956        assert_eq!(v, vec![AnimatedTag::Bord(3.5)]);
1957    }
1958
1959    #[test]
1960    fn parses_xbord_ybord_pair() {
1961        let v = parse_block(r"\xbord2\ybord4");
1962        assert_eq!(v, vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)]);
1963    }
1964
1965    #[test]
1966    fn parses_shad_uniform_and_per_axis() {
1967        let v = parse_block(r"\shad5\xshad-2.5\yshad3");
1968        assert_eq!(
1969            v,
1970            vec![
1971                AnimatedTag::Shad(5.0),
1972                AnimatedTag::Xshad(-2.5),
1973                AnimatedTag::Yshad(3.0),
1974            ]
1975        );
1976    }
1977
1978    #[test]
1979    fn parses_blur_and_be_are_separate_variants() {
1980        // Per Aegisub spec these are different filters; the old impl
1981        // collapsed both into Blur, which lost \be vs \blur fidelity.
1982        let v = parse_block(r"\blur2.5\be3");
1983        assert_eq!(v.len(), 2);
1984        assert!(matches!(v[0], AnimatedTag::Blur(b) if (b - 2.5).abs() < 1e-6));
1985        assert!(matches!(v[1], AnimatedTag::Be(3)));
1986    }
1987
1988    #[test]
1989    fn be_rounds_non_integer_strengths() {
1990        // Spec says integer; tolerate floats from the wild.
1991        let v = parse_block(r"\be2.7");
1992        assert!(matches!(v[0], AnimatedTag::Be(3)));
1993    }
1994
1995    #[test]
1996    fn parses_fax_fay() {
1997        let v = parse_block(r"\fax0.5\fay-0.25");
1998        assert_eq!(v, vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.25)]);
1999    }
2000
2001    #[test]
2002    fn parses_iclip_rect() {
2003        let v = parse_block(r"\iclip(10,20,100,200)");
2004        assert_eq!(
2005            v,
2006            vec![AnimatedTag::IClipRect {
2007                x1: 10.0,
2008                y1: 20.0,
2009                x2: 100.0,
2010                y2: 200.0,
2011            }]
2012        );
2013    }
2014
2015    #[test]
2016    fn parses_iclip_drawing_passthrough() {
2017        let v = parse_block(r"\iclip(m 0 0 l 100 0 l 100 100 l 0 100)");
2018        assert_eq!(v.len(), 1);
2019        assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
2020    }
2021
2022    #[test]
2023    fn parses_iclip_with_scale_prefix_is_drawing_form() {
2024        // `\iclip(scale, drawing)` — two-arg form is the scaled drawing
2025        // variant, NOT a rect (rect requires exactly 4 numeric args).
2026        let v = parse_block(r"\iclip(2,m 0 0 l 50 50)");
2027        assert!(matches!(v[0], AnimatedTag::IClipDrawing(_)));
2028    }
2029
2030    #[test]
2031    fn evaluate_bord_sets_both_axes() {
2032        let cue_anim = CueAnimation {
2033            tags: vec![AnimatedTag::Bord(2.5)],
2034        };
2035        let st = cue_anim.evaluate_at(0, 1000);
2036        assert_eq!(st.border, Some((2.5, 2.5)));
2037    }
2038
2039    #[test]
2040    fn evaluate_xbord_then_ybord_combines() {
2041        let cue_anim = CueAnimation {
2042            tags: vec![AnimatedTag::Xbord(2.0), AnimatedTag::Ybord(4.0)],
2043        };
2044        let st = cue_anim.evaluate_at(0, 1000);
2045        assert_eq!(st.border, Some((2.0, 4.0)));
2046    }
2047
2048    #[test]
2049    fn evaluate_bord_after_xbord_ybord_overrides_both() {
2050        // Spec: "if you use \bord after \xbord or \ybord on a line, it
2051        // will [override them]".
2052        let cue_anim = CueAnimation {
2053            tags: vec![
2054                AnimatedTag::Xbord(2.0),
2055                AnimatedTag::Ybord(4.0),
2056                AnimatedTag::Bord(1.0),
2057            ],
2058        };
2059        let st = cue_anim.evaluate_at(0, 1000);
2060        assert_eq!(st.border, Some((1.0, 1.0)));
2061    }
2062
2063    #[test]
2064    fn evaluate_bord_clamps_negative_to_zero() {
2065        // Spec: "Border width cannot be negative."
2066        let cue_anim = CueAnimation {
2067            tags: vec![AnimatedTag::Bord(-3.0)],
2068        };
2069        let st = cue_anim.evaluate_at(0, 1000);
2070        assert_eq!(st.border, Some((0.0, 0.0)));
2071    }
2072
2073    #[test]
2074    fn evaluate_shad_uniform_and_xshad_yshad_negative() {
2075        // \shad uniform must be non-negative per spec; \xshad/\yshad
2076        // may be negative (shadow above/left of text).
2077        let cue_anim = CueAnimation {
2078            tags: vec![AnimatedTag::Shad(2.0)],
2079        };
2080        let st = cue_anim.evaluate_at(0, 1000);
2081        assert_eq!(st.shadow, Some((2.0, 2.0)));
2082
2083        let cue_anim2 = CueAnimation {
2084            tags: vec![AnimatedTag::Xshad(-3.5), AnimatedTag::Yshad(1.5)],
2085        };
2086        let st2 = cue_anim2.evaluate_at(0, 1000);
2087        assert_eq!(st2.shadow, Some((-3.5, 1.5)));
2088
2089        // \shad must be clamped to >= 0 (spec).
2090        let cue_anim3 = CueAnimation {
2091            tags: vec![AnimatedTag::Shad(-2.0)],
2092        };
2093        let st3 = cue_anim3.evaluate_at(0, 1000);
2094        assert_eq!(st3.shadow, Some((0.0, 0.0)));
2095    }
2096
2097    #[test]
2098    fn evaluate_be_strength() {
2099        let cue_anim = CueAnimation {
2100            tags: vec![AnimatedTag::Be(5)],
2101        };
2102        let st = cue_anim.evaluate_at(0, 1000);
2103        assert_eq!(st.be_strength, 5);
2104        // And \be does NOT touch blur_sigma (which is \blur).
2105        assert_eq!(st.blur_sigma, 0.0);
2106    }
2107
2108    #[test]
2109    fn evaluate_fax_fay_writes_shear() {
2110        let cue_anim = CueAnimation {
2111            tags: vec![AnimatedTag::Fax(0.5), AnimatedTag::Fay(-0.3)],
2112        };
2113        let st = cue_anim.evaluate_at(0, 1000);
2114        assert!((st.shear.0 - 0.5).abs() < 1e-6);
2115        assert!((st.shear.1 + 0.3).abs() < 1e-6);
2116    }
2117
2118    #[test]
2119    fn evaluate_iclip_rect_normalises() {
2120        let cue_anim = CueAnimation {
2121            tags: vec![AnimatedTag::IClipRect {
2122                x1: 100.0,
2123                y1: 200.0,
2124                x2: 10.0,
2125                y2: 20.0,
2126            }],
2127        };
2128        let st = cue_anim.evaluate_at(0, 1000);
2129        let c = st.iclip_rect.unwrap();
2130        assert_eq!((c.x1, c.y1, c.x2, c.y2), (10.0, 20.0, 100.0, 200.0));
2131        // \iclip and \clip are mutually exclusive in the cue but
2132        // independent fields on RenderState; only iclip_rect is set.
2133        assert!(st.clip_rect.is_none());
2134    }
2135
2136    #[test]
2137    fn evaluate_iclip_drawing_stored() {
2138        let cue_anim = CueAnimation {
2139            tags: vec![AnimatedTag::IClipDrawing("m 0 0 l 10 10".into())],
2140        };
2141        let st = cue_anim.evaluate_at(0, 1000);
2142        assert_eq!(st.iclip_drawing.as_deref(), Some("m 0 0 l 10 10"));
2143        assert!(st.clip_drawing.is_none());
2144    }
2145
2146    #[test]
2147    fn t_interpolates_bord() {
2148        // \bord(0) at t=0, ramps to \bord(4) at t=1000.
2149        let cue_anim = CueAnimation {
2150            tags: vec![
2151                AnimatedTag::Bord(0.0),
2152                AnimatedTag::T {
2153                    t1_ms: Some(0),
2154                    t2_ms: Some(1000),
2155                    accel: 1.0,
2156                    inner: vec![AnimatedTag::Bord(4.0)],
2157                },
2158            ],
2159        };
2160        let st_mid = cue_anim.evaluate_at(500, 1000);
2161        let (bx, by) = st_mid.border.unwrap();
2162        assert!((bx - 2.0).abs() < 1e-5, "bx = {}", bx);
2163        assert!((by - 2.0).abs() < 1e-5);
2164        let st_end = cue_anim.evaluate_at(1000, 1000);
2165        let (bx2, by2) = st_end.border.unwrap();
2166        assert!((bx2 - 4.0).abs() < 1e-5);
2167        assert!((by2 - 4.0).abs() < 1e-5);
2168    }
2169
2170    #[test]
2171    fn t_interpolates_shad_per_axis() {
2172        let cue_anim = CueAnimation {
2173            tags: vec![
2174                AnimatedTag::Xshad(0.0),
2175                AnimatedTag::Yshad(0.0),
2176                AnimatedTag::T {
2177                    t1_ms: Some(0),
2178                    t2_ms: Some(1000),
2179                    accel: 1.0,
2180                    inner: vec![AnimatedTag::Xshad(6.0), AnimatedTag::Yshad(-2.0)],
2181                },
2182            ],
2183        };
2184        let st = cue_anim.evaluate_at(500, 1000);
2185        let (sx, sy) = st.shadow.unwrap();
2186        assert!((sx - 3.0).abs() < 1e-5);
2187        assert!((sy + 1.0).abs() < 1e-5);
2188    }
2189
2190    #[test]
2191    fn t_interpolates_fax_fay() {
2192        let cue_anim = CueAnimation {
2193            tags: vec![AnimatedTag::T {
2194                t1_ms: Some(0),
2195                t2_ms: Some(1000),
2196                accel: 1.0,
2197                inner: vec![AnimatedTag::Fax(1.0)],
2198            }],
2199        };
2200        let st = cue_anim.evaluate_at(500, 1000);
2201        // Starting shear is (0, 0); halfway to (1, 0) → 0.5.
2202        assert!((st.shear.0 - 0.5).abs() < 1e-5);
2203    }
2204
2205    #[test]
2206    fn t_interpolates_be_rounds_to_integer() {
2207        let cue_anim = CueAnimation {
2208            tags: vec![
2209                AnimatedTag::Be(0),
2210                AnimatedTag::T {
2211                    t1_ms: Some(0),
2212                    t2_ms: Some(1000),
2213                    accel: 1.0,
2214                    inner: vec![AnimatedTag::Be(10)],
2215                },
2216            ],
2217        };
2218        let st = cue_anim.evaluate_at(500, 1000);
2219        // Halfway: 5 (rounded).
2220        assert_eq!(st.be_strength, 5);
2221        let st_q = cue_anim.evaluate_at(250, 1000);
2222        // Quarter: 2.5 → rounds to 3 (round-half-to-even per f32 round).
2223        assert!(st_q.be_strength == 2 || st_q.be_strength == 3);
2224    }
2225
2226    #[test]
2227    fn extract_typed_tags_from_real_world_cue() {
2228        // A composite cue that exercises every new typed tag in a single
2229        // Dialogue line — representative of dense typesetting subs.
2230        let cue = SubtitleCue {
2231            start_us: 0,
2232            end_us: 5_000_000,
2233            style_ref: None,
2234            positioning: None,
2235            segments: vec![
2236                Segment::Raw(
2237                    r"{\bord2\xbord3\ybord4\shad1\xshad-2\yshad2\blur1.5\be2\fax0.1\fay-0.1\iclip(0,0,640,480)}"
2238                        .into(),
2239                ),
2240                Segment::Text("text".into()),
2241            ],
2242        };
2243        let anim = extract_cue_animation(&cue);
2244        assert_eq!(anim.tags.len(), 11, "got {:?}", anim.tags);
2245        let st = anim.evaluate_at(0, 5000);
2246        // Border: \bord(2) then xbord=3,ybord=4 overrides → (3,4).
2247        assert_eq!(st.border, Some((3.0, 4.0)));
2248        // Shadow: \shad(1) then xshad=-2, yshad=2 → (-2, 2).
2249        assert_eq!(st.shadow, Some((-2.0, 2.0)));
2250        assert!((st.blur_sigma - 1.5).abs() < 1e-6);
2251        assert_eq!(st.be_strength, 2);
2252        assert!((st.shear.0 - 0.1).abs() < 1e-6);
2253        assert!((st.shear.1 + 0.1).abs() < 1e-6);
2254        let c = st.iclip_rect.unwrap();
2255        assert_eq!((c.x1, c.y1, c.x2, c.y2), (0.0, 0.0, 640.0, 480.0));
2256    }
2257
2258    // -----------------------------------------------------------------
2259    // r81 typed tag coverage: \2c / \3c / \4c per-component colours +
2260    // \alpha + \1a..\4a per-component alphas.
2261
2262    #[test]
2263    fn parses_color2_color3_color4() {
2264        let v = parse_block(r"\2c&H0000FF&\3c&H00FF00&\4c&HFF0000&");
2265        assert_eq!(
2266            v,
2267            vec![
2268                AnimatedTag::Color2((255, 0, 0)),
2269                AnimatedTag::Color3((0, 255, 0)),
2270                AnimatedTag::Color4((0, 0, 255)),
2271            ]
2272        );
2273    }
2274
2275    #[test]
2276    fn parses_alpha_all_and_per_component() {
2277        let v = parse_block(r"\alpha&H80&\1a&HFF&\2a&H00&\3a&H40&\4a&HC0&");
2278        assert_eq!(
2279            v,
2280            vec![
2281                AnimatedTag::Alpha(0x80),
2282                AnimatedTag::Alpha1(0xFF),
2283                AnimatedTag::Alpha2(0x00),
2284                AnimatedTag::Alpha3(0x40),
2285                AnimatedTag::Alpha4(0xC0),
2286            ]
2287        );
2288    }
2289
2290    #[test]
2291    fn parses_alpha_tolerates_envelope_variants() {
2292        // All four shapes the wild emits should parse identically.
2293        assert_eq!(parse_alpha_byte("&HFF&"), Some(0xFF));
2294        assert_eq!(parse_alpha_byte("&HFF"), Some(0xFF));
2295        assert_eq!(parse_alpha_byte("HFF"), Some(0xFF));
2296        assert_eq!(parse_alpha_byte("0xFF"), Some(0xFF));
2297        assert_eq!(parse_alpha_byte("ff"), Some(0xFF));
2298        assert_eq!(parse_alpha_byte(""), None);
2299    }
2300
2301    #[test]
2302    fn evaluate_color2_color3_color4_writes_separate_fields() {
2303        let cue_anim = CueAnimation {
2304            tags: vec![
2305                AnimatedTag::Color2((10, 20, 30)),
2306                AnimatedTag::Color3((40, 50, 60)),
2307                AnimatedTag::Color4((70, 80, 90)),
2308            ],
2309        };
2310        let st = cue_anim.evaluate_at(0, 1000);
2311        assert_eq!(st.secondary_color, Some((10, 20, 30)));
2312        assert_eq!(st.outline_color, Some((40, 50, 60)));
2313        assert_eq!(st.shadow_color, Some((70, 80, 90)));
2314        // \2c / \3c / \4c must not pollute \1c.
2315        assert_eq!(st.primary_color, None);
2316    }
2317
2318    #[test]
2319    fn evaluate_alpha_global_sets_all_four_channels() {
2320        let cue_anim = CueAnimation {
2321            tags: vec![AnimatedTag::Alpha(0x80)],
2322        };
2323        let st = cue_anim.evaluate_at(0, 1000);
2324        assert_eq!(st.primary_alpha, Some(0x80));
2325        assert_eq!(st.secondary_alpha, Some(0x80));
2326        assert_eq!(st.outline_alpha, Some(0x80));
2327        assert_eq!(st.shadow_alpha, Some(0x80));
2328    }
2329
2330    #[test]
2331    fn evaluate_per_component_alpha_overrides_global() {
2332        // \alpha sets all four, then \3a&HFF& makes border transparent.
2333        let cue_anim = CueAnimation {
2334            tags: vec![AnimatedTag::Alpha(0x40), AnimatedTag::Alpha3(0xFF)],
2335        };
2336        let st = cue_anim.evaluate_at(0, 1000);
2337        assert_eq!(st.primary_alpha, Some(0x40));
2338        assert_eq!(st.secondary_alpha, Some(0x40));
2339        assert_eq!(st.outline_alpha, Some(0xFF));
2340        assert_eq!(st.shadow_alpha, Some(0x40));
2341    }
2342
2343    #[test]
2344    fn alpha_per_component_does_not_touch_alpha_mul() {
2345        // \fad alpha_mul is the cue-level envelope; per-component
2346        // alphas (\1a..\4a) are independent overrides on top.
2347        let cue_anim = CueAnimation {
2348            tags: vec![AnimatedTag::Alpha1(0x80), AnimatedTag::Alpha3(0xC0)],
2349        };
2350        let st = cue_anim.evaluate_at(0, 1000);
2351        assert_eq!(st.alpha_mul, 1.0);
2352        assert_eq!(st.primary_alpha, Some(0x80));
2353        assert_eq!(st.outline_alpha, Some(0xC0));
2354    }
2355
2356    #[test]
2357    fn t_interpolates_color3() {
2358        // Border colour interpolation: red → blue over [0, 1000].
2359        let cue_anim = CueAnimation {
2360            tags: vec![
2361                AnimatedTag::Color3((255, 0, 0)),
2362                AnimatedTag::T {
2363                    t1_ms: Some(0),
2364                    t2_ms: Some(1000),
2365                    accel: 1.0,
2366                    inner: vec![AnimatedTag::Color3((0, 0, 255))],
2367                },
2368            ],
2369        };
2370        let st = cue_anim.evaluate_at(500, 1000);
2371        let rgb = st.outline_color.unwrap();
2372        assert!((rgb.0 as i32 - 127).abs() <= 1);
2373        assert_eq!(rgb.1, 0);
2374        assert!((rgb.2 as i32 - 127).abs() <= 1);
2375    }
2376
2377    #[test]
2378    fn t_interpolates_alpha1() {
2379        // Primary alpha 0x00 → 0xFF over [0, 1000]. At t=500 ≈ 0x80.
2380        let cue_anim = CueAnimation {
2381            tags: vec![
2382                AnimatedTag::Alpha1(0x00),
2383                AnimatedTag::T {
2384                    t1_ms: Some(0),
2385                    t2_ms: Some(1000),
2386                    accel: 1.0,
2387                    inner: vec![AnimatedTag::Alpha1(0xFF)],
2388                },
2389            ],
2390        };
2391        let st = cue_anim.evaluate_at(500, 1000);
2392        let a = st.primary_alpha.unwrap();
2393        assert!((a as i32 - 0x80).abs() <= 1, "got {:#x}", a);
2394        // Endpoint sanity.
2395        let st_end = cue_anim.evaluate_at(1000, 1000);
2396        assert_eq!(st_end.primary_alpha, Some(0xFF));
2397    }
2398
2399    #[test]
2400    fn t_interpolates_alpha_global_writes_all_four() {
2401        // \alpha:&H00& → &HFF& halfway gives 0x80 on every channel.
2402        let cue_anim = CueAnimation {
2403            tags: vec![
2404                AnimatedTag::Alpha(0x00),
2405                AnimatedTag::T {
2406                    t1_ms: Some(0),
2407                    t2_ms: Some(1000),
2408                    accel: 1.0,
2409                    inner: vec![AnimatedTag::Alpha(0xFF)],
2410                },
2411            ],
2412        };
2413        let st = cue_anim.evaluate_at(500, 1000);
2414        for ch in [
2415            st.primary_alpha,
2416            st.secondary_alpha,
2417            st.outline_alpha,
2418            st.shadow_alpha,
2419        ] {
2420            let a = ch.unwrap();
2421            assert!((a as i32 - 0x80).abs() <= 1);
2422        }
2423    }
2424
2425    #[test]
2426    fn extract_full_alpha_and_color_cue() {
2427        // Composite real-world cue: per-axis colours + per-channel
2428        // alphas all in a single override block.
2429        let cue = SubtitleCue {
2430            start_us: 0,
2431            end_us: 2_000_000,
2432            style_ref: None,
2433            positioning: None,
2434            segments: vec![
2435                Segment::Raw(
2436                    r"{\1c&H0000FF&\2c&H00FF00&\3c&HFF0000&\4c&H808080&\alpha&H80&\3a&HFF&}".into(),
2437                ),
2438                Segment::Text("text".into()),
2439            ],
2440        };
2441        let anim = extract_cue_animation(&cue);
2442        assert_eq!(anim.tags.len(), 6, "got {:?}", anim.tags);
2443        let st = anim.evaluate_at(0, 2000);
2444        assert_eq!(st.primary_color, Some((255, 0, 0)));
2445        assert_eq!(st.secondary_color, Some((0, 255, 0)));
2446        assert_eq!(st.outline_color, Some((0, 0, 255)));
2447        assert_eq!(st.shadow_color, Some((128, 128, 128)));
2448        // \alpha 0x80 → all four channels 0x80, then \3a&HFF& overrides
2449        // the border channel only.
2450        assert_eq!(st.primary_alpha, Some(0x80));
2451        assert_eq!(st.secondary_alpha, Some(0x80));
2452        assert_eq!(st.outline_alpha, Some(0xFF));
2453        assert_eq!(st.shadow_alpha, Some(0x80));
2454    }
2455
2456    #[test]
2457    fn unrecognised_color_or_alpha_payload_is_skipped() {
2458        // Empty payload or junk yields no AnimatedTag (parser drops it).
2459        assert!(parse_block(r"\2c&Hgggggg&").is_empty());
2460        assert!(parse_block(r"\1a").is_empty());
2461        assert!(parse_block(r"\3c").is_empty());
2462    }
2463
2464    #[test]
2465    fn capital_k_karaoke_tag_is_recognised_as_kf() {
2466        // Per Aegisub: "\K and \kf are identical". Our base parser
2467        // lowercases tag names before matching, so \K already routes
2468        // through the \k handler — this test pins that contract.
2469        use crate::parse;
2470        let src = "[Script Info]\n\
2471ScriptType: v4.00+\n\
2472\n\
2473[V4+ Styles]\n\
2474Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
2475Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
2476\n\
2477[Events]\n\
2478Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
2479Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\K50}sweep{\\K30}done\n";
2480        let t = parse(src.as_bytes()).unwrap();
2481        let segs = &t.cues[0].segments;
2482        // Should contain two Karaoke segments (one per \K marker).
2483        let karaoke_count = segs
2484            .iter()
2485            .filter(|s| matches!(s, Segment::Karaoke { .. }))
2486            .count();
2487        assert_eq!(karaoke_count, 2, "got segs = {:?}", segs);
2488    }
2489
2490    // ---------------------------------------------------------------
2491    // \fsp letter-spacing + \q wrap-style coverage (round 88).
2492
2493    #[test]
2494    fn parses_fsp_static() {
2495        let v = parse_block(r"\fsp3");
2496        assert_eq!(v, vec![AnimatedTag::Fsp(3.0)]);
2497        // Negative + decimal both accepted per Aegisub spec.
2498        let v = parse_block(r"\fsp-1.5");
2499        assert_eq!(v, vec![AnimatedTag::Fsp(-1.5)]);
2500    }
2501
2502    #[test]
2503    fn parses_q_in_range() {
2504        for mode in 0..=3 {
2505            let src = format!(r"\q{mode}");
2506            let v = parse_block(&src);
2507            assert_eq!(v, vec![AnimatedTag::Q(mode as u8)]);
2508        }
2509    }
2510
2511    #[test]
2512    fn parses_q_out_of_range_dropped() {
2513        // SSA only defines wrap modes 0..=3; anything else is ignored
2514        // so the renderer keeps using the script header's WrapStyle.
2515        assert!(parse_block(r"\q4").is_empty());
2516        assert!(parse_block(r"\q-1").is_empty());
2517    }
2518
2519    #[test]
2520    fn evaluate_fsp_static_override() {
2521        let cue_anim = CueAnimation {
2522            tags: vec![AnimatedTag::Fsp(2.5)],
2523        };
2524        let st = cue_anim.evaluate_at(0, 1000);
2525        assert_eq!(st.letter_spacing, Some(2.5));
2526        // Default state has no override.
2527        assert!(RenderState::identity().letter_spacing.is_none());
2528    }
2529
2530    #[test]
2531    fn evaluate_q_static_override() {
2532        let cue_anim = CueAnimation {
2533            tags: vec![AnimatedTag::Q(2)],
2534        };
2535        let st = cue_anim.evaluate_at(0, 1000);
2536        assert_eq!(st.wrap_style, Some(2));
2537        assert!(RenderState::identity().wrap_style.is_none());
2538    }
2539
2540    #[test]
2541    fn fsp_animatable_via_t() {
2542        // \t(0,1000,\fsp4) — letter-spacing should ramp 0 → 4 over
2543        // the cue. Without a pre-state \fsp, the source defaults to
2544        // the post-state value (no interpolation source), matching how
2545        // \blur etc. behave today.
2546        let v = parse_block(r"\fsp0\t(0,1000,\fsp4)");
2547        assert_eq!(v.len(), 2);
2548        let cue_anim = CueAnimation { tags: v };
2549        let st0 = cue_anim.evaluate_at(0, 1000);
2550        assert_eq!(st0.letter_spacing, Some(0.0));
2551        let st_mid = cue_anim.evaluate_at(500, 1000);
2552        let mid = st_mid.letter_spacing.expect("set");
2553        assert!(
2554            (mid - 2.0).abs() < 1e-3,
2555            "expected 2.0 at midpoint, got {mid}"
2556        );
2557        let st_end = cue_anim.evaluate_at(1000, 1000);
2558        assert_eq!(st_end.letter_spacing, Some(4.0));
2559    }
2560
2561    #[test]
2562    fn q_static_inside_t_snaps_post() {
2563        // \q is not animatable; if the spec value appears inside \t
2564        // it should snap to the post value once t1 has elapsed.
2565        let v = parse_block(r"\q0\t(500,1000,\q2)");
2566        assert_eq!(v.len(), 2);
2567        let cue_anim = CueAnimation { tags: v };
2568        // Before the transition starts: pre-value.
2569        let st_before = cue_anim.evaluate_at(0, 1000);
2570        assert_eq!(st_before.wrap_style, Some(0));
2571        // Once t > t1 (k > 0): post-value.
2572        let st_mid = cue_anim.evaluate_at(750, 1000);
2573        assert_eq!(st_mid.wrap_style, Some(2));
2574        let st_end = cue_anim.evaluate_at(1000, 1000);
2575        assert_eq!(st_end.wrap_style, Some(2));
2576    }
2577
2578    #[test]
2579    fn extract_fsp_q_from_cue_segment() {
2580        let cue = SubtitleCue {
2581            start_us: 0,
2582            end_us: 1_000_000,
2583            style_ref: None,
2584            positioning: None,
2585            segments: vec![
2586                Segment::Raw(r"{\fsp2\q1}".into()),
2587                Segment::Text("spaced".into()),
2588            ],
2589        };
2590        let anim = extract_cue_animation(&cue);
2591        assert_eq!(anim.tags.len(), 2);
2592        let st = anim.evaluate_at(0, 1000);
2593        assert_eq!(st.letter_spacing, Some(2.0));
2594        assert_eq!(st.wrap_style, Some(1));
2595    }
2596
2597    #[test]
2598    fn parses_an_in_range() {
2599        // Aegisub numpad spec: 1=bl, 2=bc, 3=br, 4=ml, 5=mc, 6=mr,
2600        // 7=tl, 8=tc, 9=tr. All nine should parse to AnimatedTag::An.
2601        for pos in 1..=9 {
2602            let src = format!(r"\an{pos}");
2603            let v = parse_block(&src);
2604            assert_eq!(v, vec![AnimatedTag::An(pos as u8)]);
2605        }
2606    }
2607
2608    #[test]
2609    fn parses_an_out_of_range_dropped() {
2610        // Only 1..=9 are valid numpad positions per the Aegisub spec;
2611        // 0 and 10+ are dropped so the renderer keeps the style's
2612        // Alignment field.
2613        assert!(parse_block(r"\an0").is_empty());
2614        assert!(parse_block(r"\an10").is_empty());
2615        assert!(parse_block(r"\an-1").is_empty());
2616    }
2617
2618    #[test]
2619    fn parses_legacy_a_known_codes() {
2620        // Per the Aegisub spec: low nibble = L/C/R (1/2/3), +4 = top,
2621        // +8 = mid. So the recognised legacy codes are
2622        // {1,2,3,5,6,7,9,10,11}.
2623        let cases: &[(u8, u8)] = &[
2624            (1, 1),
2625            (2, 2),
2626            (3, 3),
2627            (5, 7),
2628            (6, 8),
2629            (7, 9),
2630            (9, 4),
2631            (10, 5),
2632            (11, 6),
2633        ];
2634        for (legacy, numpad) in cases {
2635            let src = format!(r"\a{legacy}");
2636            let v = parse_block(&src);
2637            assert_eq!(
2638                v,
2639                vec![AnimatedTag::A(*legacy)],
2640                "legacy code {} should parse",
2641                legacy
2642            );
2643            // And the apply path must map it to the right numpad
2644            // value on RenderState::alignment.
2645            let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2646            assert_eq!(
2647                st.alignment,
2648                Some(*numpad),
2649                "legacy {} should map to numpad {}",
2650                legacy,
2651                numpad
2652            );
2653        }
2654    }
2655
2656    #[test]
2657    fn parses_legacy_a_unknown_codes_drop_override() {
2658        // Codes 4, 8, 12+ are not documented legacy slots; the parser
2659        // still records the AnimatedTag::A but the evaluator drops the
2660        // alignment override (style alignment wins).
2661        for legacy in [4_u8, 8, 12, 20, 255] {
2662            let src = format!(r"\a{legacy}");
2663            let v = parse_block(&src);
2664            assert_eq!(v, vec![AnimatedTag::A(legacy)]);
2665            let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2666            assert!(
2667                st.alignment.is_none(),
2668                "legacy {} should not override alignment",
2669                legacy
2670            );
2671        }
2672    }
2673
2674    #[test]
2675    fn evaluate_an_static_override() {
2676        let cue_anim = CueAnimation {
2677            tags: vec![AnimatedTag::An(7)],
2678        };
2679        let st = cue_anim.evaluate_at(0, 1000);
2680        assert_eq!(st.alignment, Some(7));
2681        // Default identity has no override.
2682        assert!(RenderState::identity().alignment.is_none());
2683        // Static — does not vary across the cue.
2684        let st_mid = cue_anim.evaluate_at(500, 1000);
2685        let st_end = cue_anim.evaluate_at(1000, 1000);
2686        assert_eq!(st_mid.alignment, Some(7));
2687        assert_eq!(st_end.alignment, Some(7));
2688    }
2689
2690    #[test]
2691    fn an_static_inside_t_snaps_post() {
2692        // \an is not animatable per spec (Aegisub: "Specify the
2693        // alignment of the line"); inside \t it should snap to the
2694        // post-value once t1 has elapsed, mirroring \q.
2695        let v = parse_block(r"\an2\t(500,1000,\an8)");
2696        assert_eq!(v.len(), 2);
2697        let cue_anim = CueAnimation { tags: v };
2698        // Pre-transition: pre-value (numpad 2 = bottom-center).
2699        let st_before = cue_anim.evaluate_at(0, 1000);
2700        assert_eq!(st_before.alignment, Some(2));
2701        // Once t > t1: post-value (numpad 8 = top-center).
2702        let st_mid = cue_anim.evaluate_at(750, 1000);
2703        assert_eq!(st_mid.alignment, Some(8));
2704        let st_end = cue_anim.evaluate_at(1000, 1000);
2705        assert_eq!(st_end.alignment, Some(8));
2706    }
2707
2708    #[test]
2709    fn an_later_overrides_earlier_legacy_a() {
2710        // Last-writer-wins, matching the static-override model.
2711        let v = parse_block(r"\a6\an1");
2712        assert_eq!(v.len(), 2);
2713        let st = CueAnimation { tags: v }.evaluate_at(0, 1000);
2714        assert_eq!(st.alignment, Some(1));
2715    }
2716
2717    #[test]
2718    fn extract_an_from_cue_segment() {
2719        let cue = SubtitleCue {
2720            start_us: 0,
2721            end_us: 1_000_000,
2722            style_ref: None,
2723            positioning: None,
2724            segments: vec![
2725                Segment::Raw(r"{\an5}".into()),
2726                Segment::Text("centered".into()),
2727            ],
2728        };
2729        let anim = extract_cue_animation(&cue);
2730        assert_eq!(anim.tags, vec![AnimatedTag::An(5)]);
2731        let st = anim.evaluate_at(0, 1000);
2732        assert_eq!(st.alignment, Some(5));
2733    }
2734
2735    // ---------------------------------------------------------------
2736    // \k karaoke-timing family coverage (round 115).
2737
2738    #[test]
2739    fn parses_k_family_kinds() {
2740        // Lowercase \k = instant Fill; \kf = Sweep; \ko = Outline.
2741        assert_eq!(
2742            parse_block(r"\k50"),
2743            vec![AnimatedTag::Karaoke {
2744                kind: KaraokeKind::Fill,
2745                cs: 50,
2746            }]
2747        );
2748        assert_eq!(
2749            parse_block(r"\kf30"),
2750            vec![AnimatedTag::Karaoke {
2751                kind: KaraokeKind::Sweep,
2752                cs: 30,
2753            }]
2754        );
2755        assert_eq!(
2756            parse_block(r"\ko20"),
2757            vec![AnimatedTag::Karaoke {
2758                kind: KaraokeKind::Outline,
2759                cs: 20,
2760            }]
2761        );
2762    }
2763
2764    #[test]
2765    fn capital_k_is_sweep_identical_to_kf() {
2766        // Aegisub: "\K and \kf are identical". The uppercase form must
2767        // resolve to Sweep, not the lowercase \k Fill.
2768        let cap = parse_block(r"\K40");
2769        let kf = parse_block(r"\kf40");
2770        assert_eq!(
2771            cap,
2772            vec![AnimatedTag::Karaoke {
2773                kind: KaraokeKind::Sweep,
2774                cs: 40,
2775            }]
2776        );
2777        assert_eq!(cap, kf);
2778    }
2779
2780    #[test]
2781    fn k_negative_duration_clamps_to_zero() {
2782        assert_eq!(
2783            parse_block(r"\k-10"),
2784            vec![AnimatedTag::Karaoke {
2785                kind: KaraokeKind::Fill,
2786                cs: 0,
2787            }]
2788        );
2789    }
2790
2791    #[test]
2792    fn kt_is_not_handled() {
2793        // Aegisub explicitly leaves \kt undocumented/unsupported; we
2794        // skip it (the round-trip text path keeps it verbatim via Raw).
2795        assert!(parse_block(r"\kt100").is_empty());
2796    }
2797
2798    #[test]
2799    fn karaoke_spans_are_cumulative() {
2800        // Two syllables of 50cs then 30cs → [0,500), [500,800) ms.
2801        let v = parse_block(r"\k50\kf30");
2802        let anim = CueAnimation { tags: v };
2803        let spans = anim.karaoke_spans();
2804        assert_eq!(
2805            spans,
2806            vec![
2807                KaraokeSpan {
2808                    kind: KaraokeKind::Fill,
2809                    start_ms: 0,
2810                    end_ms: 500,
2811                },
2812                KaraokeSpan {
2813                    kind: KaraokeKind::Sweep,
2814                    start_ms: 500,
2815                    end_ms: 800,
2816                },
2817            ]
2818        );
2819    }
2820
2821    #[test]
2822    fn karaoke_span_progress() {
2823        let span = KaraokeSpan {
2824            kind: KaraokeKind::Sweep,
2825            start_ms: 500,
2826            end_ms: 800,
2827        };
2828        assert_eq!(span.progress(400), 0.0); // before
2829        assert_eq!(span.progress(500), 0.0); // at start
2830        assert!((span.progress(650) - 0.5).abs() < 1e-6); // halfway
2831        assert_eq!(span.progress(800), 1.0); // at end
2832        assert_eq!(span.progress(900), 1.0); // after
2833    }
2834
2835    #[test]
2836    fn karaoke_zero_length_span_progress_is_one_past_start() {
2837        let span = KaraokeSpan {
2838            kind: KaraokeKind::Fill,
2839            start_ms: 100,
2840            end_ms: 100,
2841        };
2842        assert_eq!(span.progress(50), 0.0);
2843        assert_eq!(span.progress(150), 1.0);
2844    }
2845
2846    #[test]
2847    fn karaoke_is_noop_on_render_state() {
2848        // \k carries timeline info, not per-frame transform/colour
2849        // state; evaluate_at must leave RenderState at identity.
2850        let v = parse_block(r"\k50\kf30");
2851        let st = CueAnimation { tags: v }.evaluate_at(250, 1000);
2852        assert_eq!(st, RenderState::identity());
2853    }
2854
2855    #[test]
2856    fn extract_karaoke_from_cue_segments() {
2857        // Through the full parse → extract path the base parser emits
2858        // Segment::Karaoke markers; karaoke_spans must still resolve
2859        // their cumulative timing (kind defaults to Fill since the
2860        // marker drops the family member).
2861        use crate::parse;
2862        let src = "[Script Info]\n\
2863ScriptType: v4.00+\n\
2864\n\
2865[V4+ Styles]\n\
2866Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n\
2867Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n\
2868\n\
2869[Events]\n\
2870Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
2871Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\\k50}la{\\kf30}la\n";
2872        let t = parse(src.as_bytes()).unwrap();
2873        let anim = extract_cue_animation(&t.cues[0]);
2874        let spans = anim.karaoke_spans();
2875        assert_eq!(spans.len(), 2);
2876        assert_eq!(spans[0].start_ms, 0);
2877        assert_eq!(spans[0].end_ms, 500);
2878        assert_eq!(spans[1].start_ms, 500);
2879        assert_eq!(spans[1].end_ms, 800);
2880    }
2881}