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::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig};
6use super::style::{FontWeight, TextAlign, VerticalAlign};
7
8// --- Animation effects (nested inside CssStyle as typed array) ---
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    // --- Entrance presets ---
23    FadeIn(AnimationTiming),
24    FadeInUp(AnimationTiming),
25    FadeInDown(AnimationTiming),
26    FadeInLeft(AnimationTiming),
27    FadeInRight(AnimationTiming),
28    SlideInLeft(AnimationTiming),
29    SlideInRight(AnimationTiming),
30    SlideInUp(AnimationTiming),
31    SlideInDown(AnimationTiming),
32    ScaleIn(AnimationTiming),
33    BounceIn(AnimationTiming),
34    BlurIn(AnimationTiming),
35    RotateIn(AnimationTiming),
36    ElasticIn(AnimationTiming),
37    /// Scale up from nothing with a back-out overshoot, then a short elastic
38    /// pulse before settling — the notification-badge arrival, where the
39    /// second beat is what draws the eye after the first has placed the
40    /// element. `AnimationTiming.overshoot` sets the pulse amplitude
41    /// (default 0.18 = 118%); 0 reduces it to a plain back-out scale-in.
42    PopIn(AnimationTiming),
43    // --- Exit presets ---
44    FadeOut(AnimationTiming),
45    FadeOutUp(AnimationTiming),
46    FadeOutDown(AnimationTiming),
47    SlideOutLeft(AnimationTiming),
48    SlideOutRight(AnimationTiming),
49    SlideOutUp(AnimationTiming),
50    SlideOutDown(AnimationTiming),
51    ScaleOut(AnimationTiming),
52    BounceOut(AnimationTiming),
53    BlurOut(AnimationTiming),
54    RotateOut(AnimationTiming),
55    // --- Continuous presets ---
56    Pulse(AnimationTiming),
57    Float(AnimationTiming),
58    Shake(AnimationTiming),
59    Spin(AnimationTiming),
60    // --- 3D presets ---
61    FlipInX(AnimationTiming),
62    FlipInY(AnimationTiming),
63    FlipOutX(AnimationTiming),
64    FlipOutY(AnimationTiming),
65    TiltIn(TiltInConfig),
66    // --- Stroke presets ---
67    DrawIn(AnimationTiming),
68    StrokeReveal(AnimationTiming),
69    // --- Special presets ---
70    Typewriter(AnimationTiming),
71    WipeLeft(AnimationTiming),
72    WipeRight(AnimationTiming),
73    // --- Floating/orbit presets ---
74    #[serde(alias = "float_3d")]
75    Float3d(AnimationTiming),
76    // --- Char animation presets ---
77    CharScaleIn(CharAnimationTiming),
78    CharFadeIn(CharAnimationTiming),
79    CharWave(CharAnimationTiming),
80    CharBounce(CharAnimationTiming),
81    CharRotateIn(CharAnimationTiming),
82    CharSlideUp(CharAnimationTiming),
83    /// Each word (or char) arrives blurred and settles sharp, combined with
84    /// a slight upward translate and an opacity ramp — one continuous
85    /// per-unit animation driven by the same progress value, not three
86    /// independently-timed effects. `CharAnimationTiming.blur` sets the
87    /// starting blur sigma in px (default
88    /// `engine::animator::DEFAULT_CHAR_BLUR_SIGMA`, tuned for 100px+ display
89    /// type).
90    ///
91    /// Resolved through `engine::animator::extract_effects` →
92    /// `ResolvedCharAnimation` like its five siblings, so it picks up
93    /// container-level stagger shifting and `timeline`-embedded copies. (It
94    /// used to be read directly off `style.animation` inside
95    /// `rustmotion_components::text::Text::paint` and therefore missed both.)
96    CharBlurIn(CharAnimationTiming),
97    // --- Non-preset effects ---
98    Glow(GlowConfig),
99    /// A band of light sweeping across the element's own painted pixels.
100    /// See [`ShimmerConfig`].
101    Shimmer(ShimmerConfig),
102    Wiggle(WiggleConfig),
103    Orbit(OrbitConfig),
104    Keyframes(KeyframesConfig),
105    MotionBlur(MotionBlurConfig),
106    /// Temporal trail effect: paints copies of the component at prior times
107    /// with decaying opacity, creating a persistence-of-vision ghost trail.
108    Trail(TrailConfig),
109    /// Move the component along an SVG path, and — when `orient` is set —
110    /// rotate it to face the path's tangent direction. See
111    /// [`MotionPathConfig`]'s doc comment for the path syntax, the
112    /// coordinate space path points are interpreted in, and how degenerate
113    /// paths (empty, single-point, zero-length) are handled.
114    MotionPath(MotionPathConfig),
115}
116
117impl AnimationEffect {
118    /// Shift the effect's start delay by `by` seconds. Used by `timeline`
119    /// steps, whose animations run relative to the step's `at`. Continuous
120    /// effects without a delay concept (glow, wiggle, orbit, motion blur)
121    /// are unaffected.
122    pub fn shift_delay(&mut self, by: f64) {
123        use AnimationEffect::*;
124        match self {
125            FadeIn(t) | FadeInUp(t) | FadeInDown(t) | FadeInLeft(t) | FadeInRight(t)
126            | SlideInLeft(t) | SlideInRight(t) | SlideInUp(t) | SlideInDown(t) | ScaleIn(t)
127            | BounceIn(t) | BlurIn(t) | RotateIn(t) | ElasticIn(t) | PopIn(t) | FadeOut(t)
128            | FadeOutUp(t) | FadeOutDown(t) | SlideOutLeft(t) | SlideOutRight(t)
129            | SlideOutUp(t) | SlideOutDown(t) | ScaleOut(t) | BounceOut(t) | BlurOut(t)
130            | RotateOut(t) | Pulse(t) | Float(t) | Shake(t) | Spin(t) | FlipInX(t) | FlipInY(t)
131            | FlipOutX(t) | FlipOutY(t) | DrawIn(t) | StrokeReveal(t) | Typewriter(t)
132            | WipeLeft(t) | WipeRight(t) | Float3d(t) => t.delay += by,
133            TiltIn(c) => c.delay += by,
134            CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c)
135            | CharSlideUp(c) | CharBlurIn(c) => c.delay += by,
136            Keyframes(c) => c.delay += by,
137            MotionPath(c) => c.delay += by,
138            Shimmer(c) => c.delay += by,
139            Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {}
140        }
141    }
142
143    /// If this is a preset variant, return the corresponding AnimationPreset and timing.
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// `deny_unknown_fields` (constat #8): this is the `AnimationTiming` payload
194// of an internally-tagged `AnimationEffect` variant (`#[serde(tag = "name")]`
195// on the enum). Serde's tagged-enum deserializer buffers the object and
196// re-drives it through the variant's own `Deserialize` impl *without* the
197// `name` tag key, so `deny_unknown_fields` here rejects a typo'd field (e.g.
198// `duratoin`) without ever seeing/rejecting `name` itself — verified with a
199// minimal repro before relying on it. Without this, `validate_attrs.rs`
200// never sees inside `style.animation[*]` (it only walks component-level
201// keys), so a typo silently no-ops instead of erroring.
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
203#[serde(deny_unknown_fields)]
204pub struct AnimationTiming {
205    /// Delay before animation starts (seconds).
206    #[serde(default)]
207    pub delay: f64,
208    /// Animation duration (seconds).
209    #[serde(default = "default_animation_duration")]
210    pub duration: f64,
211    /// Loop the animation continuously.
212    #[serde(default, rename = "loop")]
213    pub repeat: bool,
214    /// Overshoot/anticipation intensity for scale_in/scale_out (0.0 = none, default 0.08 = 8%).
215    #[serde(default)]
216    pub overshoot: Option<f64>,
217    /// Spring physics for the preset's motion keyframes (translate/scale/
218    /// rotate — opacity keeps its ease to avoid alpha overshoot flashes).
219    /// `bounce_in` / `elastic_in` use their built-in springs as defaults;
220    /// this overrides them.
221    #[serde(default)]
222    pub spring: Option<SpringConfig>,
223    /// Travel of an oscillating preset, in pixels (`float_3d` only; default
224    /// 12). Threaded through `to_preset_config` into `PresetConfig::amplitude`,
225    /// which `expand_preset_inner` already reads — this field is what makes
226    /// an author-supplied amplitude actually reach it instead of the
227    /// hardcoded default on every element.
228    #[serde(default)]
229    pub amplitude: Option<f64>,
230}
231
232fn default_animation_duration() -> f64 {
233    0.8
234}
235
236/// Configuration for the `tilt_in` animation with configurable 3D transform values.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
238#[serde(deny_unknown_fields)]
239pub struct TiltInConfig {
240    #[serde(default)]
241    pub delay: f64,
242    #[serde(default = "default_animation_duration")]
243    pub duration: f64,
244    #[serde(default, rename = "loop")]
245    pub repeat: bool,
246    /// Initial rotate_x angle in degrees (default: 15.0).
247    #[serde(default)]
248    pub rotate_x: Option<f64>,
249    /// Initial rotate_y angle in degrees (default: -15.0).
250    #[serde(default)]
251    pub rotate_y: Option<f64>,
252    /// Perspective depth in px (default: 1000.0).
253    #[serde(default)]
254    pub perspective: Option<f64>,
255    /// Initial scale value (default: 0.9).
256    #[serde(default)]
257    pub scale_from: Option<f64>,
258}
259
260impl Default for AnimationTiming {
261    fn default() -> Self {
262        Self {
263            delay: 0.0,
264            duration: 0.8,
265            repeat: false,
266            overshoot: None,
267            spring: None,
268            amplitude: None,
269        }
270    }
271}
272
273/// Timing configuration for char animation effect variants (used inside style.animation).
274#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
275#[serde(deny_unknown_fields)]
276pub struct CharAnimationTiming {
277    /// Delay before animation starts (seconds).
278    #[serde(default)]
279    pub delay: f64,
280    /// Duration of each unit's animation in seconds.
281    #[serde(default = "default_char_duration_f64")]
282    pub duration: f64,
283    /// Delay between each unit (char or word) in seconds.
284    #[serde(default = "default_char_stagger_f64")]
285    pub stagger: f64,
286    /// Granularity: animate per character or per word.
287    #[serde(default)]
288    pub granularity: TextAnimGranularity,
289    /// Easing function for each unit's animation.
290    #[serde(default)]
291    pub easing: EasingType,
292    /// Overshoot intensity for char_scale_in/char_bounce (0.0 = none, default 0.08 = 8%).
293    #[serde(default)]
294    pub overshoot: Option<f64>,
295    /// Starting blur sigma in px for char_blur_in — the word (or char)
296    /// begins blurred by this amount and settles to sharp (sigma 0) by the
297    /// end of its unit animation. Unused by the other five char presets.
298    /// Defaults to 14px sigma, chosen to read clearly at 100px+ display
299    /// type without collapsing into a featureless blob (see render proof
300    /// in issue #118).
301    #[serde(default)]
302    pub blur: Option<f64>,
303    /// Which way each unit travels in from. Only `char_slide_up` and
304    /// `char_blur_in` have a travel axis to redirect; the other presets
305    /// ignore it. Default `up` — the historical behaviour.
306    #[serde(default)]
307    pub direction: TextAnimDirection,
308    /// Multiplier on how far each unit travels, `1.0` being each preset's
309    /// own tuned distance (0.8em for `slide_up`, 0.12em for `blur_in`).
310    /// Use ~0.5 for a tighter arrival, ~1.85 for a pronounced staircase.
311    #[serde(default)]
312    pub distance: Option<f64>,
313    /// Scale each unit starts at, growing to 1.0 over its animation —
314    /// combined with, not instead of, the preset's own motion. 0.82 gives
315    /// the punchy "number pops in" arrival, 0.92 a barely-perceptible
316    /// settle. Unset means no scaling (the historical behaviour); the
317    /// scale-based presets (`char_scale_in`, `char_bounce`) own their scale
318    /// curve outright and ignore this.
319    #[serde(default)]
320    pub scale_from: Option<f64>,
321    /// Randomises each unit's start time by up to ±`jitter × stagger`, so
322    /// the units arrive in uneven bursts instead of a metronomic march.
323    /// This is what separates a streaming-token look from a typewriter:
324    /// language models don't emit words on a clock. 0 (default) keeps the
325    /// exact even spacing.
326    ///
327    /// The offsets are derived from `seed` and the unit's index, never from
328    /// a live RNG — a frame must render identically no matter which
329    /// process, thread or `--frames` segment computes it.
330    #[serde(default)]
331    pub jitter: Option<f64>,
332    /// Seed for `jitter`. Changing it reshuffles the arrival rhythm without
333    /// changing its statistics.
334    #[serde(default)]
335    pub seed: Option<u32>,
336    /// Colour each unit starts at before settling to the text's own colour
337    /// over its animation. A dim grey here reproduces the way freshly
338    /// streamed tokens read as unsettled before the eye accepts them.
339    #[serde(default)]
340    pub ink_from: Option<String>,
341}
342
343impl Default for CharAnimationTiming {
344    /// Mirrors the serde defaults exactly, so `CharAnimationTiming::default()`
345    /// and `serde_json::from_value(json!({}))` describe the same animation.
346    fn default() -> Self {
347        Self {
348            delay: 0.0,
349            duration: default_char_duration_f64(),
350            stagger: default_char_stagger_f64(),
351            granularity: TextAnimGranularity::default(),
352            easing: EasingType::default(),
353            overshoot: None,
354            blur: None,
355            direction: TextAnimDirection::default(),
356            distance: None,
357            scale_from: None,
358            jitter: None,
359            seed: None,
360            ink_from: None,
361        }
362    }
363}
364
365fn default_char_stagger_f64() -> f64 {
366    0.03
367}
368
369fn default_char_duration_f64() -> f64 {
370    0.4
371}
372
373/// Per-character or per-word text animation configuration (legacy root-level prop).
374#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
375pub struct CharAnimation {
376    /// Animation preset: "scale_in", "fade_in", "wave", "bounce", "rotate_in", "slide_up", "blur_in".
377    #[serde(default = "default_char_preset")]
378    pub preset: CharAnimPreset,
379    /// Granularity: animate per character or per word.
380    #[serde(default)]
381    pub granularity: TextAnimGranularity,
382    /// Delay between each unit (char or word) in seconds.
383    #[serde(default = "default_char_stagger")]
384    pub stagger: f32,
385    /// Duration of each unit's animation in seconds.
386    #[serde(default = "default_char_duration")]
387    pub duration: f32,
388    /// Easing function.
389    #[serde(default)]
390    pub easing: EasingType,
391    /// Initial delay before the first unit starts.
392    #[serde(default)]
393    pub delay: f32,
394}
395
396/// Granularity for text animation: per character or per word.
397#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
398#[serde(rename_all = "snake_case")]
399#[derive(PartialEq)]
400pub enum TextAnimGranularity {
401    /// Animate each character individually (default).
402    #[default]
403    Char,
404    /// Animate each word as a unit.
405    Word,
406}
407
408/// Which way a per-unit entrance travels from before it settles.
409///
410/// The unit starts offset in the *opposite* direction of travel and moves
411/// towards its laid-out position: `Up` (the default, and the historical
412/// behaviour) starts below the baseline and rises; `Down` starts above and
413/// falls, which is the "letters cascading from the top" look.
414///
415/// Read by the `slide_up` and `blur_in` char presets — the ones whose motion
416/// is a translate. `scale_in`, `bounce`, `rotate_in`, `fade_in` and `wave`
417/// have no travel axis to redirect and ignore it.
418#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
419#[serde(rename_all = "snake_case")]
420pub enum TextAnimDirection {
421    /// Starts below its line and rises into place (default).
422    #[default]
423    Up,
424    /// Starts above its line and falls into place.
425    Down,
426    /// Starts to the right and slides left into place.
427    Left,
428    /// Starts to the left and slides right into place.
429    Right,
430}
431
432impl TextAnimDirection {
433    /// The `(x, y)` offset, in px, a unit sits at when its animation has not
434    /// started yet. `travel` is the distance the unit covers.
435    pub fn offset(self, travel: f32) -> (f32, f32) {
436        match self {
437            Self::Up => (0.0, travel),
438            Self::Down => (0.0, -travel),
439            Self::Left => (travel, 0.0),
440            Self::Right => (-travel, 0.0),
441        }
442    }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
446#[serde(rename_all = "snake_case")]
447#[derive(PartialEq)]
448pub enum CharAnimPreset {
449    /// Each character scales from 0 to 1.
450    #[default]
451    ScaleIn,
452    /// Each character fades from 0 to 1 opacity.
453    FadeIn,
454    /// Characters oscillate vertically in a wave pattern.
455    Wave,
456    /// Each character bounces in (scale overshoot).
457    Bounce,
458    /// Each character rotates in from a random angle.
459    RotateIn,
460    /// Each character slides up from below.
461    SlideUp,
462    /// Each character/word arrives blurred and settles sharp, with a
463    /// slight upward translate and opacity ramp driven by the same
464    /// progress value (see `AnimationEffect::CharBlurIn`).
465    BlurIn,
466}
467
468fn default_char_preset() -> CharAnimPreset {
469    CharAnimPreset::ScaleIn
470}
471
472fn default_char_stagger() -> f32 {
473    0.03
474}
475
476fn default_char_duration() -> f32 {
477    0.4
478}
479
480impl AnimationTiming {
481    /// Convert to PresetConfig for compatibility with resolve_animations.
482    pub fn to_preset_config(&self) -> PresetConfig {
483        PresetConfig {
484            amplitude: self.amplitude,
485            delay: self.delay,
486            duration: self.duration,
487            repeat: self.repeat,
488            overshoot: self.overshoot,
489            spring: self.spring.clone(),
490        }
491    }
492}
493
494/// Constat #4: every `property` name `engine::animator::{apply_property,
495/// get_property_value}` (read-only for this workstream — the solver logic
496/// itself stays there) actually recognises for `wiggle`/`keyframes`
497/// animations. Anything outside this set has always been a silent no-op in
498/// the solver (`_ => {}` / `_ => 0.0`): the animation plays as if the
499/// property doesn't exist, with no error and no visual signal that
500/// something is wrong. `WiggleConfig.property` and `Animation.property`
501/// (the latter via `KeyframesConfig.keyframes`'s `deserialize_with`, since
502/// `Animation` itself lives in `schema/animation.rs`, which this workstream
503/// may only touch for `deny_unknown_fields`) are validated against this set
504/// at parse time instead — turning the silent no-op into a named error, so
505/// a mixed-convention typo (`"translateX"`, `"positionX"`, `"Rotation"`) or
506/// a wholesale unsupported name is caught immediately.
507///
508/// `"color"` is included because `resolve_animations` special-cases
509/// `anim.property == "color"` outside `apply_property`/`get_property_value`
510/// — it is a real, solver-recognised value for `Animation`, just resolved on
511/// a different path than the numeric properties.
512const KNOWN_MOTION_PROPERTIES: &[&str] = &[
513    "opacity",
514    "position.x",
515    "translate_x",
516    "position.y",
517    "translate_y",
518    "scale",
519    "scale.x",
520    "scale.y",
521    "rotation",
522    "rotate_x",
523    "rotate_y",
524    "blur",
525    "visible_chars",
526    "visible_chars_progress",
527    "border_radius",
528    "font_size",
529    "width",
530    "height",
531    "gap",
532    "padding",
533    "stroke_width",
534    "shadow_blur",
535    "glow_radius",
536    "glow_intensity",
537    "perspective",
538    "draw_progress",
539    "motion_progress",
540    "color",
541];
542
543/// Reject a `property` value the solver doesn't recognise, with a
544/// "did-you-mean" nudge when the only mismatch is casing/separator
545/// convention (`translateX` / `translate-x` vs `translate_x`) — the exact
546/// trap constat #4 names: this project mixes kebab-case (CSS-style, most of
547/// `CssStyle`) and snake_case (these property names) conventions, and an
548/// author reasoning from the former naturally reaches for the latter's
549/// kebab or camelCase spelling.
550fn validate_motion_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
551    if KNOWN_MOTION_PROPERTIES.contains(&value) {
552        return Ok(());
553    }
554    let normalize = |s: &str| s.replace(['-', ' '], "_").to_lowercase();
555    let normalized = normalize(value);
556    if let Some(suggestion) = KNOWN_MOTION_PROPERTIES
557        .iter()
558        .find(|known| normalize(known) == normalized)
559    {
560        Err(E::custom(format!(
561            "unknown animation property '{value}' — did you mean '{suggestion}'?"
562        )))
563    } else {
564        Err(E::custom(format!(
565            "unknown animation property '{value}': expected one of {}",
566            KNOWN_MOTION_PROPERTIES.join(", ")
567        )))
568    }
569}
570
571fn deserialize_motion_property<'de, D>(deserializer: D) -> Result<String, D::Error>
572where
573    D: serde::Deserializer<'de>,
574{
575    let s = String::deserialize(deserializer)?;
576    validate_motion_property::<D::Error>(&s)?;
577    Ok(s)
578}
579
580/// Validates every keyframe's `property` the same way
581/// [`deserialize_motion_property`] does for `WiggleConfig` — `Animation`
582/// itself lives in `schema/animation.rs`, out of reach for anything beyond
583/// `deny_unknown_fields` in this workstream, so the check is applied here,
584/// at the one field that actually consumes `Vec<Animation>` in this file.
585fn deserialize_validated_keyframes<'de, D>(deserializer: D) -> Result<Vec<Animation>, D::Error>
586where
587    D: serde::Deserializer<'de>,
588{
589    let animations = Vec::<Animation>::deserialize(deserializer)?;
590    for anim in &animations {
591        validate_motion_property::<D::Error>(&anim.property)?;
592    }
593    Ok(animations)
594}
595
596/// Custom keyframe animations configuration.
597#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
598#[serde(deny_unknown_fields)]
599pub struct KeyframesConfig {
600    #[serde(deserialize_with = "deserialize_validated_keyframes")]
601    pub keyframes: Vec<Animation>,
602    #[serde(default)]
603    pub delay: f64,
604    #[serde(default = "default_animation_duration")]
605    pub duration: f64,
606    #[serde(default, rename = "loop")]
607    pub repeat: bool,
608}
609
610/// Motion blur configuration.
611///
612/// Ghost nodes are sampled at times `t - i * (shutter / fps) / samples` for
613/// `i` in `1..=samples`. Each ghost and the principal node are painted with
614/// opacity `1.0 / (samples + 1)` so their premultiplied-alpha sum approximates
615/// the shutter-averaged exposure.
616///
617/// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and
618/// superimposes exactly on the principal → visually equivalent to no blur.
619#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
620#[serde(deny_unknown_fields)]
621pub struct MotionBlurConfig {
622    /// Reserved for future intensity scaling (currently unused by the ghost
623    /// sampler — the `samples` parameter controls quality). Kept for schema
624    /// compatibility with existing JSON that may carry this field.
625    #[serde(default)]
626    pub intensity: f32,
627    /// Number of ghost samples in the shutter window (default 6, clamped 1..=16).
628    /// Use 1 to effectively disable (degenerate: ghost = principal position).
629    #[serde(default = "default_motion_blur_samples")]
630    pub samples: u32,
631    /// Fraction of one frame duration used as the shutter window (default 0.5).
632    /// The temporal spread equals `shutter / fps` seconds.
633    #[serde(default = "default_motion_blur_shutter")]
634    pub shutter: f64,
635}
636
637fn default_motion_blur_samples() -> u32 {
638    6
639}
640fn default_motion_blur_shutter() -> f64 {
641    0.5
642}
643
644/// Trail effect configuration.
645///
646/// Produces `copies` ghost nodes behind the principal, each offset in time by
647/// `i * spacing` seconds. The i-th ghost (1-based) is painted with opacity
648/// `base_opacity * falloff^i`; the principal is unchanged. Unlike motion blur,
649/// the trail is additive: the principal retains its full opacity.
650#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
651#[serde(deny_unknown_fields)]
652pub struct TrailConfig {
653    /// Number of trailing ghost copies (default 4, clamped 1..=12).
654    #[serde(default = "default_trail_copies")]
655    pub copies: u32,
656    /// Time gap between successive ghost copies in seconds (default 0.05).
657    #[serde(default = "default_trail_spacing")]
658    pub spacing: f64,
659    /// Opacity multiplier applied per copy: ghost `i` uses `falloff^i` times
660    /// the component's base opacity (default 0.6).
661    #[serde(default = "default_trail_falloff")]
662    pub falloff: f32,
663}
664
665fn default_trail_copies() -> u32 {
666    4
667}
668fn default_trail_spacing() -> f64 {
669    0.05
670}
671fn default_trail_falloff() -> f32 {
672    0.6
673}
674
675// --- Orbit Config ---
676
677/// Configuration for a 3D orbit/floating animation effect.
678/// Creates circular or elliptical motion with pseudo-depth (scale + opacity modulation).
679#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
680#[serde(deny_unknown_fields)]
681pub struct OrbitConfig {
682    /// Horizontal radius of the orbit in pixels.
683    #[serde(default = "default_orbit_radius")]
684    pub radius_x: f64,
685    /// Vertical radius of the orbit in pixels.
686    #[serde(default = "default_orbit_radius")]
687    pub radius_y: f64,
688    /// Orbit speed in revolutions per second.
689    #[serde(default = "default_orbit_speed")]
690    pub speed: f64,
691    /// Starting angle in degrees (0 = right, 90 = bottom).
692    #[serde(default)]
693    pub start_angle: f64,
694    /// Scale modulation depth (0.0 = none, 0.2 = 20% size variation for depth effect).
695    #[serde(default = "default_orbit_depth")]
696    pub depth: f64,
697    /// Opacity modulation depth (0.0 = none, 0.3 = 30% opacity variation for depth).
698    #[serde(default)]
699    pub opacity_depth: f64,
700    /// Tilt angle in degrees — tilts the orbit plane for a 3D perspective look.
701    #[serde(default)]
702    pub tilt: f64,
703    /// Phase offset (0.0 to 1.0) — offsets the starting position along the orbit.
704    #[serde(default)]
705    pub phase: f64,
706}
707
708fn default_orbit_radius() -> f64 {
709    30.0
710}
711fn default_orbit_speed() -> f64 {
712    0.5
713}
714fn default_orbit_depth() -> f64 {
715    0.15
716}
717
718// --- Wiggle Config ---
719
720#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
721#[serde(deny_unknown_fields)]
722pub struct WiggleConfig {
723    #[serde(deserialize_with = "deserialize_motion_property")]
724    pub property: String,
725    pub amplitude: f64,
726    pub frequency: f64,
727    #[serde(default)]
728    pub seed: u64,
729    /// Number of noise octaves (higher = more detail, default 3)
730    #[serde(default)]
731    pub octaves: Option<u32>,
732    /// Phase offset in seconds
733    #[serde(default)]
734    pub phase: Option<f64>,
735    /// Exponential decay rate (amplitude diminishes over time)
736    #[serde(default)]
737    pub decay: Option<f64>,
738    /// Easing function to reshape the noise curve
739    #[serde(default)]
740    pub easing: Option<EasingType>,
741    /// Oscillation mode: "noise" (default, layered simplex) or "sine" (pure sine wave)
742    #[serde(default)]
743    pub mode: Option<String>,
744}
745
746// --- Motion Path Config ---
747
748/// Configuration for the `motion_path` animation effect: moves — and,
749/// optionally, orients — a component along an SVG path.
750///
751/// # Path syntax
752/// `path` is the SVG path `d`-attribute mini-language (`M`/`L`/`H`/`V`/`C`/
753/// `S`/`Q`/`T`/`A`/`Z`, absolute or relative) — the exact syntax `shape`'s
754/// `ShapeType::Path { data }` already accepts and parses with
755/// `skia_safe::Path::from_svg` (`engine/renderer/shapes.rs`). This effect
756/// reuses that same call rather than inventing a second path grammar, and
757/// measures the parsed path with `skia_safe::PathMeasure` — the exact
758/// primitive `svg.rs` (dash-reveal of an SVG document's paths) and
759/// `arrow.rs` (dash-reveal of a bezier curve, plus its own tangent-based
760/// arrowhead orientation) already use for `draw_progress`. `orient`'s
761/// tangent-to-degrees math is the same `atan2(tangent.y, tangent.x)` idiom
762/// `arrow.rs::draw_arrowhead` already computes for its arrowhead.
763///
764/// # Coordinate space
765/// Path coordinates are pixel **deltas relative to wherever CSS layout
766/// placed the component absent this effect** — the same convention `orbit`
767/// already uses for its circular motion (see `OrbitConfig`/`apply_orbits`),
768/// and the only one implementable here: the resolver this effect plugs into
769/// (`engine::animator::resolve_props_for_effects`) receives only
770/// `(effects, time, scene_duration)` — never the component's resolved
771/// layout box or the viewport, which are computed downstream in
772/// `paint_pass.rs`/`box_builder.rs`. So `"M0,0 L200,0"` slides the
773/// component 200px right of its laid-out position (and back, if the effect
774/// loops); `"M100,0 L300,0"` starts the component already displaced 100px
775/// right of its laid-out position — a direct, intentional consequence of
776/// treating the whole path as a translate delta, not a bug to normalize
777/// away.
778///
779/// # Degenerate paths
780/// - **Empty or syntactically invalid** `path` (e.g. `""`, garbage text) is
781///   rejected at JSON-parse time by [`deserialize_motion_path_data`] — a
782///   named error, never a silent no-op, mirroring
783///   [`deserialize_motion_property`]'s treatment of an unrecognised
784///   `wiggle`/`keyframes` property name.
785/// - **Zero measured length** (a single point, e.g. `"M50,50"`, or every
786///   segment collapsing onto one point) is syntactically valid SVG and is
787///   *not* rejected at parse time — it has a well-defined render-time
788///   meaning: the component holds at that single point for the whole
789///   timeline, and `orient` (if set) contributes no rotation (a tangent is
790///   undefined at zero length) instead of propagating a NaN. See
791///   `engine::animator::motion_path_sample`. `validate_schema.rs` flags it
792///   as a (non-blocking) warning, since it is very likely — but not
793///   certainly — an authoring mistake (e.g. duplicated coordinates).
794#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
795#[serde(deny_unknown_fields)]
796pub struct MotionPathConfig {
797    /// SVG path data (the `d`-attribute mini-language). Must contain at
798    /// least one drawable point — an empty or unparsable path is rejected
799    /// at deserialize time (see [`deserialize_motion_path_data`]).
800    #[serde(deserialize_with = "deserialize_motion_path_data")]
801    pub path: String,
802    /// Delay before travel starts (seconds). Before this, the component
803    /// sits at the path's start point (progress 0) — the same "hold at the
804    /// entrance state" behaviour every other preset/effect with a `delay`
805    /// already has.
806    #[serde(default)]
807    pub delay: f64,
808    /// Time to travel the whole path once (seconds).
809    #[serde(default = "default_animation_duration")]
810    pub duration: f64,
811    /// Loop the traversal continuously instead of holding at the path's end
812    /// once `delay + duration` has elapsed.
813    #[serde(default, rename = "loop")]
814    pub repeat: bool,
815    /// Rotate the component to face the path's tangent direction (default
816    /// false — position without orientation, e.g. for content that should
817    /// stay upright while it moves).
818    #[serde(default)]
819    pub orient: bool,
820    /// Degrees added on top of the tangent-derived rotation, for assets
821    /// whose drawn "forward" direction is not +X (default 0.0). E.g. an
822    /// icon drawn pointing up needs `orient_offset: 90`.
823    #[serde(default)]
824    pub orient_offset: f64,
825    /// Easing applied to progress along the path (default linear — constant
826    /// speed along the curve, the expected default for a hand-authored
827    /// trajectory; `ease_in`/`ease_out` bunches travel toward one end).
828    #[serde(default)]
829    pub easing: EasingType,
830}
831
832/// Reject `motion_path.path` values that cannot produce at least one
833/// drawable point — the JSON-authoring analogue of "empty path" from the
834/// workstream brief. Unlike [`deserialize_motion_property`], there is no
835/// finite alphabet to suggest a correction from: any syntactically valid
836/// (even visually nonsensical) SVG path `d` string is accepted, exactly as
837/// `shape`'s `ShapeType::Path { data }` already accepts it via the same
838/// `skia_safe::Path::from_svg` call — this does not invent a second path
839/// grammar.
840///
841/// A path that parses but has zero measured *length* (e.g. `"M50,50"`) is
842/// deliberately NOT rejected here — see `MotionPathConfig`'s "Degenerate
843/// paths" doc section for why, and where that case is instead surfaced (a
844/// `validate_schema.rs` warning, not a parse-time error).
845fn deserialize_motion_path_data<'de, D>(deserializer: D) -> Result<String, D::Error>
846where
847    D: serde::Deserializer<'de>,
848{
849    let s = String::deserialize(deserializer)?;
850    match SkiaPath::from_svg(&s) {
851        Some(path) if path.count_points() > 0 => Ok(s),
852        Some(_) => Err(serde::de::Error::custom(format!(
853            "motion_path.path '{s}' has no drawable point (an empty path has nothing to travel \
854             to) — provide at least one command, e.g. \"M0,0 L100,0\""
855        ))),
856        None => Err(serde::de::Error::custom(format!(
857            "motion_path.path '{s}' is not valid SVG path data (the same 'd'-attribute \
858             mini-language shape's type: \"path\" accepts), e.g. \"M0,0 C50,-100 150,-100 200,0\""
859        ))),
860    }
861}
862
863// --- Supporting types ---
864
865#[derive(Debug, Serialize, Deserialize, JsonSchema)]
866#[serde(rename_all = "snake_case")]
867pub enum ShapeType {
868    Rect,
869    Circle,
870    RoundedRect,
871    Ellipse,
872    Triangle,
873    Star {
874        #[serde(default = "default_star_points")]
875        points: u32,
876    },
877    Polygon {
878        #[serde(default = "default_polygon_sides")]
879        sides: u32,
880    },
881    Path {
882        data: String,
883    },
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
887#[serde(untagged)]
888pub enum Fill {
889    Solid(String),
890    Gradient(Gradient),
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
894pub struct Gradient {
895    #[serde(rename = "type")]
896    pub gradient_type: GradientType,
897    pub colors: Vec<String>,
898    #[serde(default)]
899    pub stops: Option<Vec<f32>>,
900    #[serde(default)]
901    pub angle: Option<f32>,
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
905#[serde(rename_all = "snake_case")]
906pub enum GradientType {
907    Linear,
908    Radial,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
912pub struct Stroke {
913    pub color: String,
914    #[serde(default = "default_stroke_width")]
915    pub width: f32,
916}
917
918#[derive(Debug, Serialize, Deserialize, JsonSchema)]
919#[serde(rename_all = "snake_case")]
920#[derive(Default)]
921pub enum ImageFit {
922    Cover,
923    #[default]
924    Contain,
925    Fill,
926}
927
928// --- Shape Text ---
929
930#[derive(Debug, Serialize, Deserialize, JsonSchema)]
931pub struct ShapeText {
932    pub content: String,
933    #[serde(default = "default_font_size")]
934    pub font_size: f32,
935    #[serde(default = "default_color")]
936    pub color: String,
937    #[serde(default = "default_font_family")]
938    pub font_family: String,
939    #[serde(default)]
940    pub font_weight: FontWeight,
941    #[serde(default)]
942    pub align: TextAlign,
943    #[serde(default)]
944    pub vertical_align: VerticalAlign,
945    #[serde(default)]
946    pub line_height: Option<f32>,
947    #[serde(default)]
948    pub letter_spacing: Option<f32>,
949    #[serde(default)]
950    pub padding: Option<f32>,
951}
952
953#[derive(Debug, Serialize, Deserialize, JsonSchema)]
954pub struct CaptionWord {
955    pub text: String,
956    pub start: f64,
957    pub end: f64,
958}
959
960#[derive(Debug, Serialize, Deserialize, JsonSchema)]
961#[serde(rename_all = "snake_case")]
962#[derive(Default)]
963pub enum CaptionStyle {
964    #[default]
965    Highlight,
966    Karaoke,
967    WordByWord,
968    /// TikTok-style: only the active word is shown, centered, with a
969    /// spring-like scale-in and a rounded pill background.
970    WordPop,
971    /// Karaoke line (all words visible, wrapped) where the active word
972    /// scales up, takes `active_color` and gets a pill background.
973    KaraokePop,
974}
975
976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
977pub struct GradientBorder {
978    pub colors: Vec<String>,
979    #[serde(default = "default_gradient_border_width")]
980    pub width: f32,
981    #[serde(default)]
982    pub angle: f32,
983}
984
985fn default_gradient_border_width() -> f32 {
986    2.0
987}
988
989/// Inner shadow configuration (inset shadow).
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
991pub struct InnerShadow {
992    pub color: String,
993    #[serde(default)]
994    pub offset_x: f32,
995    #[serde(default)]
996    pub offset_y: f32,
997    #[serde(default = "default_inner_shadow_blur")]
998    pub blur: f32,
999}
1000
1001fn default_inner_shadow_blur() -> f32 {
1002    10.0
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1006pub struct TextShadow {
1007    #[serde(default = "default_shadow_color")]
1008    pub color: String,
1009    #[serde(default = "default_shadow_offset")]
1010    pub offset_x: f32,
1011    #[serde(default = "default_shadow_offset")]
1012    pub offset_y: f32,
1013    #[serde(default = "default_shadow_blur")]
1014    pub blur: f32,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1018pub struct TextBackground {
1019    pub color: String,
1020    #[serde(default = "default_text_bg_padding")]
1021    pub padding: f32,
1022    #[serde(default)]
1023    pub corner_radius: f32,
1024}
1025
1026// --- Visual Effect Types ---
1027
1028/// Glow effect (colored luminous halo around the element)
1029#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1030#[serde(deny_unknown_fields)]
1031pub struct GlowConfig {
1032    /// Glow color (hex string, e.g. "#5C39EE")
1033    #[serde(default = "default_glow_color")]
1034    pub color: String,
1035    /// Blur radius of the glow
1036    #[serde(default = "default_glow_radius")]
1037    pub radius: f32,
1038    /// Intensity multiplier (higher = brighter glow, default 1.0)
1039    #[serde(default = "default_glow_intensity")]
1040    pub intensity: f32,
1041}
1042
1043/// A later label a `text` swaps to, and when.
1044#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1045#[serde(deny_unknown_fields)]
1046pub struct TextState {
1047    /// Time (seconds, scene-local) at which this label takes over.
1048    pub at: f64,
1049    /// The label from `at` onwards.
1050    pub content: String,
1051}
1052
1053/// How a `text` crosses from one of its `states` to the next.
1054///
1055/// Both labels are on screen at once during the window: the outgoing one
1056/// leaves upwards while blurring out, the incoming one arrives from below
1057/// while sharpening. Cutting between them instead reads as a glitch, and
1058/// fading alone reads as two unrelated labels rather than one value changing.
1059#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1060#[serde(deny_unknown_fields)]
1061pub struct TextSwapConfig {
1062    /// How long the crossing takes (seconds).
1063    #[serde(default = "default_swap_duration")]
1064    pub duration: f64,
1065    /// Vertical travel of each label, in px.
1066    #[serde(default = "default_swap_distance")]
1067    pub distance: f32,
1068    /// Peak blur sigma (px) reached by a label at the far end of its travel.
1069    /// `0` gives a pure slide-and-fade.
1070    #[serde(default = "default_swap_blur")]
1071    pub blur: f32,
1072}
1073
1074impl Default for TextSwapConfig {
1075    fn default() -> Self {
1076        Self {
1077            duration: default_swap_duration(),
1078            distance: default_swap_distance(),
1079            blur: default_swap_blur(),
1080        }
1081    }
1082}
1083
1084fn default_swap_duration() -> f64 {
1085    0.45
1086}
1087
1088fn default_swap_distance() -> f32 {
1089    18.0
1090}
1091
1092fn default_swap_blur() -> f32 {
1093    8.0
1094}
1095
1096/// Shape of a text caret.
1097#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
1098#[serde(rename_all = "snake_case")]
1099pub enum CaretShape {
1100    /// A thin vertical rule, like a text editor's insertion point.
1101    #[default]
1102    Line,
1103    /// A filled block covering a character cell, like a terminal.
1104    Block,
1105}
1106
1107/// A caret pinned to a `text`'s reveal head.
1108///
1109/// Only meaningful alongside a `typewriter` animation: the caret follows the
1110/// last revealed character and stops at the end of the line once the reveal
1111/// finishes. Composing a standalone `cursor` component next to the text gets
1112/// you a caret that stays where you put it while the text grows out from
1113/// under it, which is the thing this exists to avoid.
1114#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1115#[serde(deny_unknown_fields)]
1116pub struct CaretConfig {
1117    /// Caret shape.
1118    #[serde(default)]
1119    pub shape: CaretShape,
1120    /// Caret colour (hex). Defaults to the text's own colour, so a caret
1121    /// inherits a theme change without being restated.
1122    #[serde(default)]
1123    pub color: Option<String>,
1124    /// Blink period in seconds — one full off/on cycle. `0` disables the
1125    /// blink and leaves the caret solid.
1126    #[serde(default = "default_caret_blink")]
1127    pub blink: f32,
1128    /// Hide the caret once the reveal has finished, instead of leaving it
1129    /// parked at the end of the text.
1130    #[serde(default)]
1131    pub hide_when_done: bool,
1132}
1133
1134impl Default for CaretConfig {
1135    fn default() -> Self {
1136        Self {
1137            shape: CaretShape::default(),
1138            color: None,
1139            blink: default_caret_blink(),
1140            hide_when_done: false,
1141        }
1142    }
1143}
1144
1145fn default_caret_blink() -> f32 {
1146    1.0
1147}
1148
1149/// A band of light sweeping across the element, restricted to the pixels the
1150/// element actually painted.
1151///
1152/// That restriction is the whole point: painted over the element's *box*, a
1153/// sheen reads as a rectangle sliding past. Painted over the element's own
1154/// alpha, it reads as light catching the glyphs (or the icon, or the chart
1155/// bars) themselves. See `engine::paint_pass`, which composites it with
1156/// `BlendMode::SrcATop` inside the node's own layer.
1157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1158#[serde(deny_unknown_fields)]
1159pub struct ShimmerConfig {
1160    /// Delay before the sweep starts (seconds).
1161    #[serde(default)]
1162    pub delay: f64,
1163    /// How long one sweep takes (seconds).
1164    #[serde(default = "default_shimmer_duration")]
1165    pub duration: f64,
1166    /// Colour of the band (hex).
1167    #[serde(default = "default_shimmer_color")]
1168    pub color: String,
1169    /// Band width as a fraction of the distance it sweeps (0.35 = a band
1170    /// covering roughly a third of the element at any instant). Wider reads
1171    /// as a soft wash, narrower as a hard glint.
1172    #[serde(default = "default_shimmer_width")]
1173    pub width: f32,
1174    /// Peak opacity of the band (0..1).
1175    #[serde(default = "default_shimmer_intensity")]
1176    pub intensity: f32,
1177    /// Lean of the band in degrees. `0` is an upright band sweeping
1178    /// left-to-right; the default 20 tilts it, which is what makes it read
1179    /// as a reflection rather than a wipe. The band always travels
1180    /// perpendicular to itself, so this angles the travel too.
1181    #[serde(default = "default_shimmer_angle")]
1182    pub angle: f32,
1183    /// Repeat the sweep for the rest of the scene.
1184    #[serde(default, rename = "loop")]
1185    pub repeat: bool,
1186}
1187
1188fn default_shimmer_duration() -> f64 {
1189    0.9
1190}
1191
1192fn default_shimmer_color() -> String {
1193    "#FFFFFF".to_string()
1194}
1195
1196fn default_shimmer_width() -> f32 {
1197    0.35
1198}
1199
1200fn default_shimmer_intensity() -> f32 {
1201    0.75
1202}
1203
1204fn default_shimmer_angle() -> f32 {
1205    20.0
1206}
1207
1208fn default_glow_color() -> String {
1209    "#FFFFFF80".to_string()
1210}
1211
1212fn default_glow_radius() -> f32 {
1213    10.0
1214}
1215
1216fn default_glow_intensity() -> f32 {
1217    1.0
1218}
1219
1220// --- Default functions ---
1221
1222fn default_font_size() -> f32 {
1223    48.0
1224}
1225
1226fn default_color() -> String {
1227    "#FFFFFF".to_string()
1228}
1229
1230fn default_font_family() -> String {
1231    "Inter".to_string()
1232}
1233
1234fn default_stroke_width() -> f32 {
1235    2.0
1236}
1237
1238fn default_star_points() -> u32 {
1239    5
1240}
1241
1242fn default_polygon_sides() -> u32 {
1243    6
1244}
1245
1246fn default_shadow_color() -> String {
1247    "#00000080".to_string()
1248}
1249
1250fn default_shadow_offset() -> f32 {
1251    2.0
1252}
1253
1254fn default_shadow_blur() -> f32 {
1255    4.0
1256}
1257
1258fn default_text_bg_padding() -> f32 {
1259    8.0
1260}
1261
1262#[cfg(test)]
1263mod motion_property_tests {
1264    use super::*;
1265    use serde_json::json;
1266
1267    // ---- constat #4: `WiggleConfig.property` / `Animation.property` (via
1268    // `KeyframesConfig.keyframes`) are free strings the solver silently
1269    // no-ops on when unrecognised (RED first). ----
1270
1271    #[test]
1272    fn wiggle_known_property_still_works() {
1273        let json = json!({
1274            "name": "wiggle",
1275            "property": "translate_x",
1276            "amplitude": 10.0,
1277            "frequency": 1.0
1278        });
1279        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1280        match effect {
1281            AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "translate_x"),
1282            other => panic!("expected Wiggle, got {other:?}"),
1283        }
1284    }
1285
1286    #[test]
1287    fn wiggle_unknown_property_is_a_named_error_not_a_silent_no_op() {
1288        // A wholly unsupported name — the animation would otherwise play,
1289        // resolve every frame, and simply never touch any rendered
1290        // property: no error, no visible effect, no signal at all.
1291        let json = json!({
1292            "name": "wiggle",
1293            "property": "skew",
1294            "amplitude": 10.0,
1295            "frequency": 1.0
1296        });
1297        let err = serde_json::from_value::<AnimationEffect>(json)
1298            .expect_err("an unrecognised wiggle property must be rejected, not silently inert");
1299        assert!(err.to_string().contains("skew"), "got: {err}");
1300    }
1301
1302    #[test]
1303    fn wiggle_kebab_case_property_gets_a_did_you_mean() {
1304        // The exact trap named in constat #4: this project mixes kebab-case
1305        // (CSS-style, most of `CssStyle`) and snake_case (these property
1306        // names) conventions across files, so an author reasoning in
1307        // kebab-case naturally writes `translate-x` instead of the
1308        // solver's `translate_x` — silently inert before this fix.
1309        let json = json!({
1310            "name": "wiggle",
1311            "property": "translate-x",
1312            "amplitude": 10.0,
1313            "frequency": 1.0
1314        });
1315        let err = serde_json::from_value::<AnimationEffect>(json)
1316            .expect_err("kebab-case must not silently resolve to a snake_case no-op");
1317        let msg = err.to_string();
1318        assert!(msg.contains("translate-x"), "got: {msg}");
1319        assert!(
1320            msg.contains("translate_x"),
1321            "expected a did-you-mean nudge toward the correct spelling, got: {msg}"
1322        );
1323    }
1324
1325    #[test]
1326    fn keyframes_animation_known_property_still_works() {
1327        let json = json!({
1328            "name": "keyframes",
1329            "keyframes": [
1330                { "property": "opacity", "keyframes": [
1331                    { "time": 0.0, "value": 0.0 },
1332                    { "time": 1.0, "value": 1.0 }
1333                ]}
1334            ]
1335        });
1336        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1337        match effect {
1338            AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "opacity"),
1339            other => panic!("expected Keyframes, got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn keyframes_animation_unknown_property_is_a_named_error() {
1345        let json = json!({
1346            "name": "keyframes",
1347            "keyframes": [
1348                { "property": "positionX", "keyframes": [
1349                    { "time": 0.0, "value": 0.0 },
1350                    { "time": 1.0, "value": 1.0 }
1351                ]}
1352            ]
1353        });
1354        let err = serde_json::from_value::<AnimationEffect>(json).expect_err(
1355            "an unrecognised keyframe animation property must be rejected, not silently inert",
1356        );
1357        assert!(err.to_string().contains("positionX"), "got: {err}");
1358    }
1359
1360    #[test]
1361    fn keyframes_animation_color_property_still_works() {
1362        // "color" is solver-recognised (special-cased in
1363        // `resolve_animations`, outside `apply_property`), not a numeric
1364        // motion property — must not be rejected.
1365        let json = json!({
1366            "name": "keyframes",
1367            "keyframes": [
1368                { "property": "color", "keyframes": [
1369                    { "time": 0.0, "value": "#000000" },
1370                    { "time": 1.0, "value": "#ffffff" }
1371                ]}
1372            ]
1373        });
1374        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1375        assert!(matches!(effect, AnimationEffect::Keyframes(_)));
1376    }
1377}
1378
1379#[cfg(test)]
1380mod motion_path_schema_tests {
1381    use super::*;
1382    use serde_json::json;
1383
1384    #[test]
1385    fn motion_path_deserializes_with_known_fields() {
1386        let json = json!({
1387            "name": "motion_path",
1388            "path": "M0,0 L100,0 L100,100",
1389            "delay": 0.2,
1390            "duration": 1.5,
1391            "loop": true,
1392            "orient": true,
1393            "orient_offset": 90.0,
1394            "easing": "ease_out"
1395        });
1396        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1397        match effect {
1398            AnimationEffect::MotionPath(cfg) => {
1399                assert_eq!(cfg.path, "M0,0 L100,0 L100,100");
1400                assert_eq!(cfg.delay, 0.2);
1401                assert_eq!(cfg.duration, 1.5);
1402                assert!(cfg.repeat);
1403                assert!(cfg.orient);
1404                assert_eq!(cfg.orient_offset, 90.0);
1405                assert_eq!(cfg.easing, EasingType::EaseOut);
1406            }
1407            other => panic!("expected MotionPath, got {other:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn motion_path_defaults_match_documented_values() {
1413        let json = json!({ "name": "motion_path", "path": "M0,0 L10,0" });
1414        let effect: AnimationEffect = serde_json::from_value(json).unwrap();
1415        match effect {
1416            AnimationEffect::MotionPath(cfg) => {
1417                assert_eq!(cfg.delay, 0.0);
1418                assert_eq!(cfg.duration, default_animation_duration());
1419                assert!(!cfg.repeat);
1420                assert!(!cfg.orient);
1421                assert_eq!(cfg.orient_offset, 0.0);
1422                assert_eq!(cfg.easing, EasingType::Linear);
1423            }
1424            other => panic!("expected MotionPath, got {other:?}"),
1425        }
1426    }
1427
1428    // ---- brief's "empty path" degenerate case: rejected at parse time,
1429    // not left to silently produce a no-op or a NaN downstream. ----
1430
1431    #[test]
1432    fn motion_path_rejects_an_empty_path_string() {
1433        let json = json!({ "name": "motion_path", "path": "" });
1434        let err = serde_json::from_value::<AnimationEffect>(json)
1435            .expect_err("an empty motion_path.path must be rejected, not silently accepted");
1436        assert!(err.to_string().contains("no drawable point"), "got: {err}");
1437    }
1438
1439    #[test]
1440    fn motion_path_rejects_unparsable_svg_path_data() {
1441        let json = json!({ "name": "motion_path", "path": "this is not svg path data !!" });
1442        let err = serde_json::from_value::<AnimationEffect>(json)
1443            .expect_err("garbage path data must be rejected, not silently accepted");
1444        assert!(
1445            err.to_string().contains("not valid SVG path data"),
1446            "got: {err}"
1447        );
1448    }
1449
1450    // A single-point ("zero measured length") path is syntactically valid
1451    // and must NOT be rejected at parse time — see MotionPathConfig's
1452    // "Degenerate paths" doc section; the render-time-defined behaviour is
1453    // covered in `engine::animator`'s tests, and the advisory warning in
1454    // `validate_schema.rs`'s.
1455    #[test]
1456    fn motion_path_accepts_a_single_point_path() {
1457        let json = json!({ "name": "motion_path", "path": "M50,50" });
1458        let effect: AnimationEffect = serde_json::from_value(json)
1459            .expect("a syntactically valid single-point path must be accepted");
1460        assert!(matches!(effect, AnimationEffect::MotionPath(_)));
1461    }
1462
1463    #[test]
1464    fn motion_path_rejects_unknown_fields() {
1465        let json = json!({ "name": "motion_path", "path": "M0,0 L10,0", "detla": 0.2 });
1466        let err = serde_json::from_value::<AnimationEffect>(json)
1467            .expect_err("a typo'd field on motion_path must be rejected, not silently ignored");
1468        assert!(err.to_string().contains("detla"), "got: {err}");
1469    }
1470
1471    #[test]
1472    fn motion_path_shift_delay_shifts_the_configs_own_delay() {
1473        let mut effect = AnimationEffect::MotionPath(MotionPathConfig {
1474            path: "M0,0 L10,0".to_string(),
1475            delay: 0.5,
1476            duration: 1.0,
1477            repeat: false,
1478            orient: false,
1479            orient_offset: 0.0,
1480            easing: EasingType::Linear,
1481        });
1482        effect.shift_delay(0.25);
1483        match effect {
1484            AnimationEffect::MotionPath(cfg) => assert!((cfg.delay - 0.75).abs() < 1e-9),
1485            other => panic!("expected MotionPath, got {other:?}"),
1486        }
1487    }
1488}