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