Skip to main content

rustmotion_core/schema/
video.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::Path as SkiaPath;
4
5use super::animation::{
6    Animation, AnimationPreset, ChromaticAberrationConfig, EasingType, PresetConfig, SpringConfig,
7};
8use super::style::{FontWeight, TextAlign, VerticalAlign};
9
10/// A single animation effect. Discriminated by `"type"` in JSON.
11/// Each preset name is a valid type, plus special types: glow, wiggle, keyframes, motion_blur.
12///
13/// Examples:
14/// ```json
15/// { "name": "fade_in_up", "delay": 0.3, "duration": 0.6 }
16/// { "name": "glow", "color": "#F68F2B", "radius": 16, "intensity": 1.2 }
17/// { "name": "wiggle", "property": "translate_y", "amplitude": 5, "frequency": 2 }
18/// ```
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
20#[serde(tag = "name", rename_all = "snake_case")]
21pub enum AnimationEffect {
22    FadeIn(AnimationTiming),
23    FadeInUp(AnimationTiming),
24    FadeInDown(AnimationTiming),
25    FadeInLeft(AnimationTiming),
26    FadeInRight(AnimationTiming),
27    SlideInLeft(AnimationTiming),
28    SlideInRight(AnimationTiming),
29    SlideInUp(AnimationTiming),
30    SlideInDown(AnimationTiming),
31    ScaleIn(AnimationTiming),
32    BounceIn(AnimationTiming),
33    BlurIn(AnimationTiming),
34    RotateIn(AnimationTiming),
35    ElasticIn(AnimationTiming),
36    /// Scale up from nothing with a back-out overshoot, then a short elastic
37    /// pulse before settling — the notification-badge arrival, where the
38    /// second beat is what draws the eye after the first has placed the
39    /// element. `AnimationTiming.overshoot` sets the pulse amplitude
40    /// (default 0.18 = 118%); 0 reduces it to a plain back-out scale-in.
41    PopIn(AnimationTiming),
42    FadeOut(AnimationTiming),
43    FadeOutUp(AnimationTiming),
44    FadeOutDown(AnimationTiming),
45    SlideOutLeft(AnimationTiming),
46    SlideOutRight(AnimationTiming),
47    SlideOutUp(AnimationTiming),
48    SlideOutDown(AnimationTiming),
49    ScaleOut(AnimationTiming),
50    BounceOut(AnimationTiming),
51    BlurOut(AnimationTiming),
52    RotateOut(AnimationTiming),
53    Pulse(AnimationTiming),
54    Float(AnimationTiming),
55    Shake(AnimationTiming),
56    Spin(AnimationTiming),
57    FlipInX(AnimationTiming),
58    FlipInY(AnimationTiming),
59    FlipOutX(AnimationTiming),
60    FlipOutY(AnimationTiming),
61    TiltIn(TiltInConfig),
62    DrawIn(AnimationTiming),
63    StrokeReveal(AnimationTiming),
64    Typewriter(AnimationTiming),
65    WipeLeft(AnimationTiming),
66    WipeRight(AnimationTiming),
67    #[serde(alias = "float_3d")]
68    Float3d(AnimationTiming),
69    CharScaleIn(CharAnimationTiming),
70    CharFadeIn(CharAnimationTiming),
71    CharWave(CharAnimationTiming),
72    CharBounce(CharAnimationTiming),
73    CharRotateIn(CharAnimationTiming),
74    CharSlideUp(CharAnimationTiming),
75    /// Each word (or char) arrives blurred and settles sharp, combined with
76    /// a slight upward translate and an opacity ramp — one continuous
77    /// per-unit animation driven by the same progress value, not three
78    /// independently-timed effects. `CharAnimationTiming.blur` sets the
79    /// starting blur sigma in px (default
80    /// `engine::animator::DEFAULT_CHAR_BLUR_SIGMA`, tuned for 100px+ display
81    /// type).
82    ///
83    /// Resolved through `engine::animator::extract_effects` →
84    /// `ResolvedCharAnimation` like its five siblings, so it picks up
85    /// container-level stagger shifting and `timeline`-embedded copies. (It
86    /// used to be read directly off `style.animation` inside
87    /// `rustmotion_components::text::Text::paint` and therefore missed both.)
88    CharBlurIn(CharAnimationTiming),
89    Glow(GlowConfig),
90    /// A band of light sweeping across the element's own painted pixels.
91    /// See [`ShimmerConfig`].
92    Shimmer(ShimmerConfig),
93    Wiggle(WiggleConfig),
94    Orbit(OrbitConfig),
95    Keyframes(KeyframesConfig),
96    MotionBlur(MotionBlurConfig),
97    /// Temporal trail effect: paints copies of the component at prior times
98    /// with decaying opacity, creating a persistence-of-vision ghost trail.
99    Trail(TrailConfig),
100    /// Move the component along an SVG path, and — when `orient` is set —
101    /// rotate it to face the path's tangent direction. See
102    /// [`MotionPathConfig`]'s doc comment for the path syntax, the
103    /// coordinate space path points are interpreted in, and how degenerate
104    /// paths (empty, single-point, zero-length) are handled.
105    MotionPath(MotionPathConfig),
106    /// Per-element channel-split: the node's own rendered content splits into
107    /// red/cyan fringes that converge back to zero separation by the end.
108    /// See [`ChromaticAberrationConfig`]'s doc comment.
109    ChromaticAberration(ChromaticAberrationConfig),
110    /// Cuts the node's own rendered pixels into a deterministic Voronoi
111    /// partition and flies the pieces apart (or together). See
112    /// [`ShatterConfig`]'s doc comment.
113    Shatter(ShatterConfig),
114    /// Throws a ring of short strokes outward from just off the node's own
115    /// box edge, then retracts them. See [`BurstConfig`]'s doc comment.
116    Burst(BurstConfig),
117}
118
119impl AnimationEffect {
120    pub fn shift_delay(&mut self, by: f64) {
121        use AnimationEffect::*;
122        match self {
123            FadeIn(t) | FadeInUp(t) | FadeInDown(t) | FadeInLeft(t) | FadeInRight(t)
124            | SlideInLeft(t) | SlideInRight(t) | SlideInUp(t) | SlideInDown(t) | ScaleIn(t)
125            | BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | PopIn(t) | FadeOut(t)
126            | FadeOutUp(t) | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t)
127            | SlideOutUp(t) | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t)
128            | RotateOut(t) | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t)
129            | FlipOutX(t) | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t)
130            | WipeLeft(t) | WipeRight(t) | Float3d(t) => t.delay += by,
131            TiltIn(c) => c.delay += by,
132            CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c)
133            | CharSlideUp(c) | CharBlurIn(c) => c.delay += by,
134            Keyframes(c) => c.delay += by,
135            MotionPath(c) => c.delay += by,
136            Shimmer(c) => c.delay += by,
137            ChromaticAberration(c) => c.delay += by,
138            Shatter(c) => c.delay += by,
139            Burst(c) => c.delay += by,
140            Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {}
141        }
142    }
143
144    pub fn as_preset(&self) -> Option<(AnimationPreset, &AnimationTiming)> {
145        match self {
146            Self::FadeIn(t) => Some((AnimationPreset::FadeIn, t)),
147            Self::FadeInUp(t) => Some((AnimationPreset::FadeInUp, t)),
148            Self::FadeInDown(t) => Some((AnimationPreset::FadeInDown, t)),
149            Self::FadeInLeft(t) => Some((AnimationPreset::FadeInLeft, t)),
150            Self::FadeInRight(t) => Some((AnimationPreset::FadeInRight, t)),
151            Self::SlideInLeft(t) => Some((AnimationPreset::SlideInLeft, t)),
152            Self::SlideInRight(t) => Some((AnimationPreset::SlideInRight, t)),
153            Self::SlideInUp(t) => Some((AnimationPreset::SlideInUp, t)),
154            Self::SlideInDown(t) => Some((AnimationPreset::SlideInDown, t)),
155            Self::ScaleIn(t) => Some((AnimationPreset::ScaleIn, t)),
156            Self::BounceIn(t) => Some((AnimationPreset::BounceIn, t)),
157            Self::BlurIn(t) => Some((AnimationPreset::BlurIn, t)),
158            Self::RotateIn(t) => Some((AnimationPreset::RotateIn, t)),
159            Self::ElasticIn(t) => Some((AnimationPreset::ElasticIn, t)),
160            Self::PopIn(t) => Some((AnimationPreset::PopIn, t)),
161            Self::FadeOut(t) => Some((AnimationPreset::FadeOut, t)),
162            Self::FadeOutUp(t) => Some((AnimationPreset::FadeOutUp, t)),
163            Self::FadeOutDown(t) => Some((AnimationPreset::FadeOutDown, t)),
164            Self::SlideOutLeft(t) => Some((AnimationPreset::SlideOutLeft, t)),
165            Self::SlideOutRight(t) => Some((AnimationPreset::SlideOutRight, t)),
166            Self::SlideOutUp(t) => Some((AnimationPreset::SlideOutUp, t)),
167            Self::SlideOutDown(t) => Some((AnimationPreset::SlideOutDown, t)),
168            Self::ScaleOut(t) => Some((AnimationPreset::ScaleOut, t)),
169            Self::BounceOut(t) => Some((AnimationPreset::BounceOut, t)),
170            Self::BlurOut(t) => Some((AnimationPreset::BlurOut, t)),
171            Self::RotateOut(t) => Some((AnimationPreset::RotateOut, t)),
172            Self::Pulse(t) => Some((AnimationPreset::Pulse, t)),
173            Self::Float(t) => Some((AnimationPreset::Float, t)),
174            Self::Shake(t) => Some((AnimationPreset::Shake, t)),
175            Self::Spin(t) => Some((AnimationPreset::Spin, t)),
176            Self::FlipInX(t) => Some((AnimationPreset::FlipInX, t)),
177            Self::FlipInY(t) => Some((AnimationPreset::FlipInY, t)),
178            Self::FlipOutX(t) => Some((AnimationPreset::FlipOutX, t)),
179            Self::FlipOutY(t) => Some((AnimationPreset::FlipOutY, t)),
180            Self::TiltIn(_) => None,
181            Self::DrawIn(t) => Some((AnimationPreset::DrawIn, t)),
182            Self::StrokeReveal(t) => Some((AnimationPreset::StrokeReveal, t)),
183            Self::Float3d(t) => Some((AnimationPreset::Float3d, t)),
184            Self::Typewriter(t) => Some((AnimationPreset::Typewriter, t)),
185            Self::WipeLeft(t) => Some((AnimationPreset::WipeLeft, t)),
186            Self::WipeRight(t) => Some((AnimationPreset::WipeRight, t)),
187            _ => None,
188        }
189    }
190}
191
192/// Timing configuration for preset animations.
193#[derive(Debug, Clone, JsonSchema, PartialEq)]
194pub struct AnimationTiming {
195    /// Delay before animation starts (seconds).
196    #[serde(default)]
197    pub delay: f64,
198    /// Animation duration (seconds).
199    #[serde(default = "default_animation_duration")]
200    pub duration: f64,
201    /// Loop the animation continuously. Kept as a plain bool for source
202    /// compatibility with every reader that only ever checked this flag
203    /// (several live outside this workstream's owned files) — `true`
204    /// covers both "loop forever" (`repeat_count: None`) and "loop a known
205    /// number of times" (`repeat_count: Some(n)`), so a finite count is
206    /// never mistaken for a one-shot animation by code that only reads
207    /// this field. The JSON `"loop"` key this deserializes from accepts
208    /// either shape (see [`AnimationTimingWire`]); `repeat` alone can't
209    /// tell you which one was written — check `repeat_count` for that.
210    #[serde(default, rename = "loop")]
211    #[schemars(with = "RepeatSpec")]
212    pub repeat: bool,
213    /// How many times the animation plays, when the JSON `"loop"` value
214    /// was a positive integer rather than a bare bool (issue #330) — e.g.
215    /// `"loop": 12` for GSAP's `repeat: 11` (11 *re*plays, 12 plays
216    /// total — this field counts total plays, not replays). `None`
217    /// defers entirely to `repeat`: `true` loops forever, `false` plays
218    /// once — today's behaviour, unchanged. `0` and `1` both fold back
219    /// into `repeat: false, repeat_count: None` at parse time (see
220    /// [`RepeatSpec::into_parts`]): there is no visible difference
221    /// between "play once" and "loop zero times", so there's no reason to
222    /// carry a count that never changes anything downstream.
223    #[serde(default, skip)]
224    #[schemars(skip)]
225    pub repeat_count: Option<u32>,
226    /// Reverse direction on every other play (ping-pong) instead of
227    /// snapping back to the start each cycle — GSAP calls this `yoyo`.
228    /// Only meaningful when the animation actually repeats (`repeat` or
229    /// `repeat_count`); a no-op otherwise. Works on any animation this
230    /// timing drives, not a fixed set of presets — see
231    /// `engine::animator::cycle_time`.
232    #[serde(default)]
233    pub yoyo: bool,
234    /// Pause between plays, in seconds, held at the resting value of the
235    /// play that just finished before the next one starts. `0.0` (default)
236    /// is a seamless loop.
237    #[serde(default)]
238    pub repeat_delay: f64,
239    /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%).
240    #[serde(default)]
241    pub overshoot: Option<f64>,
242    /// Spring physics for the preset's motion keyframes (translate/scale/
243    /// rotate — opacity keeps its ease to avoid alpha overshoot flashes).
244    /// `bounce_in` / `elastic_in` use their built-in springs as defaults;
245    /// this overrides them.
246    #[serde(default)]
247    pub spring: Option<SpringConfig>,
248    /// Travel of an oscillating preset, in pixels (`float_3d` only; default
249    /// 12). Threaded through `to_preset_config` into `PresetConfig::amplitude`,
250    /// which `expand_preset_inner` already reads — this field is what makes
251    /// an author-supplied amplitude actually reach it instead of the
252    /// hardcoded default on every element.
253    #[serde(default)]
254    pub amplitude: Option<f64>,
255}
256
257fn default_animation_duration() -> f64 {
258    0.8
259}
260
261/// `true` loops forever, `false` plays once, and a positive integer is a
262/// total number of plays (`12` for GSAP's `repeat: 11`). `0` and `1` both
263/// mean "play once".
264#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
265#[serde(untagged)]
266enum RepeatSpec {
267    Loop(bool),
268    Count(u32),
269}
270
271impl Default for RepeatSpec {
272    fn default() -> Self {
273        RepeatSpec::Loop(false)
274    }
275}
276
277impl RepeatSpec {
278    fn into_parts(self) -> (bool, Option<u32>) {
279        match self {
280            RepeatSpec::Loop(b) => (b, None),
281            RepeatSpec::Count(0) | RepeatSpec::Count(1) => (false, None),
282            RepeatSpec::Count(n) => (true, Some(n)),
283        }
284    }
285
286    fn from_parts(repeat: bool, repeat_count: Option<u32>) -> Self {
287        match repeat_count {
288            Some(n) => RepeatSpec::Count(n),
289            None => RepeatSpec::Loop(repeat),
290        }
291    }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295#[serde(deny_unknown_fields)]
296struct AnimationTimingWire {
297    #[serde(default)]
298    delay: f64,
299    #[serde(default = "default_animation_duration")]
300    duration: f64,
301    #[serde(default, rename = "loop")]
302    repeat: RepeatSpec,
303    #[serde(default)]
304    yoyo: bool,
305    #[serde(default)]
306    repeat_delay: f64,
307    #[serde(default)]
308    overshoot: Option<f64>,
309    #[serde(default)]
310    spring: Option<SpringConfig>,
311    #[serde(default)]
312    amplitude: Option<f64>,
313}
314
315impl From<AnimationTimingWire> for AnimationTiming {
316    fn from(wire: AnimationTimingWire) -> Self {
317        let (repeat, repeat_count) = wire.repeat.into_parts();
318        AnimationTiming {
319            delay: wire.delay,
320            duration: wire.duration,
321            repeat,
322            repeat_count,
323            yoyo: wire.yoyo,
324            repeat_delay: wire.repeat_delay,
325            overshoot: wire.overshoot,
326            spring: wire.spring,
327            amplitude: wire.amplitude,
328        }
329    }
330}
331
332impl From<&AnimationTiming> for AnimationTimingWire {
333    fn from(t: &AnimationTiming) -> Self {
334        AnimationTimingWire {
335            delay: t.delay,
336            duration: t.duration,
337            repeat: RepeatSpec::from_parts(t.repeat, t.repeat_count),
338            yoyo: t.yoyo,
339            repeat_delay: t.repeat_delay,
340            overshoot: t.overshoot,
341            spring: t.spring.clone(),
342            amplitude: t.amplitude,
343        }
344    }
345}
346
347impl<'de> Deserialize<'de> for AnimationTiming {
348    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
349    where
350        D: serde::Deserializer<'de>,
351    {
352        AnimationTimingWire::deserialize(deserializer).map(AnimationTiming::from)
353    }
354}
355
356impl Serialize for AnimationTiming {
357    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
358    where
359        S: serde::Serializer,
360    {
361        AnimationTimingWire::from(self).serialize(serializer)
362    }
363}
364
365/// Configuration for the `tilt_in` animation with configurable 3D transform values.
366#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
367#[serde(deny_unknown_fields)]
368pub struct TiltInConfig {
369    #[serde(default)]
370    pub delay: f64,
371    #[serde(default = "default_animation_duration")]
372    pub duration: f64,
373    #[serde(default, rename = "loop")]
374    pub repeat: bool,
375    /// Initial rotate_x angle in degrees (default: 15.0).
376    #[serde(default)]
377    pub rotate_x: Option<f64>,
378    /// Initial rotate_y angle in degrees (default: -15.0).
379    #[serde(default)]
380    pub rotate_y: Option<f64>,
381    /// Perspective depth in px (default: 1000.0).
382    #[serde(default)]
383    pub perspective: Option<f64>,
384    /// Initial scale value (default: 0.9).
385    #[serde(default)]
386    pub scale_from: Option<f64>,
387}
388
389impl Default for AnimationTiming {
390    fn default() -> Self {
391        Self {
392            delay: 0.0,
393            duration: 0.8,
394            repeat: false,
395            repeat_count: None,
396            yoyo: false,
397            repeat_delay: 0.0,
398            overshoot: None,
399            spring: None,
400            amplitude: None,
401        }
402    }
403}
404
405/// Timing configuration for char animation effect variants (used inside style.animation).
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
407#[serde(deny_unknown_fields)]
408pub struct CharAnimationTiming {
409    /// Delay before animation starts (seconds).
410    #[serde(default)]
411    pub delay: f64,
412    /// Duration of each unit's animation in seconds.
413    #[serde(default = "default_char_duration_f64")]
414    pub duration: f64,
415    /// Delay between each unit (char or word) in seconds.
416    #[serde(default = "default_char_stagger_f64")]
417    pub stagger: f64,
418    /// Granularity: animate per character or per word.
419    #[serde(default)]
420    pub granularity: TextAnimGranularity,
421    /// Easing function for each unit's animation.
422    #[serde(default)]
423    pub easing: EasingType,
424    /// Overshoot intensity for char_scale_in/char_bounce (0.0 = none, default 0.08 = 8%).
425    #[serde(default)]
426    pub overshoot: Option<f64>,
427    /// Starting blur sigma in px for char_blur_in — the word (or char)
428    /// begins blurred by this amount and settles to sharp (sigma 0) by the
429    /// end of its unit animation. Unused by the other five char presets.
430    /// Defaults to 14px sigma, chosen to read clearly at 100px+ display
431    /// type without collapsing into a featureless blob (see render proof
432    /// in issue #118).
433    #[serde(default)]
434    pub blur: Option<f64>,
435    /// Which way each unit travels in from. Only `char_slide_up` and
436    /// `char_blur_in` have a travel axis to redirect; the other presets
437    /// ignore it. Default `up` — the historical behaviour.
438    #[serde(default)]
439    pub direction: TextAnimDirection,
440    /// Multiplier on how far each unit travels, `1.0` being each preset's
441    /// own tuned distance (0.8em for `slide_up`, 0.12em for `blur_in`).
442    /// Use ~0.5 for a tighter arrival, ~1.85 for a pronounced staircase.
443    #[serde(default)]
444    pub distance: Option<f64>,
445    /// Scale each unit starts at, growing to 1.0 over its animation —
446    /// combined with, not instead of, the preset's own motion. 0.82 gives
447    /// the punchy "number pops in" arrival, 0.92 a barely-perceptible
448    /// settle. Unset means no scaling (the historical behaviour); the
449    /// scale-based presets (`char_scale_in`, `char_bounce`) own their scale
450    /// curve outright and ignore this.
451    #[serde(default)]
452    pub scale_from: Option<f64>,
453    /// Randomises each unit's start time by up to ±`jitter × stagger`, so
454    /// the units arrive in uneven bursts instead of a metronomic march.
455    /// This is what separates a streaming-token look from a typewriter:
456    /// language models don't emit words on a clock. 0 (default) keeps the
457    /// exact even spacing.
458    ///
459    /// The offsets are derived from `seed` and the unit's index, never from
460    /// a live RNG — a frame must render identically no matter which
461    /// process, thread or `--frames` segment computes it.
462    #[serde(default)]
463    pub jitter: Option<f64>,
464    /// Seed for `jitter`. Changing it reshuffles the arrival rhythm without
465    /// changing its statistics.
466    #[serde(default)]
467    pub seed: Option<u32>,
468    /// Colour each unit starts at before settling to the text's own colour
469    /// over its animation. A dim grey here reproduces the way freshly
470    /// streamed tokens read as unsettled before the eye accepts them.
471    #[serde(default)]
472    pub ink_from: Option<String>,
473    /// Tilt (degrees) each unit starts at, straightening to 0 by the end of
474    /// its own animation — the "staircase word reveal" where every letter
475    /// settles from a lean. Composes with the preset instead of replacing
476    /// it, the same rule `scale_from` follows; `char_rotate_in` already
477    /// owns a rotation curve and ignores this. Unset means no tilt (the
478    /// historical behaviour).
479    #[serde(default)]
480    pub rotate_from: Option<f64>,
481    /// Pivot `rotate_from` turns each unit around. Only meaningful
482    /// alongside `rotate_from`.
483    #[serde(default)]
484    pub rotate_origin: RotateOrigin,
485    /// Deterministic per-unit size noise: each unit is additionally born at
486    /// `1.0 ± scale_jitter` and converges to its own normal scale by the
487    /// end of its animation, on top of whatever the preset already does —
488    /// "letters born at different sizes, then aligning". Derived from
489    /// `seed` and the unit's index, like `jitter`; unset means no size
490    /// noise (the historical behaviour).
491    #[serde(default)]
492    pub scale_jitter: Option<f64>,
493    /// Deterministic per-unit baseline noise, as a fraction of `font-size`:
494    /// each unit is additionally born offset by up to
495    /// `±baseline_jitter × font-size` from its laid-out baseline and settles
496    /// onto it by the end of its animation. Derived from `seed` and the
497    /// unit's index, like `jitter`; unset means no baseline noise (the
498    /// historical behaviour).
499    #[serde(default)]
500    pub baseline_jitter: Option<f64>,
501    /// When true, a unit that hasn't started its own animation yet
502    /// contributes no width to its line, and one that's mid-animation
503    /// contributes a share eased in by `reflow_easing` — so the line grows
504    /// as units arrive instead of reserving its final width from the first
505    /// frame. Combined with `text-align: center`, this is what keeps a
506    /// centred line centred while it's being written instead of growing
507    /// from a fixed left edge. Default `false` keeps the historical
508    /// behaviour: every unit's slot is reserved from frame one, only its
509    /// paint (opacity/position/scale/blur) animates.
510    #[serde(default)]
511    pub reflow: bool,
512    /// Easing curve for the width a unit contributes to its line while
513    /// `reflow` is on — independent from `easing`, which times the unit's
514    /// own visual entrance. Only meaningful alongside `reflow`.
515    #[serde(default)]
516    pub reflow_easing: EasingType,
517}
518
519impl Default for CharAnimationTiming {
520    fn default() -> Self {
521        Self {
522            delay: 0.0,
523            duration: default_char_duration_f64(),
524            stagger: default_char_stagger_f64(),
525            granularity: TextAnimGranularity::default(),
526            easing: EasingType::default(),
527            overshoot: None,
528            blur: None,
529            direction: TextAnimDirection::default(),
530            distance: None,
531            scale_from: None,
532            jitter: None,
533            seed: None,
534            ink_from: None,
535            rotate_from: None,
536            rotate_origin: RotateOrigin::default(),
537            scale_jitter: None,
538            baseline_jitter: None,
539            reflow: false,
540            reflow_easing: EasingType::default(),
541        }
542    }
543}
544
545fn default_char_stagger_f64() -> f64 {
546    0.03
547}
548
549fn default_char_duration_f64() -> f64 {
550    0.4
551}
552
553/// Per-character or per-word text animation configuration (legacy root-level prop).
554#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
555pub struct CharAnimation {
556    /// Animation preset: "scale_in", "fade_in", "wave", "bounce", "rotate_in", "slide_up", "blur_in".
557    #[serde(default = "default_char_preset")]
558    pub preset: CharAnimPreset,
559    /// Granularity: animate per character or per word.
560    #[serde(default)]
561    pub granularity: TextAnimGranularity,
562    /// Delay between each unit (char or word) in seconds.
563    #[serde(default = "default_char_stagger")]
564    pub stagger: f32,
565    /// Duration of each unit's animation in seconds.
566    #[serde(default = "default_char_duration")]
567    pub duration: f32,
568    /// Easing function.
569    #[serde(default)]
570    pub easing: EasingType,
571    /// Initial delay before the first unit starts.
572    #[serde(default)]
573    pub delay: f32,
574}
575
576/// Granularity for text animation: per character or per word.
577#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
578#[serde(rename_all = "snake_case")]
579#[derive(PartialEq)]
580pub enum TextAnimGranularity {
581    /// Animate each character individually (default).
582    #[default]
583    Char,
584    /// Animate each word as a unit.
585    Word,
586}
587
588/// Which way a per-unit entrance travels from before it settles.
589///
590/// The unit starts offset in the *opposite* direction of travel and moves
591/// towards its laid-out position: `Up` (the default, and the historical
592/// behaviour) starts below the baseline and rises; `Down` starts above and
593/// falls, which is the "letters cascading from the top" look.
594///
595/// Read by the `slide_up` and `blur_in` char presets — the ones whose motion
596/// is a translate. `scale_in`, `bounce`, `rotate_in`, `fade_in` and `wave`
597/// have no travel axis to redirect and ignore it.
598#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
599#[serde(rename_all = "snake_case")]
600pub enum TextAnimDirection {
601    /// Starts below its line and rises into place (default).
602    #[default]
603    Up,
604    /// Starts above its line and falls into place.
605    Down,
606    /// Starts to the right and slides left into place.
607    Left,
608    /// Starts to the left and slides right into place.
609    Right,
610}
611
612impl TextAnimDirection {
613    pub fn offset(self, travel: f32) -> (f32, f32) {
614        match self {
615            Self::Up => (0.0, travel),
616            Self::Down => (0.0, -travel),
617            Self::Left => (travel, 0.0),
618            Self::Right => (-travel, 0.0),
619        }
620    }
621}
622
623/// Pivot a `rotate_from` tilt turns around. Only meaningful alongside
624/// `rotate_from`; ignored otherwise.
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
626#[serde(rename_all = "snake_case")]
627pub enum RotateOrigin {
628    /// The unit's own visual centre — the same pivot `char_scale_in`,
629    /// `char_bounce` and `char_rotate_in` already turn around (default).
630    #[default]
631    Center,
632    /// The unit's leading edge, at the text baseline — where the previous
633    /// unit ends and this one begins. Turning around this point instead of
634    /// the centre reads as each unit hinging open from where it's typed,
635    /// the "staircase word reveal" look.
636    Edge,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
640#[serde(rename_all = "snake_case")]
641#[derive(PartialEq)]
642pub enum CharAnimPreset {
643    /// Each character scales from 0 to 1.
644    #[default]
645    ScaleIn,
646    /// Each character fades from 0 to 1 opacity.
647    FadeIn,
648    /// Characters oscillate vertically in a wave pattern.
649    Wave,
650    /// Each character bounces in (scale overshoot).
651    Bounce,
652    /// Each character rotates in from a random angle.
653    RotateIn,
654    /// Each character slides up from below.
655    SlideUp,
656    /// Each character/word arrives blurred and settles sharp, with a
657    /// slight upward translate and opacity ramp driven by the same
658    /// progress value (see `AnimationEffect::CharBlurIn`).
659    BlurIn,
660}
661
662fn default_char_preset() -> CharAnimPreset {
663    CharAnimPreset::ScaleIn
664}
665
666fn default_char_stagger() -> f32 {
667    0.03
668}
669
670fn default_char_duration() -> f32 {
671    0.4
672}
673
674impl AnimationTiming {
675    pub fn to_preset_config(&self) -> PresetConfig {
676        PresetConfig {
677            amplitude: self.amplitude,
678            delay: self.delay,
679            duration: self.duration,
680            repeat: self.repeat,
681            repeat_count: self.repeat_count,
682            yoyo: self.yoyo,
683            repeat_delay: self.repeat_delay,
684            overshoot: self.overshoot,
685            spring: self.spring.clone(),
686        }
687    }
688}
689
690const KNOWN_MOTION_PROPERTIES: &[&str] = &[
691    "opacity",
692    "position.x",
693    "translate_x",
694    "position.y",
695    "translate_y",
696    "scale",
697    "scale.x",
698    "scale.y",
699    "rotation",
700    "rotate_x",
701    "rotate_y",
702    "blur",
703    "blur_x",
704    "blur_y",
705    "visible_chars",
706    "visible_chars_progress",
707    "border_radius",
708    "font_size",
709    "width",
710    "height",
711    "gap",
712    "padding",
713    "stroke_width",
714    "shadow_blur",
715    "glow_radius",
716    "glow_intensity",
717    "perspective",
718    "draw_progress",
719    "motion_progress",
720    "color",
721    "clip_path_progress",
722    "letter_spacing",
723    "draw_start",
724    "draw_offset",
725];
726
727fn validate_motion_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
728    if KNOWN_MOTION_PROPERTIES.contains(&value) {
729        return Ok(());
730    }
731    let normalize = |s: &str| s.replace(['-', ' '], "_").to_lowercase();
732    let normalized = normalize(value);
733    if let Some(suggestion) = KNOWN_MOTION_PROPERTIES
734        .iter()
735        .find(|known| normalize(known) == normalized)
736    {
737        Err(E::custom(format!(
738            "unknown animation property '{value}' — did you mean '{suggestion}'?"
739        )))
740    } else {
741        Err(E::custom(format!(
742            "unknown animation property '{value}': expected one of {}",
743            KNOWN_MOTION_PROPERTIES.join(", ")
744        )))
745    }
746}
747
748fn deserialize_motion_property<'de, D>(deserializer: D) -> Result<String, D::Error>
749where
750    D: serde::Deserializer<'de>,
751{
752    let s = String::deserialize(deserializer)?;
753    validate_motion_property::<D::Error>(&s)?;
754    Ok(s)
755}
756
757fn deserialize_validated_keyframes<'de, D>(deserializer: D) -> Result<Vec<Animation>, D::Error>
758where
759    D: serde::Deserializer<'de>,
760{
761    let animations = Vec::<Animation>::deserialize(deserializer)?;
762    for anim in &animations {
763        validate_motion_property::<D::Error>(&anim.property)?;
764    }
765    Ok(animations)
766}
767
768/// Custom keyframe animations configuration.
769#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
770#[serde(deny_unknown_fields)]
771pub struct KeyframesConfig {
772    #[serde(deserialize_with = "deserialize_validated_keyframes")]
773    pub keyframes: Vec<Animation>,
774    #[serde(default)]
775    pub delay: f64,
776    #[serde(default = "default_animation_duration")]
777    pub duration: f64,
778    #[serde(default, rename = "loop")]
779    pub repeat: bool,
780}
781
782/// Animates the `d` attribute of one `<path>` inside an inline `svg`
783/// document between keyframe shapes, the `svg` counterpart to `shape`'s
784/// `path_morph`. An SVG document typically holds several paths, unlike
785/// `shape`'s single implicit path, so `target` names which one this track
786/// drives.
787#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
788#[serde(deny_unknown_fields)]
789pub struct SvgPathMorph {
790    /// The `id` attribute of the `<path>` element inside the inline SVG
791    /// document this track drives, with or without a leading `#`. No match
792    /// inside the document is a validate-time error, not a silent no-op.
793    pub target: String,
794    /// At least one keyframe; a single keyframe holds that shape statically.
795    pub keyframes: Vec<SvgPathMorphKeyframe>,
796    /// Applied within each segment between two consecutive keyframes.
797    #[serde(default)]
798    pub easing: EasingType,
799    /// Loops back to the first keyframe once `time` passes the last one,
800    /// instead of holding the last shape forever.
801    #[serde(default)]
802    pub repeat: bool,
803    /// Only with `repeat`: alternates direction each cycle instead of
804    /// snapping back to the first keyframe.
805    #[serde(default)]
806    pub yoyo: bool,
807}
808
809/// One stop in a `SvgPathMorph`.
810#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
811#[serde(deny_unknown_fields)]
812pub struct SvgPathMorphKeyframe {
813    /// Seconds from scene start, like every other keyframe `time` in this
814    /// schema — not a `0..1` fraction.
815    pub time: f64,
816    /// SVG path data (the `d`-attribute mini-language), replacing the
817    /// target path's own `d` at this keyframe's time.
818    pub value: String,
819}
820
821/// Motion blur configuration.
822///
823/// Ghost nodes are sampled at times `t - i * (shutter / fps) / samples` for
824/// `i` in `1..=samples`. Each ghost and the principal node are painted with
825/// opacity `1.0 / (samples + 1)` so their premultiplied-alpha sum approximates
826/// the shutter-averaged exposure.
827///
828/// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and
829/// superimposes exactly on the principal → visually equivalent to no blur.
830///
831/// `mode: "smear"` skips the ghost sampler entirely: the node's displacement
832/// over the `shutter / fps` window that precedes `t` is turned into a
833/// `{ "fn": "blur", "radius-x": …, "radius-y": … }` filter on the principal
834/// itself — one streaked copy instead of up to sixteen stacked ones, and no
835/// risk of a ghost taking a flex slot since none are created.
836#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
837#[serde(deny_unknown_fields)]
838pub struct MotionBlurConfig {
839    /// Reserved for future intensity scaling (currently unused by the ghost
840    /// sampler — the `samples` parameter controls quality). Kept for schema
841    /// compatibility with existing JSON that may carry this field.
842    #[serde(default)]
843    pub intensity: f32,
844    /// Number of ghost samples in the shutter window (default 6, clamped 1..=16).
845    /// Use 1 to effectively disable (degenerate: ghost = principal position).
846    /// Ignored when `mode` is `"smear"`.
847    #[serde(default = "default_motion_blur_samples")]
848    pub samples: u32,
849    /// Fraction of one frame duration used as the shutter window (default 0.5).
850    /// The temporal spread equals `shutter / fps` seconds. Also the window
851    /// `mode: "smear"` samples velocity over.
852    #[serde(default = "default_motion_blur_shutter")]
853    pub shutter: f64,
854    /// `"stack"` (default) paints ghost copies at sampled instants. `"smear"`
855    /// derives a directional blur from instantaneous velocity instead.
856    #[serde(default)]
857    pub mode: MotionBlurMode,
858}
859
860/// How `motion_blur` renders its trail. See `MotionBlurConfig`.
861#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
862#[serde(rename_all = "snake_case")]
863pub enum MotionBlurMode {
864    /// Ghost copies painted at sampled instants (the historical behaviour).
865    #[default]
866    Stack,
867    /// A directional `blur` derived from instantaneous velocity, applied to
868    /// the principal itself. Creates no ghost nodes; `samples` is ignored.
869    Smear,
870}
871
872fn default_motion_blur_samples() -> u32 {
873    6
874}
875fn default_motion_blur_shutter() -> f64 {
876    0.5
877}
878
879/// Trail effect configuration.
880///
881/// Produces `copies` ghost nodes behind the principal, each offset in time by
882/// `i * spacing` seconds. The i-th ghost (1-based) is painted with opacity
883/// `base_opacity * falloff^i`; the principal is unchanged. Unlike motion blur,
884/// the trail is additive: the principal retains its full opacity.
885#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
886#[serde(deny_unknown_fields)]
887pub struct TrailConfig {
888    /// Number of trailing ghost copies (default 4, clamped 1..=12).
889    #[serde(default = "default_trail_copies")]
890    pub copies: u32,
891    /// Time gap between successive ghost copies in seconds (default 0.05).
892    #[serde(default = "default_trail_spacing")]
893    pub spacing: f64,
894    /// Opacity multiplier applied per copy: ghost `i` uses `falloff^i` times
895    /// the component's base opacity (default 0.6).
896    #[serde(default = "default_trail_falloff")]
897    pub falloff: f32,
898}
899
900fn default_trail_copies() -> u32 {
901    4
902}
903fn default_trail_spacing() -> f64 {
904    0.05
905}
906fn default_trail_falloff() -> f32 {
907    0.6
908}
909
910/// Configuration for a 3D orbit/floating animation effect.
911/// Creates circular or elliptical motion with pseudo-depth (scale + opacity modulation).
912#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
913#[serde(deny_unknown_fields)]
914pub struct OrbitConfig {
915    /// Horizontal radius of the orbit in pixels.
916    #[serde(default = "default_orbit_radius")]
917    pub radius_x: f64,
918    /// Vertical radius of the orbit in pixels.
919    #[serde(default = "default_orbit_radius")]
920    pub radius_y: f64,
921    /// Orbit speed in revolutions per second.
922    #[serde(default = "default_orbit_speed")]
923    pub speed: f64,
924    /// Starting angle in degrees (0 = right, 90 = bottom).
925    #[serde(default)]
926    pub start_angle: f64,
927    /// Scale modulation depth (0.0 = none, 0.2 = 20% size variation for depth effect).
928    #[serde(default = "default_orbit_depth")]
929    pub depth: f64,
930    /// Opacity modulation depth (0.0 = none, 0.3 = 30% opacity variation for depth).
931    #[serde(default)]
932    pub opacity_depth: f64,
933    /// Tilt angle in degrees — tilts the orbit plane for a 3D perspective look.
934    #[serde(default)]
935    pub tilt: f64,
936    /// Phase offset (0.0 to 1.0) — offsets the starting position along the orbit.
937    #[serde(default)]
938    pub phase: f64,
939}
940
941fn default_orbit_radius() -> f64 {
942    30.0
943}
944fn default_orbit_speed() -> f64 {
945    0.5
946}
947fn default_orbit_depth() -> f64 {
948    0.15
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
952#[serde(deny_unknown_fields)]
953pub struct WiggleConfig {
954    #[serde(deserialize_with = "deserialize_motion_property")]
955    pub property: String,
956    pub amplitude: f64,
957    pub frequency: f64,
958    #[serde(default)]
959    pub seed: u64,
960    /// Number of noise octaves (higher = more detail, default 3)
961    #[serde(default)]
962    pub octaves: Option<u32>,
963    /// Phase offset in seconds
964    #[serde(default)]
965    pub phase: Option<f64>,
966    /// Exponential decay rate (amplitude diminishes over time)
967    #[serde(default)]
968    pub decay: Option<f64>,
969    /// Easing function to reshape the noise curve
970    #[serde(default)]
971    pub easing: Option<EasingType>,
972    /// Oscillation mode: "noise" (default, layered simplex) or "sine" (pure sine wave)
973    #[serde(default)]
974    pub mode: Option<String>,
975}
976
977/// Configuration for the `motion_path` animation effect: moves — and,
978/// optionally, orients — a component along an SVG path.
979///
980/// # Path syntax
981/// `path` is the SVG path `d`-attribute mini-language (`M`/`L`/`H`/`V`/`C`/
982/// `S`/`Q`/`T`/`A`/`Z`, absolute or relative) — the exact syntax `shape`'s
983/// `ShapeType::Path { data }` already accepts and parses with
984/// `skia_safe::Path::from_svg` (`engine/renderer/shapes.rs`). This effect
985/// reuses that same call rather than inventing a second path grammar, and
986/// measures the parsed path with `skia_safe::PathMeasure` — the exact
987/// primitive `svg.rs` (dash-reveal of an SVG document's paths) and
988/// `arrow.rs` (dash-reveal of a bezier curve, plus its own tangent-based
989/// arrowhead orientation) already use for `draw_progress`. `orient`'s
990/// tangent-to-degrees math is the same `atan2(tangent.y, tangent.x)` idiom
991/// `arrow.rs::draw_arrowhead` already computes for its arrowhead.
992///
993/// # Coordinate space
994/// Path coordinates are pixel **deltas relative to wherever CSS layout
995/// placed the component absent this effect** — the same convention `orbit`
996/// already uses for its circular motion (see `OrbitConfig`/`apply_orbits`),
997/// and the only one implementable here: the resolver this effect plugs into
998/// (`engine::animator::resolve_props_for_effects`) receives only
999/// `(effects, time, scene_duration)` — never the component's resolved
1000/// layout box or the viewport, which are computed downstream in
1001/// `paint_pass.rs`/`box_builder.rs`. So `"M0,0 L200,0"` slides the
1002/// component 200px right of its laid-out position (and back, if the effect
1003/// loops); `"M100,0 L300,0"` starts the component already displaced 100px
1004/// right of its laid-out position — a direct, intentional consequence of
1005/// treating the whole path as a translate delta, not a bug to normalize
1006/// away.
1007///
1008/// # Degenerate paths
1009/// - **Empty or syntactically invalid** `path` (e.g. `""`, garbage text) is
1010///   rejected at JSON-parse time by [`deserialize_motion_path_data`] — a
1011///   named error, never a silent no-op, mirroring
1012///   [`deserialize_motion_property`]'s treatment of an unrecognised
1013///   `wiggle`/`keyframes` property name.
1014/// - **Zero measured length** (a single point, e.g. `"M50,50"`, or every
1015///   segment collapsing onto one point) is syntactically valid SVG and is
1016///   *not* rejected at parse time — it has a well-defined render-time
1017///   meaning: the component holds at that single point for the whole
1018///   timeline, and `orient` (if set) contributes no rotation (a tangent is
1019///   undefined at zero length) instead of propagating a NaN. See
1020///   `engine::animator::motion_path_sample`. `validate_schema.rs` flags it
1021///   as a (non-blocking) warning, since it is very likely — but not
1022///   certainly — an authoring mistake (e.g. duplicated coordinates).
1023#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1024#[serde(deny_unknown_fields)]
1025pub struct MotionPathConfig {
1026    /// SVG path data (the `d`-attribute mini-language). Must contain at
1027    /// least one drawable point — an empty or unparsable path is rejected
1028    /// at deserialize time (see [`deserialize_motion_path_data`]).
1029    #[serde(deserialize_with = "deserialize_motion_path_data")]
1030    pub path: String,
1031    /// Delay before travel starts (seconds). Before this, the component
1032    /// sits at the path's start point (progress 0) — the same "hold at the
1033    /// entrance state" behaviour every other preset/effect with a `delay`
1034    /// already has.
1035    #[serde(default)]
1036    pub delay: f64,
1037    /// Time to travel the whole path once (seconds).
1038    #[serde(default = "default_animation_duration")]
1039    pub duration: f64,
1040    /// Loop the traversal continuously instead of holding at the path's end
1041    /// once `delay + duration` has elapsed.
1042    #[serde(default, rename = "loop")]
1043    pub repeat: bool,
1044    /// Rotate the component to face the path's tangent direction (default
1045    /// false — position without orientation, e.g. for content that should
1046    /// stay upright while it moves).
1047    #[serde(default)]
1048    pub orient: bool,
1049    /// Degrees added on top of the tangent-derived rotation, for assets
1050    /// whose drawn "forward" direction is not +X (default 0.0). E.g. an
1051    /// icon drawn pointing up needs `orient_offset: 90`.
1052    #[serde(default)]
1053    pub orient_offset: f64,
1054    /// Easing applied to progress along the path (default linear — constant
1055    /// speed along the curve, the expected default for a hand-authored
1056    /// trajectory; `ease_in`/`ease_out` bunches travel toward one end).
1057    #[serde(default)]
1058    pub easing: EasingType,
1059}
1060
1061fn deserialize_motion_path_data<'de, D>(deserializer: D) -> Result<String, D::Error>
1062where
1063    D: serde::Deserializer<'de>,
1064{
1065    let s = String::deserialize(deserializer)?;
1066    match SkiaPath::from_svg(&s) {
1067        Some(path) if path.count_points() > 0 => Ok(s),
1068        Some(_) => Err(serde::de::Error::custom(format!(
1069            "motion_path.path '{s}' has no drawable point (an empty path has nothing to travel \
1070             to) — provide at least one command, e.g. \"M0,0 L100,0\""
1071        ))),
1072        None => Err(serde::de::Error::custom(format!(
1073            "motion_path.path '{s}' is not valid SVG path data (the same 'd'-attribute \
1074             mini-language shape's type: \"path\" accepts), e.g. \"M0,0 C50,-100 150,-100 200,0\""
1075        ))),
1076    }
1077}
1078
1079/// Configuration for the `shatter` animation effect: the node's own rendered
1080/// subtree — background, border, content and children, exactly as it would
1081/// have painted — is rasterised once, cut into `pieces` convex cells by a
1082/// deterministic Voronoi partition seeded by `seed`, and each cell is flown
1083/// away from `origin`, spun and faded independently.
1084///
1085/// `seed` and the sampled instant fully determine every shard's cell,
1086/// direction, spin and depth — two renders of the same file at the same time
1087/// are byte-identical.
1088///
1089/// `mode` decides which end of the timeline is the intact node and which is
1090/// the fully dispersed one, and what happens once `delay + duration` has
1091/// elapsed:
1092/// - `"out"` (default): assembled at `delay`, fully dispersed at
1093///   `delay + duration`. Outside `[delay, delay + duration)` the effect
1094///   contributes nothing at all — the node renders byte-identically to one
1095///   with no `shatter` effect in its `animation` list, the same hard
1096///   short-circuit `chromatic_aberration` and `zoom_blur` use rather than a
1097///   fade that only gets close to zero.
1098/// - `"in"` is the mirror: dispersed at `delay`, assembled at
1099///   `delay + duration`, and — like `"out"` — outside its window the node
1100///   renders as if the effect were absent.
1101/// - `"hold"` plays the same dispersal as `"out"` but freezes at the fully
1102///   dispersed state once `delay + duration` is reached instead of
1103///   short-circuiting back to the plain node — it legitimately never
1104///   converges.
1105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1106#[serde(deny_unknown_fields)]
1107pub struct ShatterConfig {
1108    /// Delay before the shards start moving (seconds).
1109    #[serde(default)]
1110    pub delay: f64,
1111    /// How long the dispersal (or, in `"in"` mode, the assembly) takes
1112    /// (seconds).
1113    #[serde(default = "default_shatter_duration")]
1114    pub duration: f64,
1115    /// Which end of the timeline is intact and what happens after
1116    /// `delay + duration`. See the type-level doc comment.
1117    #[serde(default)]
1118    pub mode: ShatterMode,
1119    /// Number of Voronoi shards (default 24, clamped 1..=64).
1120    #[serde(default = "default_shatter_pieces")]
1121    pub pieces: u32,
1122    /// Seed for the deterministic Voronoi partition and every shard's
1123    /// direction/spin/depth jitter.
1124    #[serde(default)]
1125    pub seed: u32,
1126    /// Point shards fly away from (or, in `"in"` mode, converge toward), as
1127    /// a fraction of the node's own box (`{ "x": 0.5, "y": 0.5 }` is the
1128    /// centre, the default).
1129    #[serde(default)]
1130    pub origin: ShatterOrigin,
1131    /// Radial travel multiplier at full dispersal, relative to the node's
1132    /// own diagonal (default 1.0). `0` pins shards in place — only spin,
1133    /// depth and fade remain visible.
1134    #[serde(default = "default_shatter_spread")]
1135    pub spread: f32,
1136    /// Maximum rotation in degrees a shard reaches at full dispersal
1137    /// (default 90); each shard's own sign and magnitude are jittered from
1138    /// `seed`.
1139    #[serde(default = "default_shatter_spin")]
1140    pub spin: f32,
1141    /// Per-shard scale modulation at full dispersal, the same "0.0 = none"
1142    /// semantics as `OrbitConfig::depth` (default 0.4): each shard's own
1143    /// signed depth is jittered from `seed`, so some shards appear to come
1144    /// toward the camera (scale > 1) while others recede (scale < 1).
1145    #[serde(default = "default_shatter_depth")]
1146    pub depth: f32,
1147    /// Fade each shard's opacity to zero as it reaches full dispersal
1148    /// (default true). With `mode: "in"` this is a mirror: shards start
1149    /// transparent and reach full opacity as they assemble.
1150    #[serde(default = "default_true")]
1151    pub fade: bool,
1152}
1153
1154fn default_shatter_duration() -> f64 {
1155    0.6
1156}
1157fn default_shatter_pieces() -> u32 {
1158    24
1159}
1160fn default_shatter_spread() -> f32 {
1161    1.0
1162}
1163fn default_shatter_spin() -> f32 {
1164    90.0
1165}
1166fn default_shatter_depth() -> f32 {
1167    0.4
1168}
1169fn default_true() -> bool {
1170    true
1171}
1172
1173/// Which end of `shatter`'s timeline is the intact node. See
1174/// [`ShatterConfig`]'s doc comment.
1175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1176#[serde(rename_all = "snake_case")]
1177pub enum ShatterMode {
1178    /// Assembled at `delay`, dispersed at `delay + duration`, then
1179    /// short-circuits back to the plain node.
1180    #[default]
1181    Out,
1182    /// Dispersed at `delay`, assembled at `delay + duration`, then
1183    /// short-circuits back to the plain node (the same "no effect" state
1184    /// its own end converges to).
1185    In,
1186    /// Same dispersal as `"out"`, but freezes at full dispersal instead of
1187    /// returning to the plain node.
1188    Hold,
1189}
1190
1191/// Fractional point within `shatter`'s own box — `{ "x": 0.0, "y": 0.0 }` is
1192/// the top-left corner, `{ "x": 1.0, "y": 1.0 }` the bottom-right. See
1193/// [`ShatterConfig::origin`].
1194#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
1195#[serde(deny_unknown_fields)]
1196pub struct ShatterOrigin {
1197    #[serde(default = "default_shatter_origin_component")]
1198    pub x: f32,
1199    #[serde(default = "default_shatter_origin_component")]
1200    pub y: f32,
1201}
1202
1203impl Default for ShatterOrigin {
1204    fn default() -> Self {
1205        Self {
1206            x: default_shatter_origin_component(),
1207            y: default_shatter_origin_component(),
1208        }
1209    }
1210}
1211
1212fn default_shatter_origin_component() -> f32 {
1213    0.5
1214}
1215
1216/// Configuration for the `burst` animation effect: a ring of short strokes
1217/// that shoot outward from just off the node's own box edge and then retract,
1218/// the splash of ink a pill or a badge throws when it pops in.
1219///
1220/// Nothing about the node itself is touched — the strokes are painted over it,
1221/// outside its box, and take no layout space. Outside
1222/// `[delay, delay + duration)` the effect contributes nothing at all.
1223///
1224/// The stroke is drawn by a head and a tail that each cross the stroke's track
1225/// once: the head runs out over the first half of the window, the tail follows
1226/// over the second. Both ends of the window therefore have a zero-length
1227/// stroke — the burst is invisible at `delay` and at `delay + duration`
1228/// without ever being a fade that merely gets small.
1229#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1230#[serde(deny_unknown_fields)]
1231pub struct BurstConfig {
1232    /// Delay before the strokes start shooting out (seconds).
1233    #[serde(default)]
1234    pub delay: f64,
1235    /// Full out-and-back duration (seconds). The head reaches the far end of
1236    /// its track at the halfway point.
1237    #[serde(default = "default_burst_duration")]
1238    pub duration: f64,
1239    /// Number of strokes in the ring (default 8, clamped 1..=64).
1240    #[serde(default = "default_burst_count")]
1241    pub count: u32,
1242    /// Length of each stroke's track, in px, measured outward from `gap`
1243    /// (default 40).
1244    #[serde(default = "default_burst_length")]
1245    pub length: f32,
1246    /// Distance in px between the node's box edge and the near end of every
1247    /// stroke's track (default 12). The box is never overdrawn.
1248    #[serde(default = "default_burst_gap")]
1249    pub gap: f32,
1250    /// Stroke width in px (default 4).
1251    #[serde(default = "default_burst_width")]
1252    pub width: f32,
1253    /// Stroke colour (hex string, default "#FFB020").
1254    #[serde(default = "default_burst_color")]
1255    pub color: String,
1256    /// Seed for the per-stroke angle, length and phase jitter.
1257    #[serde(default)]
1258    pub seed: u32,
1259    /// How far each stroke may stray from its even share of the ring, as a
1260    /// fraction of the spacing between two strokes (default 0.2). `0` gives a
1261    /// perfectly regular ring; `1` lets a stroke reach its neighbour's slot.
1262    /// It also scales the per-stroke length and phase jitter.
1263    #[serde(default = "default_burst_jitter")]
1264    pub jitter: f32,
1265}
1266
1267fn default_burst_duration() -> f64 {
1268    0.4
1269}
1270fn default_burst_count() -> u32 {
1271    8
1272}
1273fn default_burst_length() -> f32 {
1274    40.0
1275}
1276fn default_burst_gap() -> f32 {
1277    12.0
1278}
1279fn default_burst_width() -> f32 {
1280    4.0
1281}
1282fn default_burst_color() -> String {
1283    "#FFB020".to_string()
1284}
1285fn default_burst_jitter() -> f32 {
1286    0.2
1287}
1288
1289#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1290#[serde(rename_all = "snake_case")]
1291pub enum ShapeType {
1292    Rect,
1293    Circle,
1294    RoundedRect,
1295    Ellipse,
1296    Triangle,
1297    Star {
1298        #[serde(default = "default_star_points")]
1299        points: u32,
1300    },
1301    Polygon {
1302        #[serde(default = "default_polygon_sides")]
1303        sides: u32,
1304    },
1305    Path {
1306        data: String,
1307    },
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1311#[serde(untagged)]
1312pub enum Fill {
1313    Solid(String),
1314    Gradient(Gradient),
1315}
1316
1317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1318pub struct Gradient {
1319    #[serde(rename = "type")]
1320    pub gradient_type: GradientType,
1321    pub colors: Vec<String>,
1322    #[serde(default)]
1323    pub stops: Option<Vec<f32>>,
1324    #[serde(default)]
1325    pub angle: Option<f32>,
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1329#[serde(rename_all = "snake_case")]
1330pub enum GradientType {
1331    Linear,
1332    Radial,
1333}
1334
1335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1336pub struct Stroke {
1337    pub color: String,
1338    #[serde(default = "default_stroke_width")]
1339    pub width: f32,
1340    /// `skia_safe::PathEffect::dash` interval list (on-length, off-length,
1341    /// repeating) — the same spelling and shape `arrow`/`connector`/`line`
1342    /// already use for their own `dashed` field. `None` (the default)
1343    /// strokes solid, exactly as every `Stroke` did before this field
1344    /// existed.
1345    #[serde(default)]
1346    pub dashed: Option<Vec<f32>>,
1347    /// Phase offset (px) into `dashed`'s pattern — `skia_safe::PathEffect::
1348    /// dash`'s second argument, hardcoded to `0.0` on `arrow`/`connector`/
1349    /// `line` today. A literal number is a constant phase; an `"= ..."`
1350    /// expression (see `crate::expr`) is re-evaluated every frame against
1351    /// the node's `crate::css::FrameClock` (`$t`/`$T`/`$duration`/`$W`/
1352    /// `$H`/`$fps`), the same per-frame mechanism `CssStyle`'s `opacity`/
1353    /// `width`/`height` already use — this is what makes a dashed stroke's
1354    /// "draw-on" (dash length == path length, offset animating from the
1355    /// full length down to `0`) expressible without the engine's classic
1356    /// keyframe/easing `AnimatedProperties` pipeline.
1357    #[serde(default)]
1358    pub dash_offset: Option<crate::expr::Computed<f32>>,
1359    /// `stroke-linecap`. Default `LineCap::Butt` — skia's own default, so
1360    /// a stroke that never set this renders byte-identical to before this
1361    /// field existed.
1362    #[serde(default)]
1363    pub line_cap: LineCap,
1364    /// `stroke-linejoin`. Default `LineJoin::Miter` — skia's own default,
1365    /// same backward-compatibility guarantee as `line_cap` above.
1366    #[serde(default)]
1367    pub line_join: LineJoin,
1368}
1369
1370/// `stroke-linecap` — how an open subpath's two ends are drawn. See
1371/// `Stroke::line_cap`.
1372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1373#[serde(rename_all = "snake_case")]
1374pub enum LineCap {
1375    #[default]
1376    Butt,
1377    Round,
1378    Square,
1379}
1380
1381/// `stroke-linejoin` — how two stroked segments meet at a vertex. See
1382/// `Stroke::line_join`.
1383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1384#[serde(rename_all = "snake_case")]
1385pub enum LineJoin {
1386    #[default]
1387    Miter,
1388    Round,
1389    Bevel,
1390}
1391
1392#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1393#[serde(rename_all = "snake_case")]
1394#[derive(Default)]
1395pub enum ImageFit {
1396    Cover,
1397    #[default]
1398    Contain,
1399    Fill,
1400}
1401
1402#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1403pub struct ShapeText {
1404    pub content: String,
1405    #[serde(default = "default_font_size")]
1406    pub font_size: f32,
1407    #[serde(default = "default_color")]
1408    pub color: String,
1409    #[serde(default = "default_font_family")]
1410    pub font_family: String,
1411    #[serde(default)]
1412    pub font_weight: FontWeight,
1413    #[serde(default)]
1414    pub align: TextAlign,
1415    #[serde(default)]
1416    pub vertical_align: VerticalAlign,
1417    #[serde(default)]
1418    pub line_height: Option<f32>,
1419    #[serde(default)]
1420    pub letter_spacing: Option<f32>,
1421    #[serde(default)]
1422    pub padding: Option<f32>,
1423}
1424
1425#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1426pub struct CaptionWord {
1427    pub text: String,
1428    pub start: f64,
1429    pub end: f64,
1430}
1431
1432#[derive(Debug, Serialize, Deserialize, JsonSchema)]
1433#[serde(rename_all = "snake_case")]
1434#[derive(Default)]
1435pub enum CaptionStyle {
1436    #[default]
1437    Highlight,
1438    Karaoke,
1439    WordByWord,
1440    /// TikTok-style: only the active word is shown, centered, with a
1441    /// spring-like scale-in and a rounded pill background.
1442    WordPop,
1443    /// Karaoke line (all words visible, wrapped) where the active word
1444    /// scales up, takes `active_color` and gets a pill background.
1445    KaraokePop,
1446}
1447
1448#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1449pub struct GradientBorder {
1450    pub colors: Vec<String>,
1451    #[serde(default = "default_gradient_border_width")]
1452    pub width: f32,
1453    #[serde(default)]
1454    pub angle: f32,
1455}
1456
1457fn default_gradient_border_width() -> f32 {
1458    2.0
1459}
1460
1461/// Inner shadow configuration (inset shadow).
1462#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1463pub struct InnerShadow {
1464    pub color: String,
1465    #[serde(default)]
1466    pub offset_x: f32,
1467    #[serde(default)]
1468    pub offset_y: f32,
1469    #[serde(default = "default_inner_shadow_blur")]
1470    pub blur: f32,
1471}
1472
1473fn default_inner_shadow_blur() -> f32 {
1474    10.0
1475}
1476
1477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1478pub struct TextShadow {
1479    #[serde(default = "default_shadow_color")]
1480    pub color: String,
1481    #[serde(default = "default_shadow_offset")]
1482    pub offset_x: f32,
1483    #[serde(default = "default_shadow_offset")]
1484    pub offset_y: f32,
1485    #[serde(default = "default_shadow_blur")]
1486    pub blur: f32,
1487}
1488
1489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1490pub struct TextBackground {
1491    pub color: String,
1492    #[serde(default = "default_text_bg_padding")]
1493    pub padding: f32,
1494    #[serde(default)]
1495    pub corner_radius: f32,
1496}
1497
1498/// Glow effect (colored luminous halo around the element)
1499#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1500#[serde(deny_unknown_fields)]
1501pub struct GlowConfig {
1502    /// Glow color (hex string, e.g. "#5C39EE")
1503    #[serde(default = "default_glow_color")]
1504    pub color: String,
1505    /// Blur radius of the glow
1506    #[serde(default = "default_glow_radius")]
1507    pub radius: f32,
1508    /// Intensity multiplier (higher = brighter glow, default 1.0)
1509    #[serde(default = "default_glow_intensity")]
1510    pub intensity: f32,
1511}
1512
1513/// A later label a `text` swaps to, and when.
1514#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1515#[serde(deny_unknown_fields)]
1516pub struct TextState {
1517    /// Time (seconds, scene-local) at which this label takes over.
1518    pub at: f64,
1519    /// The label from `at` onwards.
1520    pub content: String,
1521}
1522
1523/// How a `text` crosses from one of its `states` to the next.
1524///
1525/// Both labels are on screen at once during the window: the outgoing one
1526/// leaves upwards while blurring out, the incoming one arrives from below
1527/// while sharpening. Cutting between them instead reads as a glitch, and
1528/// fading alone reads as two unrelated labels rather than one value changing.
1529#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1530#[serde(deny_unknown_fields)]
1531pub struct TextSwapConfig {
1532    /// How long the crossing takes (seconds).
1533    #[serde(default = "default_swap_duration")]
1534    pub duration: f64,
1535    /// Vertical travel of each label, in px.
1536    #[serde(default = "default_swap_distance")]
1537    pub distance: f32,
1538    /// Peak blur sigma (px) reached by a label at the far end of its travel.
1539    /// `0` gives a pure slide-and-fade.
1540    #[serde(default = "default_swap_blur")]
1541    pub blur: f32,
1542}
1543
1544impl Default for TextSwapConfig {
1545    fn default() -> Self {
1546        Self {
1547            duration: default_swap_duration(),
1548            distance: default_swap_distance(),
1549            blur: default_swap_blur(),
1550        }
1551    }
1552}
1553
1554fn default_swap_duration() -> f64 {
1555    0.45
1556}
1557
1558fn default_swap_distance() -> f32 {
1559    18.0
1560}
1561
1562fn default_swap_blur() -> f32 {
1563    8.0
1564}
1565
1566/// Shape of a text caret.
1567#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
1568#[serde(rename_all = "snake_case")]
1569pub enum CaretShape {
1570    /// A thin vertical rule, like a text editor's insertion point.
1571    #[default]
1572    Line,
1573    /// A filled block covering a character cell, like a terminal.
1574    Block,
1575}
1576
1577/// A caret pinned to a `text`'s reveal head.
1578///
1579/// Only meaningful alongside a `typewriter` animation: the caret follows the
1580/// last revealed character and stops at the end of the line once the reveal
1581/// finishes. Composing a standalone `cursor` component next to the text gets
1582/// you a caret that stays where you put it while the text grows out from
1583/// under it, which is the thing this exists to avoid.
1584#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1585#[serde(deny_unknown_fields)]
1586pub struct CaretConfig {
1587    /// Caret shape.
1588    #[serde(default)]
1589    pub shape: CaretShape,
1590    /// Caret colour (hex). Defaults to the text's own colour, so a caret
1591    /// inherits a theme change without being restated.
1592    #[serde(default)]
1593    pub color: Option<String>,
1594    /// Blink period in seconds — one full off/on cycle. `0` disables the
1595    /// blink and leaves the caret solid.
1596    #[serde(default = "default_caret_blink")]
1597    pub blink: f32,
1598    /// Hide the caret once the reveal has finished, instead of leaving it
1599    /// parked at the end of the text.
1600    #[serde(default)]
1601    pub hide_when_done: bool,
1602}
1603
1604impl Default for CaretConfig {
1605    fn default() -> Self {
1606        Self {
1607            shape: CaretShape::default(),
1608            color: None,
1609            blink: default_caret_blink(),
1610            hide_when_done: false,
1611        }
1612    }
1613}
1614
1615fn default_caret_blink() -> f32 {
1616    1.0
1617}
1618
1619/// A band of light sweeping across the element, restricted to the pixels the
1620/// element actually painted.
1621///
1622/// That restriction is the whole point: painted over the element's *box*, a
1623/// sheen reads as a rectangle sliding past. Painted over the element's own
1624/// alpha, it reads as light catching the glyphs (or the icon, or the chart
1625/// bars) themselves. See `engine::paint_pass`, which composites it with
1626/// `BlendMode::SrcATop` inside the node's own layer.
1627#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1628#[serde(deny_unknown_fields)]
1629pub struct ShimmerConfig {
1630    /// Delay before the sweep starts (seconds).
1631    #[serde(default)]
1632    pub delay: f64,
1633    /// How long one sweep takes (seconds).
1634    #[serde(default = "default_shimmer_duration")]
1635    pub duration: f64,
1636    /// Colour of the band (hex).
1637    #[serde(default = "default_shimmer_color")]
1638    pub color: String,
1639    /// Band width as a fraction of the distance it sweeps (0.35 = a band
1640    /// covering roughly a third of the element at any instant). Wider reads
1641    /// as a soft wash, narrower as a hard glint.
1642    #[serde(default = "default_shimmer_width")]
1643    pub width: f32,
1644    /// Peak opacity of the band (0..1).
1645    #[serde(default = "default_shimmer_intensity")]
1646    pub intensity: f32,
1647    /// Lean of the band in degrees. `0` is an upright band sweeping
1648    /// left-to-right; the default 20 tilts it, which is what makes it read
1649    /// as a reflection rather than a wipe. The band always travels
1650    /// perpendicular to itself, so this angles the travel too.
1651    #[serde(default = "default_shimmer_angle")]
1652    pub angle: f32,
1653    /// Repeat the sweep for the rest of the scene.
1654    #[serde(default, rename = "loop")]
1655    pub repeat: bool,
1656}
1657
1658fn default_shimmer_duration() -> f64 {
1659    0.9
1660}
1661
1662fn default_shimmer_color() -> String {
1663    "#FFFFFF".to_string()
1664}
1665
1666fn default_shimmer_width() -> f32 {
1667    0.35
1668}
1669
1670fn default_shimmer_intensity() -> f32 {
1671    0.75
1672}
1673
1674fn default_shimmer_angle() -> f32 {
1675    20.0
1676}
1677
1678fn default_glow_color() -> String {
1679    "#FFFFFF80".to_string()
1680}
1681
1682fn default_glow_radius() -> f32 {
1683    10.0
1684}
1685
1686fn default_glow_intensity() -> f32 {
1687    1.0
1688}
1689
1690fn default_font_size() -> f32 {
1691    48.0
1692}
1693
1694fn default_color() -> String {
1695    "#FFFFFF".to_string()
1696}
1697
1698fn default_font_family() -> String {
1699    "Inter".to_string()
1700}
1701
1702fn default_stroke_width() -> f32 {
1703    2.0
1704}
1705
1706fn default_star_points() -> u32 {
1707    5
1708}
1709
1710fn default_polygon_sides() -> u32 {
1711    6
1712}
1713
1714fn default_shadow_color() -> String {
1715    "#00000080".to_string()
1716}
1717
1718fn default_shadow_offset() -> f32 {
1719    2.0
1720}
1721
1722fn default_shadow_blur() -> f32 {
1723    4.0
1724}
1725
1726fn default_text_bg_padding() -> f32 {
1727    8.0
1728}
1729
1730#[cfg(test)]
1731mod motion_property_tests {
1732    use super::*;
1733    use serde_json::json;
1734
1735    #[test]
1736    fn wiggle_known_property_still_works() {
1737        let json = json!({
1738            "name": "wiggle",
1739            "property": "translate_x",
1740            "amplitude": 10.0,
1741            "frequency": 1.0
1742        });
1743        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1744        match effect {
1745            AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "translate_x"),
1746            other => panic!("expected Wiggle, got {other:?}"),
1747        }
1748    }
1749
1750    #[test]
1751    fn wiggle_unknown_property_is_a_named_error_not_a_silent_no_op() {
1752        let json = json!({
1753            "name": "wiggle",
1754            "property": "skew",
1755            "amplitude": 10.0,
1756            "frequency": 1.0
1757        });
1758        let err = serde_json::from_value::<AnimationEffect>(json)
1759            .expect_err("an unrecognised wiggle property must be rejected, not silently inert");
1760        assert!(err.to_string().contains("skew"), "got: {err}");
1761    }
1762
1763    #[test]
1764    fn wiggle_kebab_case_property_gets_a_did_you_mean() {
1765        let json = json!({
1766            "name": "wiggle",
1767            "property": "translate-x",
1768            "amplitude": 10.0,
1769            "frequency": 1.0
1770        });
1771        let err = serde_json::from_value::<AnimationEffect>(json)
1772            .expect_err("kebab-case must not silently resolve to a snake_case no-op");
1773        let msg = err.to_string();
1774        assert!(msg.contains("translate-x"), "got: {msg}");
1775        assert!(
1776            msg.contains("translate_x"),
1777            "expected a did-you-mean nudge toward the correct spelling, got: {msg}"
1778        );
1779    }
1780
1781    #[test]
1782    fn keyframes_animation_known_property_still_works() {
1783        let json = json!({
1784            "name": "keyframes",
1785            "keyframes": [
1786                { "property": "opacity", "keyframes": [
1787                    { "time": 0.0, "value": 0.0 },
1788                    { "time": 1.0, "value": 1.0 }
1789                ]}
1790            ]
1791        });
1792        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1793        match effect {
1794            AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "opacity"),
1795            other => panic!("expected Keyframes, got {other:?}"),
1796        }
1797    }
1798
1799    #[test]
1800    fn keyframes_animation_unknown_property_is_a_named_error() {
1801        let json = json!({
1802            "name": "keyframes",
1803            "keyframes": [
1804                { "property": "positionX", "keyframes": [
1805                    { "time": 0.0, "value": 0.0 },
1806                    { "time": 1.0, "value": 1.0 }
1807                ]}
1808            ]
1809        });
1810        let err = serde_json::from_value::<AnimationEffect>(json).expect_err(
1811            "an unrecognised keyframe animation property must be rejected, not silently inert",
1812        );
1813        assert!(err.to_string().contains("positionX"), "got: {err}");
1814    }
1815
1816    #[test]
1817    fn keyframes_animation_color_property_still_works() {
1818        let json = json!({
1819            "name": "keyframes",
1820            "keyframes": [
1821                { "property": "color", "keyframes": [
1822                    { "time": 0.0, "value": "#000000" },
1823                    { "time": 1.0, "value": "#ffffff" }
1824                ]}
1825            ]
1826        });
1827        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1828        assert!(matches!(effect, AnimationEffect::Keyframes(_)));
1829    }
1830
1831    #[test]
1832    fn draw_offset_is_a_recognized_keyframe_animation_property() {
1833        let json = json!({
1834            "name": "keyframes",
1835            "keyframes": [
1836                { "property": "draw_offset", "keyframes": [
1837                    { "time": 0.0, "value": 0.0 },
1838                    { "time": 1.0, "value": 1.0 }
1839                ]}
1840            ]
1841        });
1842        let effect: AnimationEffect = serde_json::from_value(json)
1843            .expect("draw_offset must be a known animation property, not rejected as unknown");
1844        match effect {
1845            AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "draw_offset"),
1846            other => panic!("expected Keyframes, got {other:?}"),
1847        }
1848    }
1849
1850    #[test]
1851    fn draw_offset_is_a_recognized_wiggle_property() {
1852        let json = json!({
1853            "name": "wiggle",
1854            "property": "draw_offset",
1855            "amplitude": 0.1,
1856            "frequency": 1.0
1857        });
1858        let effect: AnimationEffect = serde_json::from_value(json)
1859            .expect("draw_offset must be usable as a wiggle property, not rejected as unknown");
1860        match effect {
1861            AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "draw_offset"),
1862            other => panic!("expected Wiggle, got {other:?}"),
1863        }
1864    }
1865}
1866
1867#[cfg(test)]
1868mod svg_path_morph_schema_tests {
1869    use super::*;
1870    use serde_json::json;
1871
1872    #[test]
1873    fn a_target_and_at_least_one_keyframe_parse() {
1874        let json = json!({
1875            "target": "#outline",
1876            "keyframes": [
1877                { "time": 0.0, "value": "M0 0 L10 0" },
1878                { "time": 1.0, "value": "M0 0 L20 0" }
1879            ]
1880        });
1881        let morph: SvgPathMorph = serde_json::from_value(json).expect("valid SvgPathMorph JSON");
1882        assert_eq!(morph.target, "#outline");
1883        assert_eq!(morph.keyframes.len(), 2);
1884    }
1885
1886    #[test]
1887    fn a_missing_target_is_rejected_rather_than_silently_matching_nothing() {
1888        let json = json!({
1889            "keyframes": [
1890                { "time": 0.0, "value": "M0 0 L10 0" }
1891            ]
1892        });
1893        serde_json::from_value::<SvgPathMorph>(json).expect_err(
1894            "target is required — without it there is no path to select inside the document",
1895        );
1896    }
1897
1898    #[test]
1899    fn an_unknown_field_is_rejected_by_name() {
1900        let json = json!({
1901            "target": "#outline",
1902            "keyframes": [{ "time": 0.0, "value": "M0 0 L10 0" }],
1903            "duratoin": 1.0
1904        });
1905        let err = serde_json::from_value::<SvgPathMorph>(json)
1906            .expect_err("a typo'd field must be rejected, not silently ignored");
1907        assert!(err.to_string().contains("duratoin"), "got: {err}");
1908    }
1909}
1910
1911#[cfg(test)]
1912mod motion_path_schema_tests {
1913    use super::*;
1914    use serde_json::json;
1915
1916    #[test]
1917    fn motion_path_deserializes_with_known_fields() {
1918        let json = json!({
1919            "name": "motion_path",
1920            "path": "M0,0 L100,0 L100,100",
1921            "delay": 0.2,
1922            "duration": 1.5,
1923            "loop": true,
1924            "orient": true,
1925            "orient_offset": 90.0,
1926            "easing": "ease_out"
1927        });
1928        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1929        match effect {
1930            AnimationEffect::MotionPath(cfg) => {
1931                assert_eq!(cfg.path, "M0,0 L100,0 L100,100");
1932                assert_eq!(cfg.delay, 0.2);
1933                assert_eq!(cfg.duration, 1.5);
1934                assert!(cfg.repeat);
1935                assert!(cfg.orient);
1936                assert_eq!(cfg.orient_offset, 90.0);
1937                assert_eq!(cfg.easing, EasingType::EaseOut);
1938            }
1939            other => panic!("expected MotionPath, got {other:?}"),
1940        }
1941    }
1942
1943    #[test]
1944    fn motion_path_defaults_match_documented_values() {
1945        let json = json!({ "name": "motion_path", "path": "M0,0 L10,0" });
1946        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1947        match effect {
1948            AnimationEffect::MotionPath(cfg) => {
1949                assert_eq!(cfg.delay, 0.0);
1950                assert_eq!(cfg.duration, default_animation_duration());
1951                assert!(!cfg.repeat);
1952                assert!(!cfg.orient);
1953                assert_eq!(cfg.orient_offset, 0.0);
1954                assert_eq!(cfg.easing, EasingType::Linear);
1955            }
1956            other => panic!("expected MotionPath, got {other:?}"),
1957        }
1958    }
1959
1960    #[test]
1961    fn motion_path_rejects_an_empty_path_string() {
1962        let json = json!({ "name": "motion_path", "path": "" });
1963        let err = serde_json::from_value::<AnimationEffect>(json)
1964            .expect_err("an empty motion_path.path must be rejected, not silently accepted");
1965        assert!(err.to_string().contains("no drawable point"), "got: {err}");
1966    }
1967
1968    #[test]
1969    fn motion_path_rejects_unparsable_svg_path_data() {
1970        let json = json!({ "name": "motion_path", "path": "this is not svg path data !!" });
1971        let err = serde_json::from_value::<AnimationEffect>(json)
1972            .expect_err("garbage path data must be rejected, not silently accepted");
1973        assert!(
1974            err.to_string().contains("not valid SVG path data"),
1975            "got: {err}"
1976        );
1977    }
1978
1979    #[test]
1980    fn motion_path_accepts_a_single_point_path() {
1981        let json = json!({ "name": "motion_path", "path": "M50,50" });
1982        let effect: AnimationEffect = serde_json::from_value(json)
1983            .expect("a syntactically valid single-point path must be accepted");
1984        assert!(matches!(effect, AnimationEffect::MotionPath(_)));
1985    }
1986
1987    #[test]
1988    fn motion_path_rejects_unknown_fields() {
1989        let json = json!({ "name": "motion_path", "path": "M0,0 L10,0", "detla": 0.2 });
1990        let err = serde_json::from_value::<AnimationEffect>(json)
1991            .expect_err("a typo'd field on motion_path must be rejected, not silently ignored");
1992        assert!(err.to_string().contains("detla"), "got: {err}");
1993    }
1994
1995    #[test]
1996    fn motion_path_shift_delay_shifts_the_configs_own_delay() {
1997        let mut effect = AnimationEffect::MotionPath(MotionPathConfig {
1998            path: "M0,0 L10,0".to_string(),
1999            delay: 0.5,
2000            duration: 1.0,
2001            repeat: false,
2002            orient: false,
2003            orient_offset: 0.0,
2004            easing: EasingType::Linear,
2005        });
2006        effect.shift_delay(0.25);
2007        match effect {
2008            AnimationEffect::MotionPath(cfg) => assert!((cfg.delay - 0.75).abs() < 1e-9),
2009            other => panic!("expected MotionPath, got {other:?}"),
2010        }
2011    }
2012}
2013
2014#[cfg(test)]
2015mod animation_timing_repeat_widening_tests {
2016    use super::*;
2017    use serde_json::json;
2018
2019    #[test]
2020    fn loop_true_is_unchanged_infinite_repeat() {
2021        let json = json!({ "name": "pulse", "loop": true });
2022        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
2023        match effect {
2024            AnimationEffect::Pulse(t) => {
2025                assert!(t.repeat, "\"loop\": true must still set repeat = true");
2026                assert_eq!(
2027                    t.repeat_count, None,
2028                    "a bare `true` carries no count — infinite, exactly as before"
2029                );
2030                assert!(!t.yoyo);
2031                assert_eq!(t.repeat_delay, 0.0);
2032            }
2033            other => panic!("expected Pulse, got {other:?}"),
2034        }
2035    }
2036
2037    #[test]
2038    fn loop_false_and_omitted_are_unchanged_and_identical() {
2039        let explicit: AnimationTiming = serde_json::from_value(json!({ "loop": false })).unwrap();
2040        let omitted: AnimationTiming = serde_json::from_value(json!({})).unwrap();
2041        assert_eq!(
2042            explicit, omitted,
2043            "an explicit `false` and an omitted `loop` must resolve identically"
2044        );
2045        assert!(!explicit.repeat);
2046        assert_eq!(explicit.repeat_count, None);
2047        assert_eq!(explicit, AnimationTiming::default());
2048    }
2049
2050    #[test]
2051    fn loop_as_a_positive_integer_sets_repeat_and_the_count() {
2052        let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap();
2053        assert!(t.repeat, "a finite count still loops — see the field doc");
2054        assert_eq!(t.repeat_count, Some(12));
2055    }
2056
2057    #[test]
2058    fn loop_as_zero_or_one_folds_back_to_the_boolean_form() {
2059        for n in [0, 1] {
2060            let t: AnimationTiming = serde_json::from_value(json!({ "loop": n })).unwrap();
2061            assert!(!t.repeat, "loop: {n} must not set repeat");
2062            assert_eq!(t.repeat_count, None, "loop: {n} must not carry a count");
2063        }
2064    }
2065
2066    #[test]
2067    fn yoyo_and_repeat_delay_default_to_off_and_zero() {
2068        let t: AnimationTiming = serde_json::from_value(json!({})).unwrap();
2069        assert!(!t.yoyo);
2070        assert_eq!(t.repeat_delay, 0.0);
2071    }
2072
2073    #[test]
2074    fn yoyo_and_repeat_delay_are_accepted_on_any_animation_timing() {
2075        let json = json!({
2076            "name": "shake",
2077            "duration": 0.035,
2078            "loop": 12,
2079            "yoyo": true,
2080            "repeat_delay": 0.01
2081        });
2082        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
2083        match effect {
2084            AnimationEffect::Shake(t) => {
2085                assert!(t.yoyo);
2086                assert_eq!(t.repeat_delay, 0.01);
2087                assert_eq!(t.repeat_count, Some(12));
2088            }
2089            other => panic!("expected Shake, got {other:?}"),
2090        }
2091    }
2092
2093    #[test]
2094    fn a_typo_is_still_rejected_through_the_wire_type() {
2095        let json = json!({ "name": "pulse", "duratoin": 1.0 });
2096        let err = serde_json::from_value::<AnimationEffect>(json)
2097            .expect_err("a typo'd field must still be rejected, not silently ignored");
2098        assert!(err.to_string().contains("duratoin"), "got: {err}");
2099    }
2100
2101    #[test]
2102    fn loop_as_a_negative_number_is_rejected_not_silently_coerced() {
2103        let json = json!({ "name": "pulse", "loop": -1 });
2104        assert!(
2105            serde_json::from_value::<AnimationEffect>(json).is_err(),
2106            "a negative \"loop\" is neither a bool nor a valid play count"
2107        );
2108    }
2109
2110    #[test]
2111    fn a_finite_repeat_count_round_trips_through_a_single_loop_key() {
2112        let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap();
2113        let json = serde_json::to_value(&t).unwrap();
2114        assert!(
2115            json.get("repeat_count").is_none(),
2116            "must not emit a separate repeat_count key: {json}"
2117        );
2118        assert_eq!(json["loop"], serde_json::json!(12));
2119        let back: AnimationTiming = serde_json::from_value(json).unwrap();
2120        assert_eq!(t, back);
2121    }
2122
2123    #[test]
2124    fn easing_steps_round_trips_and_is_distinct_from_cubic_bezier() {
2125        let json = json!({ "steps": 4 });
2126        let easing: EasingType = serde_json::from_value(json).unwrap();
2127        assert_eq!(easing, EasingType::Steps(4));
2128        let back = serde_json::to_value(&easing).unwrap();
2129        assert_eq!(back, json!({ "steps": 4 }));
2130    }
2131}