Skip to main content

rustmotion_core/schema/
background.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::animation::EasingType;
5use super::scenario::default_transition_easing;
6use super::video::GradientType;
7
8/// Scroll direction for animated backgrounds.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum ScrollDirection {
12    Up,
13    Down,
14    Left,
15    Right,
16    UpLeft,
17    UpRight,
18    DownLeft,
19    DownRight,
20    /// Clockwise rotation (gradient_shift only).
21    Cw,
22    /// Counter-clockwise rotation (gradient_shift only).
23    Ccw,
24}
25
26/// Config for the `gradient_shift` preset.
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct GradientShiftConfig {
29    pub colors: Vec<String>,
30    #[serde(default = "default_bg_type")]
31    pub gradient_type: GradientType,
32}
33
34/// Config for the `grid_dots` preset.
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct GridDotsConfig {
37    #[serde(default = "default_grid_dots_color")]
38    pub color: String,
39    #[serde(default = "default_bg_element_size")]
40    pub element_size: f32,
41    #[serde(default = "default_bg_spacing")]
42    pub spacing: f32,
43}
44
45fn default_grid_dots_color() -> String {
46    "#FFFFFF15".to_string()
47}
48
49/// Config for the `grid_lines` preset — a ruled grid, not a dotted one.
50///
51/// `grid_dots` marks the intersections and reads as texture; ruled lines read
52/// as structure, which is what a SaaS/data scene wants behind a chart or a
53/// code panel. Same scroll machinery (`x`/`y`/`speed`/`direction`) as the
54/// other tiled presets.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct GridLinesConfig {
57    /// Line colour (hex, alpha welcome).
58    #[serde(default = "default_grid_lines_color")]
59    pub color: String,
60    /// Cell edge in px. Small reads as graph paper, large as panels.
61    #[serde(default = "default_grid_lines_cell")]
62    pub cell: f32,
63    /// Line thickness in px.
64    #[serde(default = "default_grid_lines_weight")]
65    pub weight: f32,
66    /// Draw every Nth line at `major_weight` instead, for the
67    /// graph-paper look where the coarse grid reads through the fine one.
68    /// `0` (default) means no major lines.
69    #[serde(default)]
70    pub major_every: u32,
71    /// Thickness of a major line.
72    #[serde(default = "default_grid_lines_major_weight")]
73    pub major_weight: f32,
74}
75
76fn default_grid_lines_color() -> String {
77    "#FFFFFF14".to_string()
78}
79
80fn default_grid_lines_cell() -> f32 {
81    72.0
82}
83
84fn default_grid_lines_weight() -> f32 {
85    1.0
86}
87
88fn default_grid_lines_major_weight() -> f32 {
89    2.0
90}
91
92/// Config for the `concentric_circles` preset.
93#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
94pub struct ConcentricCirclesConfig {
95    #[serde(default = "default_concentric_color")]
96    pub color: String,
97    #[serde(default = "default_bg_element_size")]
98    pub element_size: f32,
99    #[serde(default = "default_bg_spacing")]
100    pub spacing: f32,
101    #[serde(default)]
102    pub count: Option<u32>,
103}
104
105fn default_concentric_color() -> String {
106    "#FFFFFF20".to_string()
107}
108
109/// Config for the `halo` preset.
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct HaloConfig {
112    pub zones: Vec<HaloZone>,
113}
114
115/// Config for the `pixel_grid` preset: a lattice of square cells.
116///
117/// Covers two looks with one shape. `density: 1.0` with two colours gives a
118/// true checkerboard (cells alternate by `(row + col)` parity); a density
119/// below 1 with one colour gives the sparse tile field the reference piece
120/// uses — squares on a ground, some cells simply absent.
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122pub struct PixelGridConfig {
123    /// Cell colours. One colour fills every drawn cell; several alternate by
124    /// `(row + col)`, which is what makes a checkerboard rather than a field.
125    #[serde(default = "default_pixel_colors")]
126    pub colors: Vec<String>,
127    /// Edge of a cell in px.
128    #[serde(default = "default_pixel_size")]
129    pub size: f32,
130    /// Lattice pitch in px — the distance between two cell origins. Clamped to
131    /// at least `size`, so cells never overlap; `spacing - size` is the gap.
132    #[serde(default = "default_pixel_spacing")]
133    pub spacing: f32,
134    /// Fraction of cells drawn, 0..1. Which cells is decided by a hash of the
135    /// cell's coordinates, so the pattern is stable from frame to frame — a
136    /// per-frame random would boil.
137    #[serde(default = "default_pixel_density")]
138    pub density: f32,
139    /// Where the field is densest. The reference piece ramps its density
140    /// across the frame rather than scattering uniformly, which is what stops
141    /// the texture reading as noise.
142    #[serde(default)]
143    pub density_ramp: PixelDensityRamp,
144    /// Corner radius of a cell in px. `0` for hard pixels.
145    #[serde(default)]
146    pub radius: f32,
147    /// Stable pattern selector: two backgrounds with the same seed and
148    /// geometry are identical, different seeds are different scatters.
149    #[serde(default = "default_pixel_seed")]
150    pub seed: u32,
151    /// How the field moves. `speed` on the background scales it.
152    #[serde(default)]
153    pub motion: PixelGridMotion,
154}
155
156/// Which way the fill density ramps across the frame.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub enum PixelDensityRamp {
160    /// Uniform: every cell has the same chance of being drawn.
161    #[default]
162    None,
163    Left,
164    Right,
165    Top,
166    Bottom,
167    /// Dense at the centre, thinning outwards.
168    Radial,
169    /// Dense at the frame's edges, thinning toward the centre — a vignette.
170    ///
171    /// Measured on the reference piece, in a band clear of its window, the
172    /// density runs 10.9 · 6.5 · 0.8 · 0.2 · 0.2 · 0.2 · 0.2 · 0.9 · 7.8 · 8.8 %
173    /// across the tenths of the frame: heavy at both edges, effectively empty
174    /// through the middle 60 %. That is what keeps the texture off whatever
175    /// sits in the centre, and `Radial` is its exact inverse.
176    Edges,
177}
178
179/// How a `pixel_grid` animates.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum PixelGridMotion {
183    /// Still. The lattice is a texture, not an effect.
184    #[default]
185    None,
186    /// Cells fade in and out on their own phase.
187    Twinkle,
188    /// A band of extra density travels across the field.
189    Sweep,
190}
191
192fn default_pixel_colors() -> Vec<String> {
193    vec!["#FFFFFF22".to_string()]
194}
195
196fn default_pixel_size() -> f32 {
197    10.0
198}
199
200fn default_pixel_spacing() -> f32 {
201    24.0
202}
203
204fn default_pixel_density() -> f32 {
205    0.6
206}
207
208fn default_pixel_seed() -> u32 {
209    7
210}
211
212/// Config for the `heropattern` preset.
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214pub struct HeropatternConfig {
215    /// Name of the heropattern (e.g. "plus", "topography", "jigsaw").
216    pub pattern: String,
217    #[serde(default = "default_hero_color")]
218    pub color: String,
219    #[serde(default = "default_hero_opacity")]
220    pub opacity: f32,
221    #[serde(default = "default_hero_scale")]
222    pub scale: f32,
223}
224
225fn default_hero_color() -> String {
226    "#FFFFFF".to_string()
227}
228
229fn default_hero_opacity() -> f32 {
230    0.1
231}
232
233fn default_hero_scale() -> f32 {
234    1.0
235}
236
237/// Typed background preset with its config.
238#[derive(Debug, Clone)]
239pub enum BackgroundPreset {
240    GradientShift(GradientShiftConfig),
241    GridDots(GridDotsConfig),
242    GridLines(GridLinesConfig),
243    ConcentricCircles(ConcentricCirclesConfig),
244    Halo(HaloConfig),
245    PixelGrid(PixelGridConfig),
246    Heropattern(HeropatternConfig),
247}
248
249impl BackgroundPreset {
250    pub fn name(&self) -> &'static str {
251        match self {
252            BackgroundPreset::GradientShift(_) => "gradient_shift",
253            BackgroundPreset::GridDots(_) => "grid_dots",
254            BackgroundPreset::GridLines(_) => "grid_lines",
255            BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
256            BackgroundPreset::Halo(_) => "halo",
257            BackgroundPreset::PixelGrid(_) => "pixel_grid",
258            BackgroundPreset::Heropattern(_) => "heropattern",
259        }
260    }
261}
262
263/// Animated background configuration for scenes.
264#[derive(Debug, Clone)]
265pub struct AnimatedBackground {
266    pub preset: BackgroundPreset,
267    /// Horizontal offset (pixels).
268    pub x: f32,
269    /// Vertical offset (pixels).
270    pub y: f32,
271    /// Animation speed (px/sec for tiled presets, deg/sec for gradient_shift).
272    pub speed: f32,
273    /// Scroll direction.
274    pub direction: Option<ScrollDirection>,
275}
276
277impl Serialize for AnimatedBackground {
278    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
279        use serde::ser::SerializeMap;
280        let mut map = serializer.serialize_map(None)?;
281        map.serialize_entry("preset", self.preset.name())?;
282        // Serialize preset-specific config under its name key
283        match &self.preset {
284            BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?,
285            BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?,
286            BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?,
287            BackgroundPreset::ConcentricCircles(cfg) => {
288                map.serialize_entry("concentric_circles", cfg)?
289            }
290            BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
291            BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
292            BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
293        }
294        map.serialize_entry("speed", &self.speed)?;
295        if self.x != 0.0 {
296            map.serialize_entry("x", &self.x)?;
297        }
298        if self.y != 0.0 {
299            map.serialize_entry("y", &self.y)?;
300        }
301        if let Some(ref dir) = self.direction {
302            map.serialize_entry("direction", dir)?;
303        }
304        map.end()
305    }
306}
307
308/// Every preset name the engine actually recognises. A `preset` value
309/// outside this list — including the empty string produced when the key is
310/// missing entirely — is rejected below instead of silently becoming
311/// `gradient_shift` (constat #3, sink 1).
312const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
313    "gradient_shift",
314    "grid_dots",
315    "grid_lines",
316    "concentric_circles",
317    "halo",
318    "pixel_grid",
319    "heropattern",
320];
321
322impl<'de> Deserialize<'de> for AnimatedBackground {
323    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
324        let map: serde_json::Map<String, serde_json::Value> =
325            serde_json::Map::deserialize(deserializer)?;
326
327        // Common fields
328        let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
329        let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
330        // Constat #3 (related sink, fixed alongside): a mistyped `direction`
331        // used to be swallowed by `.ok()` into a silent `None` — same class
332        // as the preset/zones/colors sinks below, just on a smaller field.
333        let direction: Option<ScrollDirection> = match map.get("direction") {
334            Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
335                serde::de::Error::custom(format!("animated-background.direction: {e}"))
336            })?),
337            None => None,
338        };
339
340        let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
341        if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
342            return Err(serde::de::Error::custom(format!(
343                "unknown animated-background preset '{preset_str}': expected one of {}",
344                KNOWN_BACKGROUND_PRESETS.join(", ")
345            )));
346        }
347
348        // Detect new vs legacy format: new format has a sub-object keyed by preset name
349        let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
350
351        let (preset, speed) = if is_new_format {
352            // New format: config in sub-object
353            let sub = map.get(preset_str).unwrap().clone();
354            let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
355            let preset = deserialize_preset_config::<D::Error>(preset_str, sub)?;
356            (preset, speed)
357        } else {
358            // Legacy flat format
359            let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32;
360            let preset = match preset_str {
361                "grid_dots" => {
362                    let color = map
363                        .get("colors")
364                        .and_then(|v| v.as_array())
365                        .and_then(|a| a.first())
366                        .and_then(|v| v.as_str())
367                        .unwrap_or("#FFFFFF15")
368                        .to_string();
369                    let element_size = map
370                        .get("element_size")
371                        .and_then(|v| v.as_f64())
372                        .unwrap_or(4.0) as f32;
373                    let spacing =
374                        map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
375                    BackgroundPreset::GridDots(GridDotsConfig {
376                        color,
377                        element_size,
378                        spacing,
379                    })
380                }
381                "concentric_circles" => {
382                    let color = map
383                        .get("colors")
384                        .and_then(|v| v.as_array())
385                        .and_then(|a| a.first())
386                        .and_then(|v| v.as_str())
387                        .unwrap_or("#FFFFFF20")
388                        .to_string();
389                    let element_size = map
390                        .get("element_size")
391                        .and_then(|v| v.as_f64())
392                        .unwrap_or(4.0) as f32;
393                    let spacing =
394                        map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
395                    let count = map.get("count").and_then(|v| v.as_u64()).map(|n| n as u32);
396                    BackgroundPreset::ConcentricCircles(ConcentricCirclesConfig {
397                        color,
398                        element_size,
399                        spacing,
400                        count,
401                    })
402                }
403                "halo" => {
404                    // Constat #3, sink 2: was `.ok().unwrap_or_default()` —
405                    // a malformed (or entirely missing) `zones` silently
406                    // became an empty halo instead of erroring. Route
407                    // through the same validated-struct path as the
408                    // new-format branch: `HaloConfig::zones` is required
409                    // (no `#[serde(default)]`), so a missing/malformed value
410                    // now produces a real "missing/invalid field zones"
411                    // error instead.
412                    let mut obj = serde_json::Map::new();
413                    if let Some(z) = map.get("zones") {
414                        obj.insert("zones".to_string(), z.clone());
415                    }
416                    let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
417                        .map_err(|e| {
418                            serde::de::Error::custom(format!("animated-background.zones: {e}"))
419                        })?;
420                    BackgroundPreset::Halo(cfg)
421                }
422                "heropattern" => {
423                    // Constat #3 (related sink, fixed alongside): the legacy
424                    // branch never had an arm for `heropattern` at all, so a
425                    // *correctly spelled* `"preset": "heropattern"` written
426                    // in the legacy flat form (no `heropattern: {...}`
427                    // sub-object) fell through the old `_ =>` wildcard and
428                    // silently became `gradient_shift` with `colors: []`.
429                    let mut obj = serde_json::Map::new();
430                    for key in ["pattern", "color", "opacity", "scale"] {
431                        if let Some(v) = map.get(key) {
432                            obj.insert(key.to_string(), v.clone());
433                        }
434                    }
435                    let cfg: HeropatternConfig =
436                        serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
437                            serde::de::Error::custom(format!(
438                                "animated-background.heropattern: {e}"
439                            ))
440                        })?;
441                    BackgroundPreset::Heropattern(cfg)
442                }
443                "gradient_shift" => {
444                    // Constat #3, sink 3: `colors`/`gradient_type` were each
445                    // parsed with `.ok().unwrap_or_default()` /
446                    // `.ok().unwrap_or_else(default_bg_type)` — so even with
447                    // `preset` spelled *correctly*, a missing or malformed
448                    // `colors` silently produced `colors: []`, i.e. a fully
449                    // empty gradient that paints black with no diagnostic at
450                    // all — the exact worst-case symptom the audit names.
451                    let mut obj = serde_json::Map::new();
452                    if let Some(c) = map.get("colors") {
453                        obj.insert("colors".to_string(), c.clone());
454                    }
455                    if let Some(g) = map.get("gradient_type") {
456                        obj.insert("gradient_type".to_string(), g.clone());
457                    }
458                    let cfg: GradientShiftConfig =
459                        serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
460                            serde::de::Error::custom(format!(
461                                "animated-background.colors/gradient_type: {e}"
462                            ))
463                        })?;
464                    BackgroundPreset::GradientShift(cfg)
465                }
466                // Unreachable: `preset_str` was already checked against
467                // `KNOWN_BACKGROUND_PRESETS` above.
468                other => {
469                    return Err(serde::de::Error::custom(format!(
470                        "internal error: unhandled animated-background preset '{other}'"
471                    )))
472                }
473            };
474            (preset, legacy_speed)
475        };
476
477        // Infer legacy direction if not specified
478        let direction = direction.or({
479            if speed > 0.0 && !is_new_format {
480                match &preset {
481                    BackgroundPreset::GradientShift(_) => Some(ScrollDirection::Cw),
482                    BackgroundPreset::GridDots(_) => Some(ScrollDirection::Up),
483                    _ => None,
484                }
485            } else {
486                None
487            }
488        });
489
490        Ok(AnimatedBackground {
491            preset,
492            x,
493            y,
494            speed,
495            direction,
496        })
497    }
498}
499
500/// Deserialize the preset-specific config object for the "new" nested
501/// format (`{"preset": "halo", "halo": {...}}`) — shared by
502/// `AnimatedBackground::deserialize` and available for reuse. `preset_str`
503/// must already be one of [`KNOWN_BACKGROUND_PRESETS`].
504fn deserialize_preset_config<E: serde::de::Error>(
505    preset_str: &str,
506    sub: serde_json::Value,
507) -> Result<BackgroundPreset, E> {
508    match preset_str {
509        "grid_dots" => Ok(BackgroundPreset::GridDots(
510            serde_json::from_value(sub).map_err(E::custom)?,
511        )),
512        "grid_lines" => Ok(BackgroundPreset::GridLines(
513            serde_json::from_value(sub).map_err(E::custom)?,
514        )),
515        "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
516            serde_json::from_value(sub).map_err(E::custom)?,
517        )),
518        "halo" => Ok(BackgroundPreset::Halo(
519            serde_json::from_value(sub).map_err(E::custom)?,
520        )),
521        "pixel_grid" => Ok(BackgroundPreset::PixelGrid(
522            serde_json::from_value(sub).map_err(E::custom)?,
523        )),
524        "heropattern" => Ok(BackgroundPreset::Heropattern(
525            serde_json::from_value(sub).map_err(E::custom)?,
526        )),
527        "gradient_shift" => Ok(BackgroundPreset::GradientShift(
528            serde_json::from_value(sub).map_err(E::custom)?,
529        )),
530        other => Err(E::custom(format!(
531            "internal error: unhandled animated-background preset '{other}'"
532        ))),
533    }
534}
535
536impl JsonSchema for AnimatedBackground {
537    fn schema_name() -> String {
538        "AnimatedBackground".to_string()
539    }
540
541    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
542        use schemars::schema::*;
543
544        let mut props = schemars::Map::new();
545        props.insert("preset".to_string(), gen.subschema_for::<String>());
546        props.insert("x".to_string(), gen.subschema_for::<f32>());
547        props.insert("y".to_string(), gen.subschema_for::<f32>());
548        props.insert("speed".to_string(), gen.subschema_for::<f32>());
549        props.insert(
550            "direction".to_string(),
551            gen.subschema_for::<Option<ScrollDirection>>(),
552        );
553        props.insert(
554            "gradient_shift".to_string(),
555            gen.subschema_for::<Option<GradientShiftConfig>>(),
556        );
557        props.insert(
558            "grid_dots".to_string(),
559            gen.subschema_for::<Option<GridDotsConfig>>(),
560        );
561        props.insert(
562            "grid_lines".to_string(),
563            gen.subschema_for::<Option<GridLinesConfig>>(),
564        );
565        props.insert(
566            "concentric_circles".to_string(),
567            gen.subschema_for::<Option<ConcentricCirclesConfig>>(),
568        );
569        props.insert(
570            "halo".to_string(),
571            gen.subschema_for::<Option<HaloConfig>>(),
572        );
573        props.insert(
574            "heropattern".to_string(),
575            gen.subschema_for::<Option<HeropatternConfig>>(),
576        );
577
578        SchemaObject {
579            instance_type: Some(InstanceType::Object.into()),
580            object: Some(Box::new(ObjectValidation {
581                properties: props,
582                required: ["preset".to_string()].into_iter().collect(),
583                ..Default::default()
584            })),
585            ..Default::default()
586        }
587        .into()
588    }
589}
590
591/// A single glow zone for the "halo" animated-background preset.
592#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
593pub struct HaloZone {
594    /// Zone color (hex string). May itself carry an alpha channel
595    /// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine.
596    pub color: String,
597    /// X position as a fraction of the surface the halo is painted on:
598    /// the viewport in a `slide` view, the world the camera travels in a
599    /// `world` view (`WorldTimeline::world_extent`). 0.0 = left, 1.0 = right.
600    #[serde(default = "default_half")]
601    pub x: f32,
602    /// Y position as a fraction of that same surface. 0.0 = top, 1.0 = bottom.
603    #[serde(default = "default_half")]
604    pub y: f32,
605    /// Radius as a fraction of that surface's `max(width, height)` — so the
606    /// same value covers proportionally the same area whichever view it is in.
607    #[serde(default = "default_halo_radius")]
608    pub radius: f32,
609    /// Zone opacity, multiplied with any alpha already encoded in `color`.
610    ///
611    /// Default `1.0` is a true no-op: it leaves `color`'s own alpha (opaque
612    /// or hex-encoded) untouched, so scenarios written before this field
613    /// existed — including ones that hid alpha inside the hex string, e.g.
614    /// `#1E3A8A55` — keep rendering identically. Values are clamped to
615    /// `0.0..=1.0`.
616    #[serde(default = "default_halo_opacity")]
617    pub opacity: f32,
618}
619
620/// Transition configuration for background interpolation between scenes.
621#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
622pub struct BackgroundTransition {
623    pub duration: f64,
624    #[serde(default = "default_transition_easing")]
625    pub easing: EasingType,
626}
627
628/// A background entry with optional template reference and transition.
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct BackgroundEntry {
631    #[serde(rename = "$ref", default)]
632    pub template_ref: Option<String>,
633    #[serde(default)]
634    pub transition: Option<BackgroundTransition>,
635    #[serde(flatten)]
636    pub overrides: serde_json::Map<String, serde_json::Value>,
637}
638
639/// Constat #5: no derived `JsonSchema` here (the `#[serde(flatten)]` map
640/// makes a fully-accurate derive impossible anyway — the point of `flatten`
641/// is "any other keys"), which is exactly why `Scene`/`View` reached for
642/// `#[schemars(skip)]` on `background` in the first place: skip was the
643/// only option with no `JsonSchema` impl to call. But `Scene`/`View` are
644/// also `deny_unknown_fields` (schemars emits `additionalProperties: false`
645/// for that), so skipping `background` didn't just leave it undocumented —
646/// it made the *exported schema* declare invalid any scenario that actually
647/// sets `scene.background` / `view.background`, which is most of them. This
648/// manual impl describes the real accepted shape (`$ref` + `transition` +
649/// "anything else", matching the `flatten`) so `background` can be a real
650/// declared property instead.
651impl JsonSchema for BackgroundEntry {
652    fn schema_name() -> String {
653        "BackgroundEntry".to_string()
654    }
655
656    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
657        use schemars::schema::*;
658
659        let mut props = schemars::Map::new();
660        props.insert("$ref".to_string(), gen.subschema_for::<Option<String>>());
661        props.insert(
662            "transition".to_string(),
663            gen.subschema_for::<Option<BackgroundTransition>>(),
664        );
665
666        SchemaObject {
667            instance_type: Some(InstanceType::Object.into()),
668            object: Some(Box::new(ObjectValidation {
669                properties: props,
670                // Mirrors `#[serde(flatten)] overrides: serde_json::Map<..>`:
671                // any other key (the preset config, `x`/`y`/`speed`/...) is
672                // genuinely accepted, not a schema gap to close.
673                additional_properties: Some(Box::new(Schema::Bool(true))),
674                ..Default::default()
675            })),
676            ..Default::default()
677        }
678        .into()
679    }
680}
681
682/// The unified background field: color string, single entry, or multiple entries.
683#[derive(Debug, Clone)]
684pub enum BackgroundValue {
685    Color(String),
686    Single(BackgroundEntry),
687    Multiple(Vec<BackgroundEntry>),
688}
689
690impl Serialize for BackgroundValue {
691    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
692    where
693        S: serde::Serializer,
694    {
695        match self {
696            BackgroundValue::Color(s) => serializer.serialize_str(s),
697            BackgroundValue::Single(entry) => entry.serialize(serializer),
698            BackgroundValue::Multiple(entries) => entries.serialize(serializer),
699        }
700    }
701}
702
703/// See [`BackgroundEntry`]'s `JsonSchema` impl doc comment — same reason:
704/// `deserialize_background_value` is a hand-written `deserialize_with`, not
705/// a derive, so there is no schema for schemars to infer without this.
706impl JsonSchema for BackgroundValue {
707    fn schema_name() -> String {
708        "BackgroundValue".to_string()
709    }
710
711    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
712        use schemars::schema::*;
713
714        let string_schema = gen.subschema_for::<String>();
715        let entry_schema = gen.subschema_for::<BackgroundEntry>();
716        let array_schema: Schema = SchemaObject {
717            instance_type: Some(InstanceType::Array.into()),
718            array: Some(Box::new(ArrayValidation {
719                items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))),
720                ..Default::default()
721            })),
722            ..Default::default()
723        }
724        .into();
725
726        SchemaObject {
727            subschemas: Some(Box::new(SubschemaValidation {
728                one_of: Some(vec![string_schema, entry_schema, array_schema]),
729                ..Default::default()
730            })),
731            ..Default::default()
732        }
733        .into()
734    }
735}
736
737/// Resolved background after template expansion — ready for rendering.
738#[derive(Debug, Clone, Default, Serialize)]
739pub struct ResolvedBackground {
740    pub color: Option<String>,
741    pub animated: Vec<AnimatedBackground>,
742    /// Transition for interpolation from the previous scene's background.
743    pub transition: Option<BackgroundTransition>,
744}
745
746fn default_half() -> f32 {
747    0.5
748}
749
750fn default_halo_radius() -> f32 {
751    0.4
752}
753
754fn default_halo_opacity() -> f32 {
755    1.0
756}
757
758fn default_bg_element_size() -> f32 {
759    4.0
760}
761
762fn default_bg_spacing() -> f32 {
763    60.0
764}
765
766#[allow(dead_code)]
767fn default_bg_speed() -> f32 {
768    30.0
769}
770
771fn default_bg_type() -> GradientType {
772    GradientType::Linear
773}
774
775/// Deserialize `animated-background` as either a single AnimatedBackground or a Vec.
776pub(crate) fn deserialize_animated_backgrounds<'de, D>(
777    deserializer: D,
778) -> Result<Vec<AnimatedBackground>, D::Error>
779where
780    D: serde::Deserializer<'de>,
781{
782    use serde::de;
783
784    struct OneOrMany;
785
786    impl<'de> de::Visitor<'de> for OneOrMany {
787        type Value = Vec<AnimatedBackground>;
788
789        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
790            f.write_str("a single animated background or an array of animated backgrounds")
791        }
792
793        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
794        where
795            A: de::SeqAccess<'de>,
796        {
797            Vec::deserialize(de::value::SeqAccessDeserializer::new(seq))
798        }
799
800        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
801        where
802            M: de::MapAccess<'de>,
803        {
804            let bg = AnimatedBackground::deserialize(de::value::MapAccessDeserializer::new(map))?;
805            Ok(vec![bg])
806        }
807    }
808
809    deserializer.deserialize_any(OneOrMany)
810}
811
812/// Deserialize `background` as a color string, a single BackgroundEntry object, or an array.
813pub(crate) fn deserialize_background_value<'de, D>(
814    deserializer: D,
815) -> Result<Option<BackgroundValue>, D::Error>
816where
817    D: serde::Deserializer<'de>,
818{
819    use serde::de;
820
821    struct BgVisitor;
822
823    impl<'de> de::Visitor<'de> for BgVisitor {
824        type Value = Option<BackgroundValue>;
825
826        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
827            f.write_str("a color string, a background object, or an array of background objects")
828        }
829
830        fn visit_none<E>(self) -> Result<Self::Value, E>
831        where
832            E: de::Error,
833        {
834            Ok(None)
835        }
836
837        fn visit_unit<E>(self) -> Result<Self::Value, E>
838        where
839            E: de::Error,
840        {
841            Ok(None)
842        }
843
844        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
845        where
846            E: de::Error,
847        {
848            Ok(Some(BackgroundValue::Color(v.to_string())))
849        }
850
851        fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
852        where
853            E: de::Error,
854        {
855            Ok(Some(BackgroundValue::Color(v)))
856        }
857
858        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
859        where
860            M: de::MapAccess<'de>,
861        {
862            let entry = BackgroundEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
863            Ok(Some(BackgroundValue::Single(entry)))
864        }
865
866        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
867        where
868            A: de::SeqAccess<'de>,
869        {
870            let entries =
871                Vec::<BackgroundEntry>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
872            Ok(Some(BackgroundValue::Multiple(entries)))
873        }
874    }
875
876    deserializer.deserialize_any(BgVisitor)
877}
878
879#[cfg(test)]
880mod halo_zone_opacity_tests {
881    use super::*;
882
883    #[test]
884    fn halo_zone_opacity_defaults_to_1_when_omitted() {
885        let zone: HaloZone =
886            serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap();
887        assert_eq!(zone.opacity, 1.0);
888    }
889
890    #[test]
891    fn halo_zone_opacity_respects_explicit_value() {
892        let zone: HaloZone = serde_json::from_value(serde_json::json!({
893            "color": "#1E3A8A",
894            "opacity": 0.35
895        }))
896        .unwrap();
897        assert_eq!(zone.opacity, 0.35);
898    }
899
900    #[test]
901    fn halo_zone_serializes_opacity() {
902        let zone = HaloZone {
903            color: "#1E3A8A".to_string(),
904            x: 0.5,
905            y: 0.5,
906            radius: 0.4,
907            opacity: 0.6,
908        };
909        let v = serde_json::to_value(&zone).unwrap();
910        // Compare as f64 with a tolerance: 0.6f32 widened to f64 is
911        // 0.6000000238418579, not exactly 0.6 — an f32 precision artifact,
912        // not a bug in the field itself.
913        let got = v["opacity"]
914            .as_f64()
915            .expect("opacity must serialize as a number");
916        assert!((got - 0.6).abs() < 1e-6, "got {got}");
917    }
918
919    #[test]
920    fn animated_background_new_format_halo_zone_defaults_opacity() {
921        // New nested format: {"preset":"halo","halo":{"zones":[...]}}
922        let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
923            "preset": "halo",
924            "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] },
925            "speed": 0
926        }))
927        .unwrap();
928        match bg.preset {
929            BackgroundPreset::Halo(cfg) => {
930                assert_eq!(cfg.zones.len(), 1);
931                assert_eq!(cfg.zones[0].opacity, 1.0);
932                // Alpha-in-hex is untouched by the schema layer — it stays in `color`.
933                assert_eq!(cfg.zones[0].color, "#1E3A8A55");
934            }
935            _ => panic!("expected Halo preset"),
936        }
937    }
938
939    #[test]
940    fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() {
941        // Legacy flat format: {"preset":"halo","zones":[...]} with no sub-object.
942        let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
943            "preset": "halo",
944            "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }]
945        }))
946        .unwrap();
947        match bg.preset {
948            BackgroundPreset::Halo(cfg) => {
949                assert_eq!(cfg.zones[0].opacity, 1.0);
950            }
951            _ => panic!("expected Halo preset"),
952        }
953    }
954}
955
956/// Constat #3: `AnimatedBackground::deserialize` had (at least) three silent
957/// sinks — an unknown `preset` name silently became `gradient_shift` with
958/// `colors: []`; a malformed/mistyped `zones` array in the legacy `halo`
959/// form silently emptied via `.ok().unwrap_or_default()`; and a
960/// malformed/missing `colors` (or `gradient_type`) on the legacy
961/// `gradient_shift` form did the exact same `.ok().unwrap_or_default()`
962/// silent-empty even when `preset` was spelled *correctly* — which is the
963/// worst-case symptom named in the audit: an entirely black video with zero
964/// diagnostics, because an empty-colors gradient paints black. Also found
965/// (and fixed alongside, same root cause: the legacy branch's `_ =>`
966/// wildcard): a *correctly spelled* `"heropattern"` preset written in the
967/// legacy flat form (no `heropattern: {...}` sub-object) silently fell
968/// through to `gradient_shift` too, because the legacy match only had
969/// explicit arms for `grid_dots`/`concentric_circles`/`halo`.
970#[cfg(test)]
971mod animated_background_silent_sink_tests {
972    use super::*;
973    use serde_json::json;
974
975    #[test]
976    fn known_preset_gradient_shift_still_works() {
977        let bg: AnimatedBackground = serde_json::from_value(json!({
978            "preset": "gradient_shift",
979            "colors": ["#111111", "#222222"],
980            "gradient_type": "radial",
981            "speed": 10
982        }))
983        .unwrap();
984        match bg.preset {
985            BackgroundPreset::GradientShift(cfg) => {
986                assert_eq!(cfg.colors, vec!["#111111", "#222222"]);
987                assert!(matches!(cfg.gradient_type, GradientType::Radial));
988            }
989            other => panic!("expected GradientShift, got {other:?}"),
990        }
991    }
992
993    #[test]
994    fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() {
995        let err = serde_json::from_value::<AnimatedBackground>(json!({
996            "preset": "starfield",
997            "speed": 10
998        }))
999        .expect_err("an unknown preset must be rejected, not silently treated as gradient_shift");
1000        let msg = err.to_string();
1001        assert!(
1002            msg.contains("starfield"),
1003            "error must name the offending preset value, got: {msg}"
1004        );
1005    }
1006
1007    #[test]
1008    fn missing_preset_key_is_a_named_error() {
1009        let err = serde_json::from_value::<AnimatedBackground>(json!({ "speed": 10 }))
1010            .expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []");
1011        assert!(
1012            err.to_string().to_lowercase().contains("preset"),
1013            "got: {err}"
1014        );
1015    }
1016
1017    #[test]
1018    fn legacy_halo_zones_still_work() {
1019        let bg: AnimatedBackground = serde_json::from_value(json!({
1020            "preset": "halo",
1021            "zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }]
1022        }))
1023        .unwrap();
1024        match bg.preset {
1025            BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1),
1026            other => panic!("expected Halo, got {other:?}"),
1027        }
1028    }
1029
1030    #[test]
1031    fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() {
1032        let err = serde_json::from_value::<AnimatedBackground>(json!({
1033            "preset": "halo",
1034            "zones": [{ "color": "#1E3A8A", "x": "not-a-number" }]
1035        }))
1036        .expect_err("a malformed zones entry must be rejected, not silently emptied");
1037        assert!(
1038            err.to_string().contains("zones") || err.to_string().contains("x"),
1039            "error should point at the offending field, got: {err}"
1040        );
1041    }
1042
1043    #[test]
1044    fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() {
1045        let err = serde_json::from_value::<AnimatedBackground>(json!({ "preset": "halo" }))
1046            .expect_err("missing zones must be rejected, not silently treated as an empty halo");
1047        assert!(err.to_string().contains("zones"), "got: {err}");
1048    }
1049
1050    #[test]
1051    fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() {
1052        // This is the exact worst-case symptom the audit names: preset is
1053        // spelled *correctly*, but colors is missing/malformed -> silently
1054        // empty colors -> a fully transparent gradient that paints black,
1055        // with no diagnostic at all.
1056        let err = serde_json::from_value::<AnimatedBackground>(json!({
1057            "preset": "gradient_shift",
1058            "speed": 5
1059        }))
1060        .expect_err("missing colors must error, not silently produce an empty (black) gradient");
1061        assert!(err.to_string().contains("colors"), "got: {err}");
1062    }
1063
1064    #[test]
1065    fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() {
1066        let bg: AnimatedBackground = serde_json::from_value(json!({
1067            "preset": "heropattern",
1068            "pattern": "plus",
1069            "color": "#ffffff",
1070            "opacity": 0.2,
1071            "scale": 1.5
1072        }))
1073        .unwrap();
1074        match bg.preset {
1075            BackgroundPreset::Heropattern(cfg) => {
1076                assert_eq!(cfg.pattern, "plus");
1077                assert_eq!(cfg.scale, 1.5);
1078            }
1079            other => panic!("expected Heropattern, got {other:?}"),
1080        }
1081    }
1082
1083    #[test]
1084    fn legacy_heropattern_missing_pattern_is_a_named_error() {
1085        let err = serde_json::from_value::<AnimatedBackground>(json!({
1086            "preset": "heropattern"
1087        }))
1088        .expect_err("heropattern with no pattern name must error");
1089        assert!(err.to_string().contains("pattern"), "got: {err}");
1090    }
1091
1092    #[test]
1093    fn direction_typo_is_a_named_error_not_a_silently_dropped_none() {
1094        let err = serde_json::from_value::<AnimatedBackground>(json!({
1095            "preset": "grid_dots",
1096            "colors": ["#fff"],
1097            "direction": "diagonal"
1098        }))
1099        .expect_err("an unrecognised direction must be rejected, not silently dropped to None");
1100        assert!(err.to_string().contains("direction"), "got: {err}");
1101    }
1102
1103    #[test]
1104    fn direction_still_works_when_valid() {
1105        let bg: AnimatedBackground = serde_json::from_value(json!({
1106            "preset": "grid_dots",
1107            "colors": ["#fff"],
1108            "direction": "up"
1109        }))
1110        .unwrap();
1111        assert!(matches!(bg.direction, Some(ScrollDirection::Up)));
1112    }
1113}