From 17e1e188ff8f900a15d39da36c141c6aaf587e65 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 9 Aug 2026 02:24:52 +0200 Subject: [PATCH] fix(animation): make the animation knobs actually reach the solver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight confirmed audit findings. Six of them are dead knobs: a field the author sets, the schema accepts, `validate` calls clean, and the engine ignores. Nobody gets an error; the video simply lacks the animation that was asked for. For a tool driven by generated JSON that is the worst failure mode there is, because the correction loop never closes. - `float_3d`'s `amplitude` never reached `PresetConfig`: `AnimationTiming` had no such field and the only converter wrote `None`. Every `float_3d` moved by the 12px default, so the documented parallax recipe produced no parallax. Measured: 60 requested, -12 delivered. - `pulse` / `float` / `shake` / `spin` built keyframes at the literal times 0.0 / 0.25 / 0.5 / 1.0 and never read `delay` or `duration`. With `delay: 1.0`, all four were already mid-animation at t=0.5s. - `"loop": true` was inert on keyframe effects and `tilt_in`: the resolver was handed `None` for the preset config, fell back to `repeat: false`, and never called `loop_time`. - `--strict-anim` resolved effects at global scene time while the engine resolves at remapped local time, so it flagged violations at instants that are never rendered — and missed real ones. It now reads the `time_params` the builder already computes. - The completion budget added `start_at` to `delay + duration`, which the engine does not do: since PR #27 `start_at` gates visibility only and `delay` is absolute scene time. A 1s animation at `start_at: 1.5` in a 2s scene was reported as overrunning. - Two keyframe animations on the same property summed or overwrote each other depending on whether one carried a `delay` — a field with nothing to do with composition, routing effects into two separately-resolved buckets. Now a single bucket with one rule: last declared wins, the CSS cascade rule, which was already the behaviour within a bucket. - The spring solver returned NaN for `mass: 0` or `stiffness: 0` and diverged on negative damping, with no validation anywhere. A NaN reaching layout contaminates the whole tree. Both ends are handled: the solver floors its inputs, and `validate` now rejects the configs outright. - Unknown keys inside `style.animation[*]` were never reported. That last one has a deliberate consequence worth stating: `deny_unknown_fields` on the nine effect-config structs means a typo now fails deserialization, and `deserialize_children` skips a child it cannot parse. A misspelled key stops producing a default-valued animation and starts removing the component, with a stderr warning. That is the same contract `CssStyle` has carried all along — it is why `margin-top` drops a component — so this extends an existing policy rather than inventing one, and `validate` catches it first. Tests: full workspace green on this branch alone. --- .../rustmotion-cli/src/commands/geometry.rs | 78 ++- .../src/commands/validate_attrs.rs | 61 ++ .../src/commands/validate_schema.rs | 213 ++++++- crates/rustmotion-core/src/engine/animator.rs | 556 ++++++++++++++++-- crates/rustmotion-core/src/schema/video.rs | 28 +- 5 files changed, 864 insertions(+), 72 deletions(-) diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index ddf85fd..9978e66 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -1182,6 +1182,7 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec resolve_props_for_effects(&effects, time, scene_duration), + Some(effects) => resolve_props_for_effects(&effects, local_time, scene_duration), None => AnimatedProperties::default(), }; let raw_bbox = bbox_of(layout); @@ -1307,6 +1327,7 @@ fn walk_anim( &box_node.children, layouts, stagger_delays, + time_params, viewport, vi, si, @@ -1899,6 +1920,61 @@ mod tests { assert_eq!(v.unwrap().axis, Axis::X); } + #[test] + fn strict_anim_respects_a_containers_time_offset_remap() { + // Constat #3: `walk_anim` used to resolve effects at raw *global* + // scene time, ignoring any `time_scale`/`time_offset` remap + // accumulated from ancestor containers — even though the renderer + // (`box_builder::build_child`) always resolves at the *local* + // remapped time (`t_local = scale * t_global + shift`). + // + // Here the shape's `slide_in_left` (delay=0, duration=1.0) sits + // inside a `flex` with `time_offset: -5.0`, which (per + // `rustmotion/src/tests.rs`'s time-remap tests) shifts local time to + // `t_local = t_global + 5.0`. Every sample in this 2s scene + // (t_global in [0, 2]) therefore resolves at local time in [5, 7] — + // 5-7s past the 1s animation window, fully settled at rest (x=100, + // well inside the 1920px-wide viewport). A walker that ignores the + // remap instead resolves at raw t_global in [0, 2], still inside the + // animation's own [0, 1] window for the first half of the scene, + // and reports a slide-in overflow that never actually happens at + // render time. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "children": [{ + "type": "flex", + "time_offset": -5.0, + "style": { "width": "1920px", "height": "1080px" }, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 0, "duration": 1.0 }] + }, + "fill": "#ff0000" + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + let overflow: Vec<_> = violations + .iter() + .filter(|v| v.kind == ViolationKind::AnimatedTextOverflow) + .collect(); + assert!( + overflow.is_empty(), + "time_offset=-5.0 settles the slide-in 5-7s before any sampled instant; a \ + walker that honours the remap must report zero overflows, got: {:?}", + overflow + ); + } + #[test] fn strict_anim_start_at_and_effect_delay_do_not_double_shift_the_timeline() { // A component with BOTH `start_at` (visibility gate) and a matching diff --git a/crates/rustmotion-cli/src/commands/validate_attrs.rs b/crates/rustmotion-cli/src/commands/validate_attrs.rs index 16d5d26..ce85c47 100644 --- a/crates/rustmotion-cli/src/commands/validate_attrs.rs +++ b/crates/rustmotion-cli/src/commands/validate_attrs.rs @@ -351,4 +351,65 @@ mod tests { assert_eq!(errors.len(), 1, "expected one error: {errors:?}"); assert!(errors[0].contains("counter"), "got: {}", errors[0]); } + + #[test] + fn typo_inside_style_animation_effect_is_reported() { + // Constat #8: `walk_component` only compares a component's own + // top-level keys, then recurses into `children` — it never looks + // inside `style`, let alone `style.animation[*]`. A typo'd field on + // an animation effect (`duratoin` instead of `duration`) used to + // deserialize silently (the effect config structs had no + // `deny_unknown_fields`), so the author got a scenario that "worked" + // but quietly ran the default 0.8s duration instead of theirs. + // + // The fix lives in `schema/video.rs` (adding `deny_unknown_fields` to + // every `AnimationEffect` payload struct) rather than here: an + // internally-tagged enum's tag field is excluded from what the + // variant's own `Deserialize` sees, so this rejects the typo without + // ever flagging the legitimate `name` tag as unknown. That routes the + // typo through the *existing* typed-parse-failure path in + // `check_component_attrs` (the same one that already catches, e.g., + // a missing required field) — it surfaces as a blocking error, not a + // `walk_component` warning. + let s = resolved(serde_json::json!([ + { + "type": "text", "content": "hi", + "style": { "animation": [{ "name": "fade_in_up", "duratoin": 0.6 }] } + } + ])); + let (errors, _) = check_component_attrs(&s); + assert!( + errors.iter().any(|e| e.contains("duratoin")), + "expected the typo'd animation-effect field to be reported as an error: {errors:?}" + ); + } + + #[test] + fn well_formed_animation_effect_fields_are_not_flagged() { + // Sanity companion to the typo test: legitimate fields across a + // spread of effect kinds (preset timing, keyframes, wiggle, glow, + // motion_blur, tilt_in) must not trip the new deny_unknown_fields. + let s = resolved(serde_json::json!([ + { + "type": "text", "content": "hi", + "style": { "animation": [ + { "name": "fade_in_up", "delay": 0.2, "duration": 0.6, "loop": false, + "overshoot": 0.1, "spring": { "damping": 12, "stiffness": 100, "mass": 1 } }, + { "name": "float_3d", "duration": 1.0, "amplitude": 20 }, + { "name": "tilt_in", "delay": 0.0, "duration": 0.4, "rotate_x": 10.0 }, + { "name": "wiggle", "property": "translate_y", "amplitude": 5, "frequency": 2, "seed": 3 }, + { "name": "glow", "color": "#ffffff", "radius": 10, "intensity": 1.0 }, + { "name": "motion_blur", "samples": 4, "shutter": 0.5 }, + { "name": "keyframes", "keyframes": [ + { "property": "opacity", "keyframes": [ + { "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 } + ] } + ], "delay": 0.0, "duration": 1.0, "loop": true } + ] } + } + ])); + let (errors, warnings) = check_component_attrs(&s); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + } } diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 5b116b1..6b30b5b 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -5,7 +5,7 @@ use rustmotion::components::{ChildComponent, Component}; use rustmotion::core::css::style::{ Background, BackgroundLayer, Color, CssStyle, Display as CssDisplay, }; -use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario}; +use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario, SpringConfig}; pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) { let mut errors = Vec::new(); @@ -123,27 +123,52 @@ fn validate_children( } // Animation completion budget check: ensure entrance animations finish within the scene. + // + // Constat #4: `start_at` is a *visibility* window, not a time + // origin — the engine resolves `animation.delay`/`duration` in + // absolute scene time regardless of `start_at` (PR #27's frozen + // semantics; `geometry.rs`'s `walk_anim` already states and relies + // on the same rule). The budget used to add `start_at` in here, + // which contradicts that: a scenario where the entrance genuinely + // finishes well inside the scene (just before the node becomes + // visible, so it appears already-settled) was rejected as if the + // animation ran late. if let Some(anim) = child.component.as_animatable() { - let start_at = child - .component - .as_timed() - .and_then(|t| t.timing().0) - .unwrap_or(0.0); - for effect in anim.animation_effects() { if let Some((delay, duration)) = entrance_budget(effect) { - let finishes_at = start_at + delay + duration; + let finishes_at = delay + duration; // 50ms tolerance for floating-point edge cases. if finishes_at > scene_duration + 0.05 { let suggested = ((finishes_at + 0.5) * 10.0).ceil() / 10.0; errors.push(format!( - "{}: animation finishes at {:.2}s (start_at {:.2} + delay {:.2} + duration {:.2}) \ + "{}: animation finishes at {:.2}s (delay {:.2} + duration {:.2}) \ but scene_duration is {:.2}s — it will be truncated. \ Increase scene duration to at least {:.1}s or reduce animation delay/duration.", - p, finishes_at, start_at, delay, duration, scene_duration, suggested + p, finishes_at, delay, duration, scene_duration, suggested )); } } + + // Constat #6: `SpringConfig` accepts any f64 unchecked — a + // preset's own `spring` override (`AnimationTiming::spring`, + // reachable via `as_preset()`) or a `keyframes` effect's + // per-`Animation` `spring` (used when that segment's easing + // is `spring`) both feed `engine::animator::spring_value`, + // where `mass <= 0`/`stiffness <= 0` produce NaN and negative + // `damping` diverges. Reject both regimes here so a bad + // config never reaches the solver. + if let Some((_, timing)) = effect.as_preset() { + if let Some(spring) = &timing.spring { + check_spring_config(spring, &p, errors); + } + } + if let AnimationEffect::Keyframes(k) = effect { + for kf_anim in &k.keyframes { + if let Some(spring) = &kf_anim.spring { + check_spring_config(spring, &p, errors); + } + } + } } } @@ -347,6 +372,36 @@ fn check_color_str(s: &str, label: &str, path: &str, errors: &mut Vec) { } } +/// Constat #6: reject `SpringConfig` values that would make +/// `engine::animator::spring_value` produce NaN (`mass <= 0`, `stiffness <= +/// 0`) or diverge instead of settle (`damping < 0`). The solver itself also +/// floors these defensively (belt and suspenders — see `spring_value`'s doc +/// comment), but catching it here gives the author an actionable error +/// instead of a silently broken render. +fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec) { + if spring.mass <= 0.0 { + errors.push(format!( + "{path}: spring.mass must be > 0 (got {}) — zero or negative mass makes the spring \ + solver divide by zero and produce NaN", + spring.mass + )); + } + if spring.stiffness <= 0.0 { + errors.push(format!( + "{path}: spring.stiffness must be > 0 (got {}) — zero or negative stiffness makes \ + the spring solver produce NaN", + spring.stiffness + )); + } + if spring.damping < 0.0 { + errors.push(format!( + "{path}: spring.damping must be >= 0 (got {}) — negative damping makes the spring \ + diverge instead of settle", + spring.damping + )); + } +} + /// The `time_scale` declared on a container component, if any. fn container_time_scale(component: &Component) -> Option { match component { @@ -554,6 +609,113 @@ mod style_warning_tests { ); } + #[test] + fn negative_spring_damping_is_an_error() { + // Constat #6: damping < 0 makes the spring solver diverge instead of + // settle (a `SpringConfig` accepts any f64 — nothing in + // `rustmotion-cli` checked `damping`/`stiffness` before this). + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": -5, "stiffness": 100, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("damping")), + "missing spring damping error: {errors:?}" + ); + } + + #[test] + fn zero_spring_stiffness_is_an_error() { + // stiffness <= 0 makes `spring_value`'s omega = sqrt(stiffness/mass) NaN. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "bounce_in", "duration": 0.6, "spring": { "damping": 10, "stiffness": 0, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("stiffness")), + "missing spring stiffness error: {errors:?}" + ); + } + + #[test] + fn zero_spring_mass_is_an_error() { + // mass <= 0 makes omega = sqrt(stiffness/mass) divide by zero -> NaN. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 10, "stiffness": 100, "mass": 0 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("mass")), + "missing spring mass error: {errors:?}" + ); + } + + #[test] + fn spring_inside_a_keyframes_effect_is_also_checked() { + // Springs aren't only on presets: a `keyframes` effect's per-Animation + // `spring` field feeds the exact same solver. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ + "name": "keyframes", + "keyframes": [{ + "property": "scale", + "easing": "spring", + "spring": { "damping": -1, "stiffness": 100, "mass": 1 }, + "keyframes": [{ "time": 0.0, "value": 0.0 }, { "time": 1.0, "value": 1.0 }] + }] + }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().any(|e| e.contains("damping")), + "missing spring damping error inside a keyframes effect: {errors:?}" + ); + } + + #[test] + fn positive_spring_values_are_accepted() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "text", + "content": "hi", + "style": { + "animation": [{ "name": "fade_in_up", "duration": 0.6, "spring": { "damping": 15, "stiffness": 100, "mass": 1 } }] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + } + #[test] fn positive_time_scale_is_accepted() { let child: ChildComponent = serde_json::from_value(serde_json::json!({ @@ -568,6 +730,37 @@ mod style_warning_tests { validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); } + + #[test] + fn completion_budget_does_not_add_start_at_to_delay_plus_duration() { + // Constat #4: `start_at` gates *visibility* only (PR #27) — the + // engine resolves `animation.delay`/`duration` in absolute scene + // time regardless of `start_at`, so an entrance that finishes at + // delay+duration=1.0s in a 2.0s scene is fine even if the node isn't + // visible until start_at=1.5s (it simply appears already-settled). + // The old formula added them (`start_at + delay + duration` = + // 1.5+0+1.0 = 2.5 > 2.0), rejecting this valid scenario. + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "start_at": 1.5, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 0.0, "duration": 1.0 }] + }, + "fill": "#ff0000" + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 2.0, &mut errors, &mut warnings); + assert!( + errors.iter().all(|e| !e.contains("animation finishes")), + "start_at must not be added to the completion budget: {errors:?}" + ); + } } /// C2 completion (issue #110 / #102): an unresolved colour must fail diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index a23148e..fb07e20 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -43,8 +43,23 @@ pub struct ResolvedCharAnimation { /// Extracted and categorized animation effects from an AnimationEffect slice. pub struct ExtractedEffects<'a> { pub presets: Vec<(AnimationPreset, PresetConfig)>, - pub keyframes: Vec<&'a Animation>, - pub owned_keyframes: Vec, + /// Every `keyframes`/`tilt_in` effect's animations, in the order their + /// source effects appear in `style.animation` (constat #5: this used to + /// be split into two buckets — routed purely by whether the effect's + /// `delay` happened to be nonzero — resolved and merged separately, + /// which made "sum vs last-wins" on a shared property depend on that + /// unrelated field. Now there is one bucket, resolved in one + /// `resolve_animations` call, so the composition rule is always + /// "last effect in the array wins on a shared property" — a CSS-cascade + /// rule, independent of `delay`). + pub keyframe_animations: Vec, + /// True when any contributing `keyframes`/`tilt_in` effect requested + /// `"loop": true` (constat #7). Applied uniformly to the whole + /// `keyframe_animations` bucket — see the doc comment on + /// `resolve_props_for_effects` for the same caveat presets already have + /// (multiple effects with different loop settings on the same property + /// is an unsupported edge case, not new to this fix). + pub keyframes_loop: bool, pub wiggles: Vec<&'a WiggleConfig>, pub orbits: Vec<&'a OrbitConfig>, pub glow: Option<&'a GlowConfig>, @@ -73,8 +88,8 @@ pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> { pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { let mut result = ExtractedEffects { presets: Vec::new(), - keyframes: Vec::new(), - owned_keyframes: Vec::new(), + keyframe_animations: Vec::new(), + keyframes_loop: false, wiggles: Vec::new(), orbits: Vec::new(), glow: None, @@ -122,24 +137,23 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { result.orbits.push(config); } AnimationEffect::Keyframes(config) => { - if config.delay.abs() > 1e-9 { - // Keyframe times are absolute scene seconds; the - // config-level delay shifts them (it used to be - // silently ignored, breaking timeline/stagger shifts - // on keyframes effects). - result - .owned_keyframes - .extend(config.keyframes.iter().map(|anim| { - let mut a = anim.clone(); - for kf in &mut a.keyframes { - kf.time += config.delay; - } - a - })); - } else { - for kf in &config.keyframes { - result.keyframes.push(kf); - } + // Keyframe times are absolute scene seconds; the + // config-level delay shifts them (applied unconditionally + // — a no-op when `delay == 0` — so every `keyframes` + // effect lands in the same bucket regardless of its + // delay; see the `ExtractedEffects::keyframe_animations` + // doc comment for why that used to matter). + result + .keyframe_animations + .extend(config.keyframes.iter().map(|anim| { + let mut a = anim.clone(); + for kf in &mut a.keyframes { + kf.time += config.delay; + } + a + })); + if config.repeat { + result.keyframes_loop = true; } } AnimationEffect::TiltIn(config) => { @@ -149,7 +163,7 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { let ry = config.rotate_y.unwrap_or(-15.0); let persp = config.perspective.unwrap_or(1000.0); let sc = config.scale_from.unwrap_or(0.9); - result.owned_keyframes.extend([ + result.keyframe_animations.extend([ kf_anim( "opacity", delay, @@ -163,6 +177,9 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { kf_anim("perspective", delay, persp, end, persp, EasingType::Linear), kf_anim("scale", delay, sc, end, 1.0, EasingType::EaseOutCubic), ]); + if config.repeat { + result.keyframes_loop = true; + } } AnimationEffect::MotionBlur(config) => { result.motion_blur = Some(config.intensity); @@ -322,10 +339,22 @@ fn ease_in_out_cubic(t: f64) -> f64 { /// Solve spring animation at time t (seconds). /// Returns a value between 0.0 and 1.0 representing progress. +/// +/// Constat #6: `SpringConfig` accepts any `f64` (it's schema-level, not +/// range-checked at parse time), and `rustmotion validate` used to check +/// nothing about it either. `mass <= 0` or `stiffness <= 0` fed straight into +/// `sqrt`/division below produced NaN (sqrt of a negative/undefined ratio, +/// or division by zero), and negative `damping` flipped the decay +/// exponent's sign so the "settling" oscillation diverged to +-infinity +/// instead. Either poisons every transform/opacity value downstream once it +/// merges into `AnimatedProperties`. `validate_schema.rs` now rejects these +/// combinations as errors (belt), and this floor keeps the solver itself +/// finite and bounded even if an out-of-band caller skips validation +/// (suspenders) — see `spring_robustness_tests` below. pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { - let damping = config.damping; - let stiffness = config.stiffness; - let mass = config.mass; + let damping = config.damping.max(0.0); + let stiffness = config.stiffness.max(1e-6); + let mass = config.mass.max(1e-6); let omega = (stiffness / mass).sqrt(); let zeta = damping / (2.0 * (stiffness * mass).sqrt()); @@ -534,15 +563,27 @@ pub fn resolve_props_for_effects( let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration); props.merge(&p); } - // owned_keyframes (generated by TiltIn etc.) are merged first so that explicit - // user keyframes take priority via the multiplicative merge() semantics. - if !extracted.owned_keyframes.is_empty() { - let kp = resolve_animations(&extracted.owned_keyframes, None, None, time, scene_duration); - props.merge(&kp); - } - if !extracted.keyframes.is_empty() { - let kf: Vec = extracted.keyframes.iter().copied().cloned().collect(); - let kp = resolve_animations(&kf, None, None, time, scene_duration); + // Every `keyframes`/`tilt_in` effect is resolved together in one call + // (constat #5): within a single `resolve_animations` call, multiple + // `Animation`s targeting the same property are applied in list order via + // `apply_property` (assignment, not addition), so the *last* effect in + // `style.animation` wins on a shared property — deterministic, and + // independent of any effect's `delay`. `keyframes_loop` (constat #7) + // carries `"loop": true` from any contributing effect into the solver, + // which `resolve_animations` used to never see (it was always called + // with `preset_config = None`, i.e. `repeat = false`). + if !extracted.keyframe_animations.is_empty() { + let loop_cfg = PresetConfig { + repeat: extracted.keyframes_loop, + ..Default::default() + }; + let kp = resolve_animations( + &extracted.keyframe_animations, + None, + Some(&loop_cfg), + time, + scene_duration, + ); props.merge(&kp); } if !extracted.wiggles.is_empty() { @@ -1283,16 +1324,34 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim_loop("scale", 0.95, 1.05)], - AnimationPreset::Float => vec![kf_anim_3kf( + // `delay`/`duration` used to be decorative here: the keyframes were + // pinned to literal times 0.0/0.25/0.5/1.0 regardless of what the + // scenario authored (constat #2), so every pulsing/floating/shaking/ + // spinning element in a scene shared one hardcoded 1-second cycle + // starting at t=0. `delay` now shifts the cycle's start and + // `duration` sets its length, exactly like every other preset. + AnimationPreset::Pulse => vec![kf_anim_3kf_over( + "scale", + delay, + end, + 0.95, + 1.05, + 0.95, + EasingType::EaseInOut, + )], + AnimationPreset::Float => vec![kf_anim_3kf_over( "position.y", + delay, + end, 0.0, -10.0, 0.0, EasingType::EaseInOut, )], - AnimationPreset::Shake => vec![kf_anim_4kf( + AnimationPreset::Shake => vec![kf_anim_4kf_over( "position.x", + delay, + end, 0.0, 10.0, -10.0, @@ -1301,9 +1360,9 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim( "rotation", + delay, 0.0, - 0.0, - 1.0, + end, 360.0, EasingType::Linear, )], @@ -1546,40 +1605,34 @@ fn kf_anim_3kf_over( } } -fn kf_anim_3kf(property: &str, v0: f64, v1: f64, v2: f64, easing: EasingType) -> Animation { - Animation { - property: property.to_string(), - keyframes: vec![kf(0.0, v0), kf(0.5, v1), kf(1.0, v2)], - easing, - spring: None, - } -} - -fn kf_anim_4kf( +/// Four-keyframe oscillation (quarter/half/end split) laid out over an +/// explicit `start..end` window — the `shake` counterpart to +/// `kf_anim_3kf_over`. +#[allow(clippy::too_many_arguments)] +fn kf_anim_4kf_over( property: &str, + start: f64, + end: f64, v0: f64, v1: f64, v2: f64, v3: f64, easing: EasingType, ) -> Animation { + let quarter = (end - start) / 4.0; Animation { property: property.to_string(), - keyframes: vec![kf(0.0, v0), kf(0.25, v1), kf(0.5, v2), kf(1.0, v3)], + keyframes: vec![ + kf(start, v0), + kf(start + quarter, v1), + kf(start + quarter * 2.0, v2), + kf(end, v3), + ], easing, spring: None, } } -fn kf_anim_loop(property: &str, min: f64, max: f64) -> Animation { - Animation { - property: property.to_string(), - keyframes: vec![kf(0.0, min), kf(0.5, max), kf(1.0, min)], - easing: EasingType::EaseInOut, - spring: None, - } -} - #[cfg(test)] mod spring_preset_tests { //! TDD tests for issue #88: spring easing on any preset via @@ -1824,3 +1877,386 @@ mod glow_tests { ); } } + +#[cfg(test)] +mod float3d_amplitude_tests { + //! Constat #1: `PresetConfig::amplitude` is read by `expand_preset_inner` + //! (`config.amplitude.unwrap_or(12.0)`) but `AnimationTiming::to_preset_config` + //! used to hardcode `amplitude: None`, so any author-supplied amplitude on + //! a `float_3d` effect never reached the solver — every element bobbed by + //! the same hardcoded 12px regardless of what was authored. + use super::*; + use crate::schema::AnimationEffect; + + /// Peak absolute `translate_y` reached while sampling densely across one + /// cycle — proxy for the oscillation's amplitude actually resolved. + fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 { + let mut peak = 0.0f64; + let steps = 200; + for i in 0..=steps { + let t = window * i as f64 / steps as f64; + let y = resolve_props_for_effects(effects, t, window + 1.0).translate_y as f64; + if y.abs() > peak.abs() { + peak = y; + } + } + peak + } + + #[test] + fn author_supplied_amplitude_reaches_the_solver() { + // Parsed from raw JSON, not built in Rust — proves the value survives + // serde all the way to the resolver, not merely that the struct has a + // field for it. + let default_fx: AnimationEffect = + serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap(); + let big_fx: AnimationEffect = + serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0, "amplitude": 60 }"#) + .unwrap(); + + let default_peak = peak_translate_y(&[default_fx], 1.0); + let big_peak = peak_translate_y(&[big_fx], 1.0); + + assert!( + (default_peak.abs() - 12.0).abs() < 0.5, + "default float_3d amplitude must stay ~12px, got {default_peak}" + ); + assert!( + big_peak.abs() > 50.0, + "amplitude=60 must reach the solver (peak translate_y near 60px), got {big_peak} \ + (default was {default_peak})" + ); + } +} + +#[cfg(test)] +mod continuous_preset_timing_tests { + //! Constat #2: `pulse` / `float` / `shake` / `spin` used to fabricate + //! keyframes at literal times 0.0/0.25/0.5/1.0, ignoring `config.delay` + //! and `config.duration` entirely — every element sharing one of these + //! presets moved in lockstep on a fixed 1-second cycle no matter what the + //! scenario authored. + use super::*; + use crate::schema::AnimationEffect; + + fn timing(delay: f64, duration: f64) -> AnimationTimingFixture { + AnimationTimingFixture { delay, duration } + } + + /// Minimal JSON round-trip helper — keeps every case going through serde, + /// like the author's JSON would. + struct AnimationTimingFixture { + delay: f64, + duration: f64, + } + + impl AnimationTimingFixture { + fn json(&self, name: &str) -> String { + format!( + r#"{{ "name": "{}", "delay": {}, "duration": {} }}"#, + name, self.delay, self.duration + ) + } + } + + #[test] + fn pulse_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap(); + // Before its delay, the cycle has not started: the resolver clamps to + // the first keyframe's value (the 0.95 trough) at every pre-delay + // instant — it must be identical at two different pre-delay times, + // not moving. Before the fix, delay/duration were ignored and the + // preset ran its own literal 0..1s cycle regardless, so t=0.1 and + // t=0.9 fell in different oscillation phases and disagreed. + let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64; + let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64; + assert!( + (early - late).abs() < 1e-6, + "pulse must be frozen before its delay=1.0 (not yet oscillating): \ + t=0.1 -> {early}, t=0.9 -> {late}" + ); + assert!( + (early - 0.95).abs() < 0.01, + "pulse before its delay must clamp to the first keyframe (0.95), got {early}" + ); + // At the midpoint of its cycle (delay + duration/2 = 2.0): near the peak (1.05). + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64; + assert!( + mid > 1.03, + "pulse at t=2.0 (cycle midpoint) must be near peak scale ~1.05, got {mid}" + ); + } + + #[test] + fn float_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("float")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_y as f64; + assert!( + before.abs() < 0.1, + "float at t=0.5 (before delay=1.0) must be at rest y=0, got {before}" + ); + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).translate_y as f64; + assert!( + mid < -8.0, + "float at t=2.0 (cycle midpoint) must be near peak y=-10, got {mid}" + ); + } + + #[test] + fn shake_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("shake")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).translate_x as f64; + assert!( + before.abs() < 0.1, + "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}" + ); + // Quarter point of the cycle (delay + duration/4 = 1.5): near +10 peak. + let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64; + assert!( + quarter > 8.0, + "shake at t=1.5 (cycle quarter) must be near peak x=+10, got {quarter}" + ); + } + + #[test] + fn spin_honours_delay_and_duration() { + let t = timing(1.0, 2.0); + let fx: AnimationEffect = serde_json::from_str(&t.json("spin")).unwrap(); + let before = + resolve_props_for_effects(std::slice::from_ref(&fx), 0.5, 10.0).rotation as f64; + assert!( + before.abs() < 0.1, + "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}" + ); + // Halfway through its own cycle (delay + duration/2 = 2.0): ~180deg. + let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64; + assert!( + (mid - 180.0).abs() < 5.0, + "spin at t=2.0 (cycle midpoint) must be near 180deg, got {mid}" + ); + } +} + +#[cfg(test)] +mod keyframes_composition_tests { + //! Constat #5: two `keyframes` effects targeting the same property used + //! to be routed into one of two buckets purely by whether `delay != 0` + //! (`owned_keyframes` vs `keyframes` in `extract_effects`), each bucket + //! resolved by its own `resolve_animations` call and combined via + //! `AnimatedProperties::merge` — which *sums* additive properties like + //! `translate_x` across buckets, while two effects landing in the *same* + //! bucket instead overwrite (last one in the list wins, since + //! `apply_property` assigns rather than adds). So the composition rule + //! depended entirely on an incidental field (`delay`) with no relation to + //! authoring intent. + //! + //! Chosen semantic: every `keyframes`/`tilt_in` effect is resolved + //! together in one `resolve_animations` call, in the order the effects + //! appear in `style.animation` — like a CSS cascade, the *last* effect + //! in the array wins on a shared property. This is deterministic and + //! independent of `delay`. + use super::*; + use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; + + /// A `keyframes` effect with one property ramping `0 -> value` over + /// `[0, 1]` (pre-shift), then shifted by `delay`. + fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect { + AnimationEffect::Keyframes(KeyframesConfig { + keyframes: vec![Animation { + property: property.to_string(), + keyframes: vec![ + Keyframe { + time: 0.0, + value: KeyframeValue::Number(0.0), + easing: None, + }, + Keyframe { + time: 1.0, + value: KeyframeValue::Number(value), + easing: None, + }, + ], + easing: EasingType::Linear, + spring: None, + }], + delay, + duration: 0.8, + repeat: false, + }) + } + + #[test] + fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() { + // Case 1: A (delay=0) declared first, B (delay=0.5) declared second. + let a1 = ramp("translate_x", 100.0, 0.0); + let b1 = ramp("translate_x", 40.0, 0.5); + let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64; + let b1_alone = resolve_props_for_effects(&[b1], 1.0, 5.0).translate_x as f64; + assert!( + (combined_1 - b1_alone).abs() < 1e-4, + "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}" + ); + + // Case 2: swap which one carries the delay, keep declaration order + // (A first, B second) — the outcome must be identical in shape: B + // (still last) wins alone, this time using B's own (now delay=0) + // timing. + let a2 = ramp("translate_x", 100.0, 0.5); + let b2 = ramp("translate_x", 40.0, 0.0); + let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64; + let b2_alone = resolve_props_for_effects(&[b2], 1.0, 5.0).translate_x as f64; + assert!( + (combined_2 - b2_alone).abs() < 1e-4, + "B (declared last) must alone determine translate_x at t=1.0 even with delay swapped: \ + combined={combined_2}, B-alone={b2_alone}" + ); + + // The two cases must NOT collapse to the same number (sanity check + // that this test isn't vacuous — B's own resolved value genuinely + // differs between the two delay assignments). + assert!( + (combined_1 - combined_2).abs() > 1.0, + "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}" + ); + } +} + +#[cfg(test)] +mod keyframes_loop_tests { + //! Constat #7: `"loop": true` on a `keyframes` effect or on `tilt_in` + //! never reached the solver. `resolve_props_for_effects` always called + //! `resolve_animations(&kfs, None, None, ...)` for both keyframe buckets + //! — passing `preset_config = None` means `resolve_animations` falls back + //! to `PresetConfig::default()`, whose `repeat` is `false`, so + //! `loop_time` was never invoked no matter what `KeyframesConfig::repeat` + //! / `TiltInConfig::repeat` said. + use super::*; + use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; + + #[test] + fn keyframes_loop_true_wraps_time_past_the_last_keyframe() { + let looping = AnimationEffect::Keyframes(KeyframesConfig { + keyframes: vec![Animation { + property: "opacity".to_string(), + keyframes: vec![ + Keyframe { + time: 0.0, + value: KeyframeValue::Number(0.0), + easing: None, + }, + Keyframe { + time: 1.0, + value: KeyframeValue::Number(1.0), + easing: None, + }, + ], + easing: EasingType::Linear, + spring: None, + }], + delay: 0.0, + duration: 0.8, + repeat: true, + }); + // t=2.5 is past the keyframe's own last time (1.0). Without looping, + // the resolver clamps to the last keyframe's value (1.0) forever. + // With looping (start=0, end=1, duration=1), t=2.5 wraps to 0.5 -> + // opacity should be ~0.5, not 1.0. + let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64; + assert!( + (opacity - 0.5).abs() < 0.05, + "looping keyframes at t=2.5 must wrap to local t=0.5 (opacity ~0.5), got {opacity}" + ); + } + + #[test] + fn tilt_in_loop_true_keeps_tilting_past_its_settle_time() { + let looping_tilt: AnimationEffect = serde_json::from_str( + r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4, "loop": true }"#, + ) + .unwrap(); + let settled: AnimationEffect = + serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#) + .unwrap(); + + // Well past the settle time (0.4s): without loop, scale is pinned at + // the final resting value (1.0). With loop (cycle 0..0.4), t=1.0 + // wraps to local t=0.2 (t=1.0 % 0.4 = 0.2), mid-tilt, scale != 1.0. + let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64; + let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64; + + assert!( + (settled_scale - 1.0).abs() < 1e-3, + "non-looping tilt_in at t=1.0 (past settle) must be resting at scale 1.0, got {settled_scale}" + ); + assert!( + (looping_scale - 1.0).abs() > 0.01, + "looping tilt_in at t=1.0 must still be mid-cycle (scale != 1.0 rest), got {looping_scale}" + ); + } +} + +#[cfg(test)] +mod spring_robustness_tests { + //! Constat #6: `spring_value` fed `mass`/`stiffness`/`damping` straight + //! into `sqrt`/division with no floor, so `mass <= 0` or `stiffness <= 0` + //! produced NaN (division by zero or sqrt of a negative number), and + //! negative `damping` flipped the decay exponent's sign, diverging to + //! +-infinity instead of settling. A NaN/inf progress value then flows + //! into transform math (translate/scale) and contaminates the whole + //! subtree it touches. + use super::*; + + #[test] + fn zero_mass_does_not_produce_nan() { + let config = SpringConfig { + damping: 10.0, + stiffness: 100.0, + mass: 0.0, + }; + for i in 0..=20 { + let t = i as f64 * 0.25; + let v = spring_value(t, &config); + assert!( + v.is_finite(), + "spring_value(t={t}) with mass=0 must be finite, got {v}" + ); + } + } + + #[test] + fn zero_stiffness_does_not_produce_nan() { + let config = SpringConfig { + damping: 10.0, + stiffness: 0.0, + mass: 1.0, + }; + for i in 0..=20 { + let t = i as f64 * 0.25; + let v = spring_value(t, &config); + assert!( + v.is_finite(), + "spring_value(t={t}) with stiffness=0 must be finite, got {v}" + ); + } + } + + #[test] + fn negative_damping_stays_bounded_instead_of_diverging() { + let config = SpringConfig { + damping: -20.0, + stiffness: 100.0, + mass: 1.0, + }; + let v_at_5s = spring_value(5.0, &config); + assert!( + v_at_5s.is_finite() && v_at_5s.abs() < 100.0, + "spring_value(t=5.0) with damping=-20 must stay bounded (finite and reasonably \ + small), got {v_at_5s} — negative damping must not diverge to +-infinity" + ); + } +} diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 9c4acc0..01819ba 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -173,7 +173,17 @@ impl AnimationEffect { } /// Timing configuration for preset animations. +// `deny_unknown_fields` (constat #8): this is the `AnimationTiming` payload +// of an internally-tagged `AnimationEffect` variant (`#[serde(tag = "name")]` +// on the enum). Serde's tagged-enum deserializer buffers the object and +// re-drives it through the variant's own `Deserialize` impl *without* the +// `name` tag key, so `deny_unknown_fields` here rejects a typo'd field (e.g. +// `duratoin`) without ever seeing/rejecting `name` itself — verified with a +// minimal repro before relying on it. Without this, `validate_attrs.rs` +// never sees inside `style.animation[*]` (it only walks component-level +// keys), so a typo silently no-ops instead of erroring. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct AnimationTiming { /// Delay before animation starts (seconds). #[serde(default)] @@ -193,6 +203,13 @@ pub struct AnimationTiming { /// this overrides them. #[serde(default)] pub spring: Option, + /// Travel of an oscillating preset, in pixels (`float_3d` only; default + /// 12). Threaded through `to_preset_config` into `PresetConfig::amplitude`, + /// which `expand_preset_inner` already reads — this field is what makes + /// an author-supplied amplitude actually reach it instead of the + /// hardcoded default on every element. + #[serde(default)] + pub amplitude: Option, } fn default_animation_duration() -> f64 { @@ -201,6 +218,7 @@ fn default_animation_duration() -> f64 { /// Configuration for the `tilt_in` animation with configurable 3D transform values. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct TiltInConfig { #[serde(default)] pub delay: f64, @@ -230,12 +248,14 @@ impl Default for AnimationTiming { repeat: false, overshoot: None, spring: None, + amplitude: None, } } } /// Timing configuration for char animation effect variants (used inside style.animation). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct CharAnimationTiming { /// Delay before animation starts (seconds). #[serde(default)] @@ -347,7 +367,7 @@ impl AnimationTiming { /// Convert to PresetConfig for compatibility with resolve_animations. pub fn to_preset_config(&self) -> PresetConfig { PresetConfig { - amplitude: None, + amplitude: self.amplitude, delay: self.delay, duration: self.duration, repeat: self.repeat, @@ -359,6 +379,7 @@ impl AnimationTiming { /// Custom keyframe animations configuration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct KeyframesConfig { pub keyframes: Vec, #[serde(default)] @@ -379,6 +400,7 @@ pub struct KeyframesConfig { /// `samples = 1` is the degenerate case: the single ghost falls at `t - 0` and /// superimposes exactly on the principal → visually equivalent to no blur. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct MotionBlurConfig { /// Reserved for future intensity scaling (currently unused by the ghost /// sampler — the `samples` parameter controls quality). Kept for schema @@ -409,6 +431,7 @@ fn default_motion_blur_shutter() -> f64 { /// `base_opacity * falloff^i`; the principal is unchanged. Unlike motion blur, /// the trail is additive: the principal retains its full opacity. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct TrailConfig { /// Number of trailing ghost copies (default 4, clamped 1..=12). #[serde(default = "default_trail_copies")] @@ -437,6 +460,7 @@ fn default_trail_falloff() -> f32 { /// Configuration for a 3D orbit/floating animation effect. /// Creates circular or elliptical motion with pseudo-depth (scale + opacity modulation). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct OrbitConfig { /// Horizontal radius of the orbit in pixels. #[serde(default = "default_orbit_radius")] @@ -477,6 +501,7 @@ fn default_orbit_depth() -> f64 { // --- Wiggle Config --- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct WiggleConfig { pub property: String, pub amplitude: f64, @@ -667,6 +692,7 @@ pub struct TextBackground { /// Glow effect (colored luminous halo around the element) #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] pub struct GlowConfig { /// Glow color (hex string, e.g. "#5C39EE") #[serde(default = "default_glow_color")]