Skip to main content

rustmotion_core/schema/
scenario.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use std::collections::HashMap;
5
6use super::animation::EasingType;
7use super::background::{
8    deserialize_animated_backgrounds, deserialize_background_value, AnimatedBackground,
9    BackgroundValue, ResolvedBackground,
10};
11use super::shake::SceneShake;
12use super::style::{CardAlign, CardDirection, CardJustify};
13use super::time::TimePoint;
14
15/// Definition of a variable in a structural component.
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct VariableDefinition {
18    #[serde(rename = "type")]
19    pub var_type: VariableType,
20    pub default: serde_json::Value,
21    /// Optional description for documentation/schema.
22    #[serde(default)]
23    pub description: Option<String>,
24}
25
26/// Supported variable types.
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum VariableType {
30    String,
31    Number,
32    Boolean,
33    Object,
34    Array,
35}
36
37/// One entry of [`Scenario::components`] — a named, reusable template.
38///
39/// Schema-only twin of `rustmotion_core::expand::ComponentDefinition`
40/// (private to that module — this type exists so `components` has a real,
41/// documented shape in the exported schema; see [`Scenario::components`]'s
42/// doc for why it can't just reuse that private struct, and why this one is
43/// never populated in practice).
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct ComponentTemplateDef {
47    /// Parameters this template accepts. Same shape as
48    /// [`ComponentTemplateParam`] — *not* [`VariableDefinition`] (the
49    /// scenario-level `config` entry shape): a template parameter's
50    /// `default` is itself optional, and omitting it is what makes the
51    /// parameter required at every `use` site. `config`'s `default` has no
52    /// such omission story — it is always required there.
53    #[serde(default)]
54    pub params: HashMap<String, ComponentTemplateParam>,
55    /// The subtree to instantiate: a single component object, or an array
56    /// of sibling component objects (a fragment spliced in place). May
57    /// itself contain nested `for-each`/`use` directives.
58    pub template: serde_json::Value,
59}
60
61/// One parameter declared by a [`ComponentTemplateDef`]. See
62/// [`ComponentTemplateDef::params`] for how this differs from
63/// [`VariableDefinition`].
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct ComponentTemplateParam {
67    #[serde(rename = "type")]
68    pub param_type: VariableType,
69    /// Omitting this makes the parameter required at every `use` site.
70    #[serde(default)]
71    pub default: Option<serde_json::Value>,
72    #[serde(default)]
73    pub description: Option<String>,
74}
75
76/// Deliberately **not** `#[derive(Deserialize)]` — see
77/// [`ScenarioDe`]/`impl From<ScenarioDe> for Scenario` just below for why:
78/// this type needs a post-deserialize pass (propagating `bpm`/`beat_offset`/
79/// `timing`/`snap` down onto every reachable [`Scene`]) that a plain derive
80/// can't express, and every call site that builds a `Scenario` from JSON
81/// (`loader.rs`, `include.rs`'s own included-file loading) is outside this
82/// workstream's owned files this wave — the propagation has to happen
83/// *inside* `Scenario::deserialize` itself so those callers need no changes.
84#[derive(Debug, Serialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct Scenario {
87    #[serde(default = "default_version")]
88    pub version: String,
89    pub video: VideoConfig,
90    /// File-based tracks (`"audio": [...]`, unchanged since before issue
91    /// #331) or, for a scenario that synthesises its own soundtrack, a
92    /// single object carrying both (`"audio": {"tracks": [...], "voices":
93    /// {...}, "score": [...], "master": {...}}`) — see [`AudioValue`].
94    #[serde(default)]
95    pub audio: AudioValue,
96    #[serde(default)]
97    pub fonts: Vec<FontEntry>,
98    #[serde(default, deserialize_with = "deserialize_scene_entries")]
99    pub scenes: Vec<SceneEntry>,
100    /// Composition: a sequence of views (slide or world). Mutually exclusive with top-level `scenes`.
101    #[serde(default)]
102    pub composition: Option<Vec<View>>,
103    /// Config definitions for structural components. Each config entry has a type and default value.
104    #[serde(default)]
105    pub config: Option<HashMap<String, VariableDefinition>>,
106    /// Named background templates that scenes can reference via `$ref`.
107    #[serde(default)]
108    pub backgrounds: HashMap<String, serde_json::Value>,
109    /// Reusable component template definitions — `"components": { "name": {
110    /// "params": {...}, "template": {...} } }`, instantiated from a
111    /// `children` entry via `{"use": "name", "props": {...}}` (see
112    /// [`ComponentTemplateDef`]). Documented in `CLAUDE.md`'s
113    /// "Factorisation" section.
114    ///
115    /// This field exists so the exported JSON Schema (`rustmotion schema`)
116    /// declares the shape the engine actually accepts — it is **not** how
117    /// `components` is consumed at runtime. `rustmotion_core::expand::
118    /// expand_directives` (a sibling workstream's file, outside this one's
119    /// scope) resolves every `for-each`/`use` against this block and then
120    /// *removes the key entirely*, before this struct is ever deserialized
121    /// — the same way `variables::apply_variables` consumes `config`'s
122    /// `$name` placeholders without `config` disappearing from the struct.
123    /// The practical difference: `config` stays meaningful after expansion
124    /// (`Scenario::config` is read elsewhere), while `components` is spent
125    /// in full during expansion, so this field is always empty by the time
126    /// any code outside `schema/` could read it.
127    #[serde(default)]
128    pub components: HashMap<String, ComponentTemplateDef>,
129    /// Studio feedback annotations. Persisted in the scenario but never read by
130    /// the renderer or the geometry validator. Skipped on serialization when empty.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub annotations: Vec<Annotation>,
133    /// Beats per minute for this scenario's beat grid (issue #336). `None`
134    /// (default) means no grid exists: any [`TimePoint`] beat (`b`) unit
135    /// then fails to resolve with `unresolved_beat_unit` (schema:
136    /// [`crate::schema::TimeError::NoBpm`]).
137    #[serde(default)]
138    pub bpm: Option<f64>,
139    /// Where beat 0 sits on the scenario's absolute timeline, in seconds.
140    /// The grid is `beat_offset + n * 60 / bpm` — not decoration: a reel
141    /// whose first beat lands at 2.2s anchors its grid there, not at 0.
142    #[serde(default)]
143    pub beat_offset: f64,
144    /// Optional override of the scenario's total rendered duration, in
145    /// seconds. Reserved for downstream consumers (audio/export tooling);
146    /// the frame-task scheduler in `rustmotion`'s `encode` module does not
147    /// read it — under `timing: "v2"` the total is always `at_last +
148    /// duration_last`, computed from the scenes themselves.
149    #[serde(default)]
150    pub duration: Option<f64>,
151    /// Scene-placement semantics. See [`TimingMode`].
152    #[serde(default)]
153    pub timing: TimingMode,
154    /// Rounds resolved times onto a grid before scheduling. See [`SnapMode`].
155    #[serde(default)]
156    pub snap: Option<SnapMode>,
157    /// Scenario-level variables (issue #329): a scalar declared once,
158    /// animated on the scenario's absolute timeline, and readable from any
159    /// expression in any scene as `$name`. Visible everywhere; shadowed by
160    /// a scene's own [`Scene::vars`] of the same name. See
161    /// `rustmotion_core::vars` for the resolver and the `Scope`
162    /// implementation that reads this.
163    #[serde(default)]
164    pub vars: crate::vars::VarSet,
165}
166
167#[derive(Debug, Deserialize)]
168#[serde(deny_unknown_fields)]
169struct ScenarioDe {
170    #[serde(default = "default_version")]
171    version: String,
172    video: VideoConfig,
173    #[serde(default)]
174    audio: AudioValue,
175    #[serde(default)]
176    fonts: Vec<FontEntry>,
177    #[serde(default, deserialize_with = "deserialize_scene_entries")]
178    scenes: Vec<SceneEntry>,
179    #[serde(default)]
180    composition: Option<Vec<View>>,
181    #[serde(default)]
182    config: Option<HashMap<String, VariableDefinition>>,
183    #[serde(default)]
184    backgrounds: HashMap<String, serde_json::Value>,
185    #[serde(default)]
186    components: HashMap<String, ComponentTemplateDef>,
187    #[serde(default)]
188    annotations: Vec<Annotation>,
189    #[serde(default)]
190    bpm: Option<f64>,
191    #[serde(default)]
192    beat_offset: f64,
193    #[serde(default)]
194    duration: Option<f64>,
195    #[serde(default)]
196    timing: TimingMode,
197    #[serde(default)]
198    snap: Option<SnapMode>,
199    #[serde(default)]
200    vars: crate::vars::VarSet,
201}
202
203impl<'de> Deserialize<'de> for Scenario {
204    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
205    where
206        D: serde::Deserializer<'de>,
207    {
208        let raw = ScenarioDe::deserialize(deserializer)?;
209        let mut scenario = Scenario {
210            version: raw.version,
211            video: raw.video,
212            audio: raw.audio,
213            fonts: raw.fonts,
214            scenes: raw.scenes,
215            composition: raw.composition,
216            config: raw.config,
217            backgrounds: raw.backgrounds,
218            components: raw.components,
219            annotations: raw.annotations,
220            bpm: raw.bpm,
221            beat_offset: raw.beat_offset,
222            duration: raw.duration,
223            timing: raw.timing,
224            snap: raw.snap,
225            vars: raw.vars,
226        };
227        scenario.propagate_time_ctx();
228        Ok(scenario)
229    }
230}
231
232impl Scenario {
233    fn propagate_time_ctx(&mut self) {
234        let ctx = super::time::TimeCtx {
235            bpm: self.bpm,
236            beat_offset: self.beat_offset,
237            scene_start: 0.0,
238        };
239        let timing = self.timing;
240        let snap = self.snap;
241        let scenario_vars = self.vars.clone();
242        stamp_entries(&mut self.scenes, ctx, timing, snap, &scenario_vars);
243        if let Some(views) = &mut self.composition {
244            for view in views {
245                stamp_entries(&mut view.scenes, ctx, timing, snap, &scenario_vars);
246            }
247        }
248    }
249}
250
251fn stamp_entries(
252    entries: &mut [SceneEntry],
253    ctx: super::time::TimeCtx,
254    timing: TimingMode,
255    snap: Option<SnapMode>,
256    scenario_vars: &crate::vars::VarSet,
257) {
258    for entry in entries {
259        if let SceneEntry::Scene(scene) = entry {
260            scene.resolved_time_ctx = ctx;
261            scene.resolved_timing = timing;
262            scene.resolved_snap = snap;
263            scene.resolved_scenario_vars = scenario_vars.clone();
264        }
265    }
266}
267
268/// Scene-placement semantics for a scenario (issue #336). See
269/// [`Scene::at`] and [`Scene::tail`] for what `"v2"` unlocks.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
271#[serde(rename_all = "snake_case")]
272pub enum TimingMode {
273    /// Today's behaviour, byte-identical for every scenario that predates
274    /// issue #336: a transition's frames *replace* frames at the tail of
275    /// the outgoing scene and the head of the incoming one, so the
276    /// scenario's rendered length is `sum(scene durations) - sum(transition
277    /// durations)`.
278    #[default]
279    V1,
280    /// Absolute scene placement with a declared overlap: scene *i* occupies
281    /// `[at_i, at_i + duration_i)`, and a transition entering it
282    /// additionally renders the *previous* scene during `[at_i, at_i +
283    /// transition duration)` — past its own end, per that scene's
284    /// [`SceneTail`]. Total duration is `at_last + duration_last`: no
285    /// subtraction anywhere.
286    V2,
287}
288
289/// What `Scenario.snap` rounds resolved times onto. Currently only the beat
290/// grid; the variant exists so the field reads as intent (`"beat"`) rather
291/// than a bare boolean.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum SnapMode {
295    /// Round to the nearest point on `beat_offset + n * 60 / bpm`.
296    Beat,
297}
298
299/// Lifecycle of a studio annotation.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
301#[serde(rename_all = "snake_case")]
302#[derive(Default)]
303pub enum AnnotationStatus {
304    #[default]
305    Open,
306    Resolved,
307}
308
309/// What an annotation points at: a JSON Pointer into the source scenario,
310/// plus optional context captured at click time.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
312pub struct AnnotationTarget {
313    /// RFC 6901 JSON Pointer into the source scenario (e.g. "/scenes/2/children/5").
314    pub pointer: String,
315    /// Component kind label captured at click time (e.g. "text", "card").
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub kind: Option<String>,
318    /// Bounding box [x, y, w, h] in video coords at the capture frame.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub rect: Option<[f32; 4]>,
321}
322
323/// A single studio feedback note attached to an element at a moment in time.
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
325pub struct Annotation {
326    /// Stable id (generated by the studio).
327    pub id: String,
328    /// Free-text change request for the agent/skill.
329    pub note: String,
330    #[serde(default)]
331    pub status: AnnotationStatus,
332    /// Frame index at capture time (global playhead).
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub frame: Option<u32>,
335    /// Resolved view index (convenience for the skill).
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub view: Option<usize>,
338    /// Resolved scene index (convenience for the skill).
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub scene: Option<usize>,
341    pub target: AnnotationTarget,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
345#[serde(rename_all = "snake_case")]
346pub enum ViewType {
347    Slide,
348    World,
349}
350
351fn default_view_type() -> ViewType {
352    ViewType::Slide
353}
354
355fn default_camera_pan_duration() -> f64 {
356    0.8
357}
358
359#[derive(Debug, Serialize, Deserialize, JsonSchema)]
360#[serde(deny_unknown_fields)]
361pub struct View {
362    #[serde(rename = "type", default = "default_view_type")]
363    pub view_type: ViewType,
364    #[serde(default, deserialize_with = "deserialize_scene_entries")]
365    pub scenes: Vec<SceneEntry>,
366    /// Transition entering this view (between views).
367    #[serde(default)]
368    pub transition: Option<Transition>,
369    /// (world) Shared background: color string, animated entry, or array.
370    #[serde(default, deserialize_with = "deserialize_background_value")]
371    pub background: Option<BackgroundValue>,
372    /// (world) Legacy shared animated backgrounds.
373    #[serde(
374        default,
375        rename = "animated-background",
376        deserialize_with = "deserialize_animated_backgrounds"
377    )]
378    pub animated_background: Vec<AnimatedBackground>,
379    /// (world) Easing for camera pan between scenes.
380    #[serde(default = "default_transition_easing")]
381    pub camera_easing: EasingType,
382    /// (world) Duration of camera pan between scenes (default 0.8s).
383    #[serde(default = "default_camera_pan_duration")]
384    pub camera_pan_duration: f64,
385}
386
387#[derive(Debug)]
388pub struct ResolvedScenario {
389    pub video: VideoConfig,
390    pub audio: Vec<AudioTrack>,
391    pub fonts: Vec<FontEntry>,
392    pub views: Vec<ResolvedView>,
393    pub included_paths: Vec<std::path::PathBuf>,
394}
395
396impl ResolvedScenario {
397    pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
398        self.views.iter().flat_map(|v| v.scenes.iter())
399    }
400
401    #[allow(dead_code)]
402    pub fn all_scenes_vec(&self) -> Vec<&Scene> {
403        self.all_scenes().collect()
404    }
405}
406
407#[derive(Debug)]
408pub struct ResolvedView {
409    pub view_type: ViewType,
410    pub scenes: Vec<Scene>,
411    pub transition: Option<Transition>,
412    pub background: ResolvedBackground,
413    pub camera_easing: EasingType,
414    pub camera_pan_duration: f64,
415}
416
417/// An entry in the `scenes` array: either a concrete scene or an include directive.
418///
419/// `#[serde(untagged)]` is kept so [`schemars`] still emits the correct
420/// (flat, non-wrapped) JSON Schema for this enum, and so a direct
421/// `SceneEntry::deserialize` call elsewhere keeps working. It is **not**
422/// how `Scenario.scenes` / `View.scenes` actually deserialize an entry from
423/// JSON, though: those two fields use [`deserialize_scene_entries`] instead
424/// (see its doc comment for why — M6, issue #110).
425#[derive(Debug, Serialize, Deserialize, JsonSchema)]
426#[serde(untagged)]
427#[allow(clippy::large_enum_variant)]
428pub enum SceneEntry {
429    /// A regular scene defined inline.
430    Scene(Scene),
431    /// A reference to an external scenario file whose scenes will be injected here.
432    Include(IncludeDirective),
433}
434
435fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result<Vec<SceneEntry>, D::Error>
436where
437    D: serde::Deserializer<'de>,
438{
439    use serde::de::Error as _;
440
441    let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
442    let mut out = Vec::with_capacity(raw.len());
443    for (i, entry) in raw.into_iter().enumerate() {
444        let is_include = entry.get("include").is_some();
445        if is_include {
446            let directive: IncludeDirective = serde_json::from_value(entry)
447                .map_err(|e| D::Error::custom(format!("scenes[{i}] (include directive): {e}")))?;
448            out.push(SceneEntry::Include(directive));
449        } else {
450            let scene: Scene = serde_json::from_value(entry)
451                .map_err(|e| D::Error::custom(format!("scenes[{i}]: {e}")))?;
452            out.push(SceneEntry::Scene(scene));
453        }
454    }
455    Ok(out)
456}
457
458/// Directive to inject scenes from an external scenario file.
459#[derive(Debug, Serialize, Deserialize, JsonSchema)]
460#[serde(deny_unknown_fields)]
461pub struct IncludeDirective {
462    /// Path (relative to parent file) or URL (http/https) to a scenario JSON file.
463    pub include: String,
464    /// Only include scenes at these 0-based indices. When absent, all scenes are included.
465    #[serde(default)]
466    pub scenes: Option<Vec<usize>>,
467    /// Config overrides to pass to the included structural component.
468    #[serde(default)]
469    pub config: Option<HashMap<String, serde_json::Value>>,
470}
471
472/// Font file to load at startup.
473///
474/// Two mutually exclusive modes:
475/// - **Local**: `path` (required) + `family`. Loads a `.ttf`/`.otf` file directly.
476/// - **Google Fonts**: `source = "google"` + `family` + optional `weights` (default [400]).
477///   The font is downloaded from the Google Fonts CSS2 API and cached in
478///   `~/.cache/rustmotion/fonts` (or `%LOCALAPPDATA%\rustmotion\fonts` on Windows).
479///   Subsequent renders with a warm cache make zero network calls.
480#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
481pub struct FontEntry {
482    /// Local file path (.ttf/.otf). Required when `source` is absent.
483    #[serde(default)]
484    pub path: Option<String>,
485    /// Font family name (e.g. "Inter", "JetBrains Mono").
486    pub family: String,
487    /// Font source. Currently the only recognised value is `"google"`.
488    /// When set, `path` must be absent.
489    #[serde(default)]
490    pub source: Option<String>,
491    /// Font weights to download (Google Fonts only). Defaults to `[400]`.
492    #[serde(default)]
493    pub weights: Option<Vec<u16>>,
494}
495
496/// `Scenario::audio`'s wire shape (issue #331): either the legacy bare
497/// array of file-based [`AudioTrack`]s, or a single object that can carry
498/// both file tracks *and* a synthesised score, mixed into one bus. Which
499/// shape a given `"audio"` value is is unambiguous from its JSON kind
500/// (array vs. object), so `#[serde(untagged)]` needs no help distinguishing
501/// them.
502///
503/// An old scenario using the bare-array form is unaffected byte-for-byte —
504/// [`AudioValue::Tracks`] round-trips through exactly the type `audio` used
505/// to be, and the `rustmotion` crate's include-resolution pass (outside
506/// this crate) never has to look past [`AudioValue::into_tracks`].
507#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
508#[serde(untagged)]
509pub enum AudioValue {
510    Tracks(Vec<AudioTrack>),
511    Config(Box<AudioConfig>),
512}
513
514impl Default for AudioValue {
515    fn default() -> Self {
516        AudioValue::Tracks(Vec::new())
517    }
518}
519
520impl AudioValue {
521    pub fn tracks(&self) -> &[AudioTrack] {
522        match self {
523            AudioValue::Tracks(tracks) => tracks,
524            AudioValue::Config(config) => &config.tracks,
525        }
526    }
527
528    pub fn into_tracks(self) -> Vec<AudioTrack> {
529        match self {
530            AudioValue::Tracks(tracks) => tracks,
531            AudioValue::Config(config) => config.tracks,
532        }
533    }
534
535    pub fn config(&self) -> Option<&AudioConfig> {
536        match self {
537            AudioValue::Tracks(_) => None,
538            AudioValue::Config(config) => Some(config),
539        }
540    }
541}
542
543/// The object form of [`AudioValue`] — file tracks plus, optionally, a
544/// synthesised score (issue #331's `voices`/`score`/`master`, matching the
545/// issue body's example verbatim). `bpm`/`beat_offset` default to the
546/// *scenario's* own [`Scenario::bpm`]/[`Scenario::beat_offset`] when
547/// absent here — see `AudioConfig::as_score`'s caller in `rustmotion`'s
548/// `encode` crate, which is where that fallback is actually applied (this
549/// crate only carries the override, it does not resolve it: `Scenario`
550/// alone does not know its own `bpm` by the time `include::resolve_includes`
551/// has consumed it — the caller captures both before that happens).
552#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
553#[serde(deny_unknown_fields)]
554pub struct AudioConfig {
555    #[serde(default)]
556    pub tracks: Vec<AudioTrack>,
557    /// Overrides the scenario's own `bpm` for this score only. Normally
558    /// left absent — sharing the scenario's real grid (deliverable #1's
559    /// whole point) is the common case, not the exception.
560    #[serde(default)]
561    pub bpm: Option<f64>,
562    #[serde(default)]
563    pub beat_offset: Option<f64>,
564    #[serde(default)]
565    pub voices: HashMap<String, crate::audio::Voice>,
566    #[serde(default)]
567    pub score: Vec<crate::audio::ScoreEvent>,
568    #[serde(default)]
569    pub master: crate::audio::MasterBus,
570}
571
572impl AudioConfig {
573    pub fn has_synth(&self) -> bool {
574        !self.voices.is_empty() || !self.score.is_empty()
575    }
576
577    pub fn as_score(&self) -> crate::audio::Score {
578        crate::audio::Score {
579            voices: self.voices.clone(),
580            score: self.score.clone(),
581            master: self.master.clone(),
582        }
583    }
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
587pub struct AudioTrack {
588    pub src: String,
589    #[serde(default)]
590    pub start: f64,
591    #[serde(default)]
592    pub end: Option<f64>,
593    #[serde(default = "default_volume")]
594    pub volume: f32,
595    #[serde(default)]
596    pub fade_in: Option<f64>,
597    #[serde(default)]
598    pub fade_out: Option<f64>,
599    #[serde(default)]
600    pub volume_keyframes: Vec<VolumeKeyframe>,
601}
602
603/// Dynamic volume control point
604#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
605pub struct VolumeKeyframe {
606    pub time: f64,
607    pub volume: f32,
608    #[serde(default)]
609    pub easing: EasingType,
610}
611
612fn default_volume() -> f32 {
613    1.0
614}
615
616#[derive(Debug, Serialize, Deserialize, JsonSchema)]
617#[serde(deny_unknown_fields)]
618pub struct VideoConfig {
619    pub width: u32,
620    pub height: u32,
621    #[serde(default = "default_fps")]
622    pub fps: u32,
623    #[serde(default = "default_background")]
624    pub background: String,
625    #[serde(default)]
626    pub codec: Option<VideoCodec>,
627    #[serde(default)]
628    pub crf: Option<u8>,
629}
630
631#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
632pub struct WorldPosition {
633    #[serde(default)]
634    pub x: f32,
635    #[serde(default)]
636    pub y: f32,
637}
638
639#[derive(Debug, Serialize, Deserialize, JsonSchema)]
640#[serde(deny_unknown_fields)]
641pub struct Scene {
642    pub duration: f64,
643    /// Unified background: color string, animated entry (with optional $ref), or array.
644    #[serde(default, deserialize_with = "deserialize_background_value")]
645    pub background: Option<BackgroundValue>,
646    #[serde(default)]
647    pub children: Vec<serde_json::Value>,
648    /// Filled once, by whoever first deserializes `children` into the component
649    /// type it owns. `rustmotion-core` cannot name that type, so it is held
650    /// erased; `Scene::prepared_children` is the typed accessor.
651    #[serde(skip)]
652    #[schemars(skip)]
653    pub prepared_children: std::sync::OnceLock<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
654    #[serde(default)]
655    pub transition: Option<Transition>,
656    #[serde(default)]
657    pub freeze_at: Option<f64>,
658    /// Flex layout for automatic layer positioning
659    #[serde(default)]
660    pub layout: Option<SceneLayout>,
661    /// Legacy animated background (kept for backward compat)
662    #[serde(
663        default,
664        rename = "animated-background",
665        deserialize_with = "deserialize_animated_backgrounds"
666    )]
667    pub animated_background: Vec<AnimatedBackground>,
668    /// Virtual camera with animatable x, y, zoom, rotation.
669    #[serde(default)]
670    pub camera: Option<Camera>,
671    /// Declarative camera shake (issue #330): a list of beat-synced
672    /// impacts, each a damped harmonic oscillator, summed and **additive**
673    /// over `camera` above — a pan and a shake ride the same frame instead
674    /// of fighting over one keyframe track. See [`SceneShake`] for the
675    /// formula and `crate::engine::shake::shake_offset` for the function
676    /// that evaluates it.
677    #[serde(default)]
678    pub shake: Option<SceneShake>,
679    /// Position of this scene in the 2D world (used by world views).
680    #[serde(default, rename = "world-position")]
681    pub world_position: Option<WorldPosition>,
682    /// (world) Keep this scene visible after its time window ends.
683    #[serde(default)]
684    pub persist: bool,
685    /// Direction the light comes from, shared by every `style.material` in
686    /// the scene. Absent means upper-left, the convention an eye reads as
687    /// "lit from above" without being told.
688    ///
689    /// Declaring it once is what makes several materials look like they
690    /// belong to the same frame: a per-node preset cannot agree with its
691    /// neighbours about where the light is.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub light: Option<SceneLight>,
694    /// Post-processing effects applied to the full frame buffer after Skia renders.
695    /// Effects are additive and applied in declaration order.
696    #[serde(default, skip_serializing_if = "Vec::is_empty")]
697    pub effects: Vec<PostEffect>,
698    /// Where this scene starts, on the scenario's absolute timeline (issue
699    /// #336). `at` is absolute by definition — see the anchoring rule
700    /// documented once on [`TimePoint`]. Defaults to [`SceneStart::Auto`]:
701    /// immediately after the previous scene's own window ends. Only
702    /// consulted by `rustmotion`'s frame-task scheduler under
703    /// `Scenario.timing = "v2"`; a `"v1"` scenario places scenes
704    /// back-to-back regardless of what this field says.
705    #[serde(default, skip_serializing_if = "SceneStart::is_auto")]
706    pub at: SceneStart,
707    /// How this scene behaves when the *next* scene's transition overlaps
708    /// into it, rendering it past its own `duration` (issue #336, `timing:
709    /// "v2"` only). Defaults to [`SceneTail::Freeze`].
710    #[serde(default, skip_serializing_if = "SceneTail::is_default_freeze")]
711    pub tail: SceneTail,
712    /// Post-resolution background (populated by include.rs, ignored by serde).
713    #[serde(skip)]
714    #[schemars(skip)]
715    pub resolved_background: ResolvedBackground,
716    /// The beat-grid context (`bpm`/`beat_offset`) of the `Scenario` this
717    /// scene was declared in — populated by [`Scenario`]'s own
718    /// `Deserialize` impl, *before* `include.rs` resolution ever runs, not
719    /// by `include.rs` itself (issue #336; see the doc on
720    /// [`ResolvedScenario`] for why). `scene_start` is always `0.0` here:
721    /// [`Scene::at`] is resolved with
722    /// [`TimePoint::resolve_absolute`](super::time::TimePoint::resolve_absolute),
723    /// which ignores it.
724    #[serde(skip)]
725    #[schemars(skip)]
726    pub resolved_time_ctx: super::time::TimeCtx,
727    /// The `Scenario.timing` this scene was declared under. Same
728    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`].
729    #[serde(skip)]
730    #[schemars(skip)]
731    pub resolved_timing: TimingMode,
732    /// The `Scenario.snap` this scene was declared under. Same
733    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`].
734    #[serde(skip)]
735    #[schemars(skip)]
736    pub resolved_snap: Option<SnapMode>,
737    /// This scene's own declared variables (issue #329): shadow a
738    /// same-named scenario-level variable ([`Scenario::vars`]) for
739    /// expressions evaluated inside this scene, and are themselves
740    /// invisible from any other scene — see `rustmotion_core::vars::VarScope`
741    /// for the shadowing rule and why that isolation needs no special-case
742    /// error handling. A variable with no `animation` here is a constant,
743    /// same as at the scenario level.
744    #[serde(default)]
745    pub vars: crate::vars::VarSet,
746    /// The enclosing [`Scenario::vars`] this scene was declared under —
747    /// populated by `Scenario`'s own `Deserialize` impl, *before*
748    /// `include.rs` resolution ever runs, not by `include.rs` itself. Same
749    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`]:
750    /// [`ResolvedScenario`] carries no scenario-level fields of its own, so
751    /// anything a scene needs to remember about its enclosing `Scenario`
752    /// has to already be sitting on the `Scene` by the time `include.rs` —
753    /// a file no single workstream owns — merges included scenes in.
754    #[serde(skip)]
755    #[schemars(skip)]
756    pub resolved_scenario_vars: crate::vars::VarSet,
757}
758
759/// The literal string `"auto"` — the only value [`SceneStart::Auto`] can
760/// hold. Its own tiny externally-tagged enum so it round-trips as the bare
761/// string `"auto"` (serde's default representation for a fieldless
762/// variant), matching every other `snake_case` string enum in this file.
763#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
764#[serde(rename_all = "snake_case")]
765pub enum SceneStartAuto {
766    Auto,
767}
768
769/// When a scene starts, on the scenario's absolute timeline. See
770/// [`Scene::at`].
771///
772/// Not `#[derive(Deserialize)]` (see [`SceneStartDe`] / its manual
773/// `Deserialize` impl just below): a syntactically malformed `at` — `"at":
774/// "banana"` — must be rejected the moment the scenario is deserialized,
775/// not discovered later as a silent fallback to automatic placement. A
776/// derive can't express that extra grammar check, only the untagged
777/// try-`"auto"`-then-`TimePoint` shape.
778#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
779#[serde(untagged)]
780pub enum SceneStart {
781    /// Immediately after the previous scene's own window ends. Serializes
782    /// and deserializes as the bare string `"auto"`.
783    Auto(SceneStartAuto),
784    /// An explicit point on the scenario's absolute timeline. Grammar is
785    /// validated at deserialize time via
786    /// [`TimePoint::validate_grammar`](super::time::TimePoint::validate_grammar)
787    /// — resolving it (which additionally needs `bpm` for a beat unit) is a
788    /// separate, later step: `rustmotion validate`'s schema pass, which can
789    /// name the offending scene and report `unresolved_beat_unit` — see
790    /// `crates/rustmotion/src/cli/commands/validate_schema.rs`.
791    At(TimePoint),
792}
793
794#[derive(Deserialize)]
795#[serde(untagged)]
796enum SceneStartDe {
797    Auto(SceneStartAuto),
798    At(TimePoint),
799}
800
801impl<'de> Deserialize<'de> for SceneStart {
802    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
803    where
804        D: serde::Deserializer<'de>,
805    {
806        use serde::de::Error as _;
807        match SceneStartDe::deserialize(deserializer)? {
808            SceneStartDe::Auto(a) => Ok(SceneStart::Auto(a)),
809            SceneStartDe::At(tp) => {
810                tp.validate_grammar().map_err(D::Error::custom)?;
811                Ok(SceneStart::At(tp))
812            }
813        }
814    }
815}
816
817impl Default for SceneStart {
818    fn default() -> Self {
819        SceneStart::Auto(SceneStartAuto::Auto)
820    }
821}
822
823impl SceneStart {
824    fn is_auto(&self) -> bool {
825        matches!(self, SceneStart::Auto(_))
826    }
827}
828
829/// How a scene behaves when it is rendered past its own `duration` because
830/// the *next* scene's transition overlaps into it. See [`Scene::tail`].
831#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
832#[serde(rename_all = "snake_case")]
833pub enum SceneTail {
834    /// Hold the scene's last frame for the overlap — the same
835    /// fixed-non-advancing-time effect `freeze_at` produces.
836    #[default]
837    Freeze,
838    /// Let the scene's own animations keep running past its declared
839    /// `duration` for the overlap.
840    Continue,
841}
842
843impl SceneTail {
844    fn is_default_freeze(&self) -> bool {
845        matches!(self, SceneTail::Freeze)
846    }
847}
848
849/// Virtual camera for pan/zoom/rotation effects at the scene level.
850#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
851#[serde(deny_unknown_fields)]
852pub struct Camera {
853    /// Camera center X offset from scene center (pixels). Default: 0.
854    #[serde(default)]
855    pub x: f32,
856    /// Camera center Y offset from scene center (pixels). Default: 0.
857    #[serde(default)]
858    pub y: f32,
859    /// Zoom factor. 1.0 = no zoom, 2.0 = 2x zoom in, 0.5 = zoom out.
860    #[serde(default = "default_camera_zoom")]
861    pub zoom: f32,
862    /// Rotation in degrees around the camera origin. Default: 0.
863    #[serde(default)]
864    pub rotation: f32,
865    /// Focal point for zoom/rotation, in frame pixels. Absent = frame centre
866    /// (the historical behaviour). When the object is present, `x`/`y`
867    /// default to 0 (top-left corner) — set both explicitly.
868    #[serde(default)]
869    pub origin: Option<CameraOrigin>,
870    /// Depth the camera is focused on, on the same scale as `style.depth`.
871    /// Default `1.0` — the plane an element sits on when it declares no
872    /// depth, so a scenario that never mentions focus is focused on
873    /// everything it has.
874    #[serde(default = "default_camera_focus")]
875    pub focus: f32,
876    /// How fast sharpness falls off away from `focus`, in blur pixels per
877    /// unit of depth difference. Default `0.0` — no depth of field at all,
878    /// which is what every scenario written before this rendered. `4.0` is
879    /// a visible but readable separation; past `20.0` an out-of-focus plane
880    /// reads as a colour wash.
881    ///
882    /// Animate `focus` through `keyframes` for a rack focus; animating
883    /// `aperture` instead opens and closes the effect without moving the
884    /// focal plane.
885    #[serde(default)]
886    pub aperture: f32,
887    /// Tilt of the whole shot about the horizontal axis, in degrees, applied
888    /// to each direct child of the scene scaled by its `style.depth` — the
889    /// same rule parallax and `focus` already follow. Default 0.
890    #[serde(default)]
891    pub rotate_x: f32,
892    /// Tilt about the vertical axis, same convention. Default 0.
893    #[serde(default)]
894    pub rotate_y: f32,
895    /// Viewing distance for `rotate_x`/`rotate_y`, in pixels. **One vanishing
896    /// point for the whole shot** — which is the difference from copying the
897    /// same rotation onto every group, where each group gets its own. `0`
898    /// means no perspective, an orthographic tilt. Default 0.
899    #[serde(default)]
900    pub perspective: f32,
901    /// Keyframe animations for camera properties.
902    #[serde(default)]
903    pub keyframes: Vec<CameraKeyframe>,
904    /// Blurs the frame across a shutter window when the camera itself is
905    /// moving (pan, zoom, rotation or `shake`) — opt-in, since it renders
906    /// `samples` sub-frame exposures and averages them instead of one.
907    /// Absent (the default) renders exactly as before. Skipped on any frame
908    /// where the resolved camera pose does not change across the window, so
909    /// a static shot never pays the extra cost.
910    #[serde(default)]
911    pub motion_blur: Option<CameraMotionBlurConfig>,
912}
913
914impl Camera {
915    pub fn resolve_property(&self, property: &str, time: f64) -> f32 {
916        let track = self
917            .keyframes
918            .iter()
919            .find(|k| k.property == property)
920            .filter(|t| !t.values.is_empty());
921        let Some(track) = track else {
922            return self.static_property(property);
923        };
924
925        let points = &track.values;
926        if time <= points[0].time {
927            return points[0].value;
928        }
929        if time >= points[points.len() - 1].time {
930            return points[points.len() - 1].value;
931        }
932        for i in 0..points.len() - 1 {
933            let p0 = &points[i];
934            let p1 = &points[i + 1];
935            if time >= p0.time && time <= p1.time {
936                let segment_duration = p1.time - p0.time;
937                let segment_t = if segment_duration.abs() < 1e-9 {
938                    1.0
939                } else {
940                    (time - p0.time) / segment_duration
941                };
942                let segment_easing = p0.easing.as_ref().unwrap_or(&track.easing);
943                let eased = crate::engine::animator::ease(segment_t, segment_easing) as f32;
944                return p0.value + (p1.value - p0.value) * eased;
945            }
946        }
947        points[points.len() - 1].value
948    }
949
950    fn static_property(&self, property: &str) -> f32 {
951        match property {
952            "x" => self.x,
953            "y" => self.y,
954            "zoom" => self.zoom,
955            "rotation" => self.rotation,
956            "focus" => self.focus,
957            "aperture" => self.aperture,
958            "rotate_x" => self.rotate_x,
959            "rotate_y" => self.rotate_y,
960            "perspective" => self.perspective,
961            "origin.x" => self.origin.as_ref().map(|o| o.x).unwrap_or(0.0),
962            "origin.y" => self.origin.as_ref().map(|o| o.y).unwrap_or(0.0),
963            _ => 0.0,
964        }
965    }
966}
967
968/// Shutter-window camera motion blur, opt-in via `camera.motion_blur`.
969#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
970#[serde(deny_unknown_fields)]
971pub struct CameraMotionBlurConfig {
972    /// Sub-frame exposures averaged together. Each one is a full render, so
973    /// this is a direct cost multiplier — default `8`, clamped to `1..=16`.
974    #[serde(default = "default_camera_motion_blur_samples")]
975    pub samples: u32,
976    /// Exposure length as a multiple of one frame duration (`shutter / fps`
977    /// seconds), ending at the frame's own time. `0.5` (the default) covers
978    /// half a frame, the 180-degree shutter of a film camera; `1.0` leaves it
979    /// open for the whole frame and smears twice as far.
980    #[serde(default = "default_camera_motion_blur_shutter")]
981    pub shutter: f64,
982}
983
984fn default_camera_motion_blur_samples() -> u32 {
985    8
986}
987
988fn default_camera_motion_blur_shutter() -> f64 {
989    0.5
990}
991
992/// Where the light comes from, and how strong it is.
993#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
994#[serde(deny_unknown_fields)]
995pub struct SceneLight {
996    /// Horizontal component of the vector pointing **toward** the light.
997    /// Negative puts the light on the left. Default `-0.35`.
998    #[serde(default = "default_light_x")]
999    pub x: f32,
1000    /// Vertical component, same convention: negative puts the light above.
1001    /// Default `-0.8`, so by default the light sits upper-left — where an
1002    /// eye expects it without being told. The vector need not be a unit
1003    /// vector; only its direction is read.
1004    #[serde(default = "default_light_y")]
1005    pub y: f32,
1006    /// Overall strength of every material in the scene. `0` flattens them
1007    /// all without editing each node. Default `1.0`.
1008    #[serde(default = "default_light_intensity")]
1009    pub intensity: f32,
1010    /// Colour of the highlight and the specular edge. Default `#FFFFFF`.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub color: Option<String>,
1013}
1014
1015fn default_light_x() -> f32 {
1016    -0.35
1017}
1018
1019fn default_light_y() -> f32 {
1020    -0.8
1021}
1022
1023fn default_light_intensity() -> f32 {
1024    1.0
1025}
1026
1027impl Default for SceneLight {
1028    fn default() -> Self {
1029        SceneLight {
1030            x: default_light_x(),
1031            y: default_light_y(),
1032            intensity: default_light_intensity(),
1033            color: None,
1034        }
1035    }
1036}
1037
1038/// Focal point of the camera in frame pixels.
1039#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1040#[serde(deny_unknown_fields)]
1041pub struct CameraOrigin {
1042    #[serde(default)]
1043    pub x: f32,
1044    #[serde(default)]
1045    pub y: f32,
1046}
1047
1048const KNOWN_CAMERA_PROPERTIES: &[&str] = &[
1049    "x",
1050    "y",
1051    "zoom",
1052    "rotation",
1053    "origin.x",
1054    "origin.y",
1055    "focus",
1056    "aperture",
1057    "rotate_x",
1058    "rotate_y",
1059    "perspective",
1060];
1061
1062fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
1063    if KNOWN_CAMERA_PROPERTIES.contains(&value) {
1064        return Ok(());
1065    }
1066    let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase();
1067    let normalized = normalize(value);
1068    if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES
1069        .iter()
1070        .find(|known| normalize(known) == normalized)
1071    {
1072        Err(E::custom(format!(
1073            "unknown camera keyframe property '{value}' — did you mean '{suggestion}'?"
1074        )))
1075    } else {
1076        Err(E::custom(format!(
1077            "unknown camera keyframe property '{value}': expected one of {}",
1078            KNOWN_CAMERA_PROPERTIES.join(", ")
1079        )))
1080    }
1081}
1082
1083fn deserialize_camera_property<'de, D>(deserializer: D) -> Result<String, D::Error>
1084where
1085    D: serde::Deserializer<'de>,
1086{
1087    let s = String::deserialize(deserializer)?;
1088    validate_camera_property::<D::Error>(&s)?;
1089    Ok(s)
1090}
1091
1092/// A keyframe for a camera property.
1093#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1094#[serde(deny_unknown_fields)]
1095pub struct CameraKeyframe {
1096    /// The camera property to animate: "x", "y", "zoom", "rotation",
1097    /// "origin.x", "origin.y" (dotted form, matching the component keyframe
1098    /// convention for compound properties).
1099    #[serde(deserialize_with = "deserialize_camera_property")]
1100    pub property: String,
1101    /// Time-value pairs for the animation.
1102    pub values: Vec<CameraKeyframePoint>,
1103    /// Easing function for interpolation.
1104    #[serde(default)]
1105    pub easing: EasingType,
1106}
1107
1108/// A single time-value point in a camera keyframe.
1109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1110#[serde(deny_unknown_fields)]
1111pub struct CameraKeyframePoint {
1112    /// Time in seconds (relative to scene start).
1113    pub time: f64,
1114    /// Value at this time.
1115    pub value: f32,
1116    /// Easing for the segment starting at this keyframe, overriding the
1117    /// track-level `easing` for that segment only — same convention as a
1118    /// component `Keyframe`.
1119    #[serde(default)]
1120    pub easing: Option<EasingType>,
1121}
1122
1123fn default_camera_focus() -> f32 {
1124    1.0
1125}
1126
1127fn default_camera_zoom() -> f32 {
1128    1.0
1129}
1130
1131/// Direction for progressive blur.
1132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1133#[serde(rename_all = "snake_case")]
1134#[derive(Default)]
1135pub enum BlurDirection {
1136    Top,
1137    #[default]
1138    Bottom,
1139}
1140
1141/// A post-processing effect applied to the full frame buffer after Skia renders.
1142/// Effects are pure Rust, deterministic, and applied in declaration order.
1143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1144#[serde(tag = "type", rename_all = "snake_case")]
1145pub enum PostEffect {
1146    /// Film grain noise overlaid on every pixel.
1147    Grain {
1148        /// Noise strength clamped to 0..1. Default: 0.15.
1149        #[serde(default = "default_grain_intensity")]
1150        intensity: f32,
1151        /// Base seed for the noise hash. Default: 42.
1152        #[serde(default = "default_grain_seed")]
1153        seed: u64,
1154        /// When true, the pattern changes per frame (`seed ^ frame_index`). Default: true.
1155        #[serde(default = "default_true")]
1156        animated: bool,
1157    },
1158    /// Darken the frame edges towards the corners.
1159    Vignette {
1160        /// Darkness strength clamped to 0..1. Default: 0.5.
1161        #[serde(default = "default_vignette_intensity")]
1162        intensity: f32,
1163        /// Fraction of the half-diagonal where darkening starts. Default: 0.75.
1164        #[serde(default = "default_vignette_radius")]
1165        radius: f32,
1166    },
1167    /// Reduce spatial resolution by averaging square pixel blocks.
1168    Pixelate {
1169        /// Block size in pixels, clamped to 1..=256. Default: 8.
1170        #[serde(default = "default_pixelate_size")]
1171        size: u32,
1172    },
1173    /// Blur that grows from zero at `start` to `max_radius` at the frame edge.
1174    ProgressiveBlur {
1175        /// Which edge gets the maximum blur. Default: bottom.
1176        #[serde(default)]
1177        direction: BlurDirection,
1178        /// Fraction of the frame height where blur begins (0.0..1.0). Default: 0.5.
1179        #[serde(default = "default_blur_start")]
1180        start: f32,
1181        /// Maximum box-blur radius in pixels at the far edge. Default: 12.0.
1182        #[serde(default = "default_blur_max_radius")]
1183        max_radius: f32,
1184    },
1185    /// A full-frame flash that decays from `intensity` to zero over
1186    /// `duration`, starting at `at` — issue #330's companion to
1187    /// [`super::shake::SceneShake`]: the reference reel this pair was
1188    /// built for lights one of these on every shake impact, so a beat-grid
1189    /// `at` can share the same value the matching
1190    /// [`super::shake::ShakeImpact::at`] uses. `at` is a [`TimePoint`],
1191    /// resolved the same way as any other in-scene time (relative to the
1192    /// scene's own start unless `@`-prefixed).
1193    Flash {
1194        /// When the flash starts, on the scene's own timeline.
1195        at: TimePoint,
1196        /// Flash colour as a hex string. Default: `"#FFFFFF"`.
1197        #[serde(default = "default_flash_color")]
1198        color: String,
1199        /// Peak strength, clamped to 0..1, at `t = at`. Default: 0.6.
1200        #[serde(default = "default_flash_intensity")]
1201        intensity: f32,
1202        /// How long the flash takes to decay to zero, in seconds. Default: 0.15.
1203        #[serde(default = "default_flash_duration")]
1204        duration: f32,
1205    },
1206    /// A tape-tear glitch: the frame breaks into horizontal bands slid
1207    /// sideways, with optional noise, scanlines and a travelling tracking
1208    /// line. Bounded by `at` and `duration` like [`PostEffect::Flash`],
1209    /// because it reads as a beat rather than as a state — a reference reel
1210    /// holds it for well under a second on a rewind.
1211    Vhs {
1212        /// When the tear starts, on the scene's own timeline.
1213        at: TimePoint,
1214        /// How long it lasts, in seconds. Default: 0.5.
1215        #[serde(default = "default_vhs_duration")]
1216        duration: f32,
1217        /// Number of horizontal strips the frame breaks into. Default: 12.
1218        #[serde(default = "default_vhs_bands")]
1219        bands: u32,
1220        /// Largest sideways shift a band can take, in pixels. Default: 40.
1221        #[serde(default = "default_vhs_offset")]
1222        offset: f32,
1223        /// Strength of the added noise, 0..1. Default: 0.25.
1224        #[serde(default = "default_vhs_noise")]
1225        noise: f32,
1226        /// Strength of the darkened scanlines, 0..1. Default: 0.2.
1227        #[serde(default = "default_vhs_scanlines")]
1228        scanlines: f32,
1229        /// A thin coloured line travelling down the frame, as tape tracking
1230        /// error looks. Absent means none.
1231        #[serde(default, skip_serializing_if = "Option::is_none")]
1232        tracking_line: Option<VhsTrackingLine>,
1233        /// Seed for the band layout and the noise. The same seed gives the
1234        /// same tear every render.
1235        #[serde(default = "default_vhs_seed")]
1236        seed: u64,
1237    },
1238}
1239
1240/// The travelling line of a [`PostEffect::Vhs`] tear.
1241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1242#[serde(deny_unknown_fields)]
1243pub struct VhsTrackingLine {
1244    /// Line colour as a hex string. Default: `"#3DA5FF"`.
1245    #[serde(default = "default_vhs_tracking_color")]
1246    pub color: String,
1247    /// How many times it crosses the frame per second. Default: 1.5.
1248    #[serde(default = "default_vhs_tracking_speed")]
1249    pub speed: f32,
1250    /// Line thickness in pixels. Default: 3.0.
1251    #[serde(default = "default_vhs_tracking_thickness")]
1252    pub thickness: f32,
1253}
1254
1255fn default_vhs_duration() -> f32 {
1256    0.5
1257}
1258fn default_vhs_bands() -> u32 {
1259    12
1260}
1261fn default_vhs_offset() -> f32 {
1262    40.0
1263}
1264fn default_vhs_noise() -> f32 {
1265    0.25
1266}
1267fn default_vhs_scanlines() -> f32 {
1268    0.2
1269}
1270fn default_vhs_seed() -> u64 {
1271    1
1272}
1273fn default_vhs_tracking_color() -> String {
1274    "#3DA5FF".to_string()
1275}
1276fn default_vhs_tracking_speed() -> f32 {
1277    1.5
1278}
1279fn default_vhs_tracking_thickness() -> f32 {
1280    3.0
1281}
1282
1283fn default_grain_intensity() -> f32 {
1284    0.15
1285}
1286fn default_grain_seed() -> u64 {
1287    42
1288}
1289fn default_true() -> bool {
1290    true
1291}
1292fn default_vignette_intensity() -> f32 {
1293    0.5
1294}
1295fn default_vignette_radius() -> f32 {
1296    0.75
1297}
1298fn default_pixelate_size() -> u32 {
1299    8
1300}
1301fn default_blur_start() -> f32 {
1302    0.5
1303}
1304fn default_blur_max_radius() -> f32 {
1305    12.0
1306}
1307fn default_flash_color() -> String {
1308    "#FFFFFF".to_string()
1309}
1310fn default_flash_intensity() -> f32 {
1311    0.6
1312}
1313fn default_flash_duration() -> f32 {
1314    0.15
1315}
1316
1317/// Scene-level flex layout configuration
1318#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1319#[serde(deny_unknown_fields)]
1320pub struct SceneLayout {
1321    #[serde(default)]
1322    pub direction: Option<CardDirection>,
1323    #[serde(default)]
1324    pub gap: Option<f32>,
1325    #[serde(default)]
1326    pub align_items: Option<CardAlign>,
1327    #[serde(default)]
1328    pub justify_content: Option<CardJustify>,
1329    #[serde(default)]
1330    pub padding: Option<f32>,
1331}
1332
1333/// The order `pixel_dissolve` turns its cells in.
1334#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1335#[serde(rename_all = "snake_case")]
1336pub enum PixelDissolveOrder {
1337    /// From the frame's border inward, so the centre — where the subject
1338    /// usually is — is the last thing to go. This is what the reference piece
1339    /// does, and it is the default for that reason.
1340    #[default]
1341    EdgesIn,
1342    /// The mirror: the centre opens first and the border closes last.
1343    CenterOut,
1344    /// No spatial order at all — every cell on its own draw.
1345    Random,
1346}
1347
1348/// The corner a `corner_reveal` is anchored to.
1349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1350#[serde(rename_all = "snake_case")]
1351pub enum TransitionCorner {
1352    /// Measured default: the reference piece grows its reveal from here, with
1353    /// the right and top edges pinned and the left and bottom edges travelling.
1354    #[default]
1355    TopRight,
1356    TopLeft,
1357    BottomRight,
1358    BottomLeft,
1359}
1360
1361/// Which way a directional transition travels.
1362#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1363#[serde(rename_all = "snake_case")]
1364pub enum TransitionDirection {
1365    /// Both frames travel leftwards; the incoming one enters from the right.
1366    #[default]
1367    Left,
1368    Right,
1369    Up,
1370    Down,
1371}
1372
1373/// The centre an `iris` mask grows from (or closes onto), or a `zoom_blur`
1374/// transition radiates its streaks from, in frame pixels. Absent = frame
1375/// centre.
1376#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1377#[serde(deny_unknown_fields)]
1378pub struct ZoomBlurOrigin {
1379    /// Horizontal centre, in frame pixels.
1380    #[serde(default)]
1381    pub x: f32,
1382    /// Vertical centre, in frame pixels.
1383    #[serde(default)]
1384    pub y: f32,
1385}
1386
1387/// The silhouette an `iris` transition's mask takes as it grows.
1388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1389#[serde(rename_all = "snake_case")]
1390pub enum IrisShape {
1391    /// A plain disc. `aspect` has no effect on this shape.
1392    #[default]
1393    Circle,
1394    /// A stadium — a rounded rectangle whose width-to-height ratio is set by
1395    /// `aspect`. `aspect: 1.0` degenerates to a circle.
1396    Pill,
1397}
1398
1399/// A coloured border traced along an `iris` mask's moving edge. Inside the
1400/// ring is whichever scene the mask currently reveals, outside it is the one
1401/// it is covering.
1402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1403#[serde(deny_unknown_fields)]
1404pub struct IrisRing {
1405    /// Ring colour, any CSS colour string.
1406    pub color: String,
1407    /// Ring thickness, in px.
1408    #[serde(default = "default_iris_ring_width")]
1409    pub width: f32,
1410}
1411
1412fn default_iris_ring_width() -> f32 {
1413    12.0
1414}
1415
1416fn default_iris_aspect() -> f32 {
1417    1.0
1418}
1419
1420/// The arbitrary silhouette a `mask` transition scales up from `origin`,
1421/// authored in the shape's own local coordinates (whatever range its points
1422/// or path data use — a 100x100 star, a 24x24 icon glyph, anything). The
1423/// silhouette's own bounding-box centre is what tracks `origin`, not a fixed
1424/// point like `(0, 0)`, so a lopsided glyph still grows from its visual
1425/// middle. Reuses the same `kind` vocabulary as `style.clip-path`
1426/// (`polygon`, `path`) rather than inventing a second grammar for the same
1427/// idea.
1428#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1429#[serde(tag = "kind", rename_all = "kebab-case")]
1430pub enum MaskShape {
1431    /// A closed straight-edge outline, in local units.
1432    Polygon { points: Vec<(f32, f32)> },
1433    /// SVG path data, in local units.
1434    Path { d: String },
1435}
1436
1437fn default_mask_to_scale() -> f32 {
1438    20.0
1439}
1440
1441fn default_blob_lobes() -> u32 {
1442    8
1443}
1444
1445fn default_blob_wobble() -> f32 {
1446    0.15
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1450#[serde(deny_unknown_fields)]
1451pub struct Transition {
1452    #[serde(rename = "type")]
1453    pub transition_type: TransitionType,
1454    /// Which corner a `corner_reveal` grows from. Ignored by every other type.
1455    #[serde(default)]
1456    pub corner: TransitionCorner,
1457    /// Cell edge in px for `pixel_dissolve`. Ignored by every other type.
1458    #[serde(default = "default_transition_cell")]
1459    pub cell: f32,
1460    /// `pixel_dissolve` and `blob` only: `pixel_dissolve`'s stable scatter
1461    /// selector, or the stable lobe-wobble selector `blob` grows its
1462    /// silhouette from. Two transitions with the same seed (and, for `blob`,
1463    /// the same `lobes`/`wobble`) reproduce the same shape.
1464    #[serde(default = "default_transition_seed")]
1465    pub seed: u32,
1466    /// `pixel_dissolve` only: which cells turn first.
1467    #[serde(default)]
1468    pub order: PixelDissolveOrder,
1469    /// Which way a `chromatic_wipe` or `whip` travels. Ignored by every
1470    /// other type — the `wipe_*`/`slide` family encodes its direction in the
1471    /// type name.
1472    #[serde(default)]
1473    pub direction: TransitionDirection,
1474    /// `chromatic_wipe` only: how far the red and cyan channels split at the
1475    /// peak of the wipe, as a multiple of the tuned default. `0` removes the
1476    /// colour flash and leaves a plain fast slide; `2` doubles it.
1477    #[serde(default = "default_transition_aberration")]
1478    pub aberration: f32,
1479    /// `zoom_blur` and `whip` only: how far the streak reaches — radially
1480    /// from `origin` for `zoom_blur`, along `direction` for `whip`. `0`
1481    /// collapses the streak pass entirely, leaving a plain zoom or a plain
1482    /// slide with no smear. Ignored by every other transition type.
1483    #[serde(default = "default_transition_strength")]
1484    pub strength: f32,
1485    /// `zoom_blur`, `iris`, `mask` and `blob` only: for `zoom_blur`, the
1486    /// centre the streaks radiate from; for the other three, the mask's
1487    /// centre — in `mask`'s case, the point its silhouette's own
1488    /// bounding-box centre grows from. Ignored by every other transition
1489    /// type. Frame pixels, like `zoom_blur`'s, not a `0..1` fraction.
1490    #[serde(default)]
1491    pub origin: Option<ZoomBlurOrigin>,
1492    /// `iris` only: the mask's silhouette. Ignored by every other type.
1493    #[serde(default)]
1494    pub shape: IrisShape,
1495    /// `iris` `pill` only: width-to-height ratio of the stadium as it grows.
1496    /// Ignored by `circle` and by every other transition type.
1497    #[serde(default = "default_iris_aspect")]
1498    pub aspect: f32,
1499    /// `iris` only: a flat colour the mask fills with instead of revealing
1500    /// the next scene directly. Once the colour covers the whole frame, the
1501    /// next scene fades in under it. Absent reveals the next scene straight
1502    /// away. Ignored by every other transition type.
1503    #[serde(default)]
1504    pub fill: Option<String>,
1505    /// `iris` only, and only meaningful with `fill` set: seconds the solid
1506    /// colour holds once it covers the whole frame, before the next scene
1507    /// fades in. Has no effect without `fill`.
1508    #[serde(default)]
1509    pub hold: f32,
1510    /// `iris` only: a coloured ring traced along the mask's moving edge.
1511    /// Ignored by every other transition type.
1512    #[serde(default)]
1513    pub ring: Option<IrisRing>,
1514    /// `iris` only: the mask closes onto `origin` instead of opening from
1515    /// it — the outgoing scene shrinks to a point rather than the incoming
1516    /// one (or `fill`) growing to cover the frame. Ignored by every other
1517    /// transition type.
1518    #[serde(default)]
1519    pub reverse: bool,
1520    /// `mask` only: the arbitrary silhouette it scales up from `origin` —
1521    /// `polygon` or `path`, the same `kind` vocabulary as `style.clip-path`.
1522    /// Required for `mask`; ignored by every other type (`blob` grows its
1523    /// own procedural silhouette instead, from `lobes`/`wobble`/`seed`).
1524    #[serde(default)]
1525    pub silhouette: Option<MaskShape>,
1526    /// `mask` only: `silhouette`'s scale factor at `progress: 0`, relative to
1527    /// its own bounding-box size. `0` is a true point; small positive values
1528    /// (the default) start it as a barely-visible speck rather than exactly
1529    /// nothing, which is fine since `progress <= 0.0` always short-circuits
1530    /// to the untouched outgoing frame regardless of this value. Ignored by
1531    /// every other transition type.
1532    #[serde(default)]
1533    pub from_scale: f32,
1534    /// `mask` only: `silhouette`'s scale factor at `progress: 1`. Large
1535    /// enough to guarantee full-frame coverage is the author's
1536    /// responsibility — unlike `iris`, an arbitrary silhouette's covering
1537    /// radius cannot be solved for automatically. Ignored by every other
1538    /// transition type.
1539    #[serde(default = "default_mask_to_scale")]
1540    pub to_scale: f32,
1541    /// `blob` only: how many lobes its procedural organic silhouette grows.
1542    /// Ignored by every other transition type.
1543    #[serde(default = "default_blob_lobes")]
1544    pub lobes: u32,
1545    /// `blob` only: how far each lobe's radius wanders from a perfect circle,
1546    /// as a fraction of it — `0` degenerates to a circle (a slow `iris` by
1547    /// another name), values approaching `1` pinch some lobes almost to the
1548    /// centre. Ignored by every other transition type.
1549    #[serde(default = "default_blob_wobble")]
1550    pub wobble: f32,
1551    /// `wipe_*`, `mask` and `blob` only: width, in px, of the soft gradient
1552    /// edge instead of a hard cut. `0` (the default) is a hard edge — the
1553    /// wipe's clip-rect boundary, or the mask/blob's clip-path boundary, with
1554    /// no blur pass at all. Ignored by every other transition type.
1555    #[serde(default)]
1556    pub feather: f32,
1557    /// `wipe_*`, `mask` and `blob` only, and only visible when `feather` is
1558    /// greater than `0`: a colour that tints the feathered edge itself,
1559    /// peaking exactly on the boundary and fading to nothing at both sides of
1560    /// the feather band — a coloured front that sweeps across ahead of the
1561    /// incoming scene. Absent leaves the feather a plain alpha blend. Ignored
1562    /// by every other transition type, and by these three when `feather` is
1563    /// `0` (a hard edge has no band to tint).
1564    #[serde(default)]
1565    pub band_color: Option<String>,
1566    #[serde(default = "default_transition_duration")]
1567    pub duration: f64,
1568    #[serde(default = "default_transition_easing")]
1569    pub easing: EasingType,
1570    /// How the background behaves during a `camera_pan`. Ignored by every
1571    /// other transition type, which composite two finished frames and have no
1572    /// separate background layer to move.
1573    #[serde(default)]
1574    pub background: PanBackground,
1575}
1576
1577/// Whether a `camera_pan` treats the background as a fixed backdrop the scenes
1578/// slide across, or as part of each scene, travelling with it.
1579#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1580#[serde(rename_all = "snake_case")]
1581pub enum PanBackground {
1582    /// The outgoing scene's background stays put while both foregrounds slide
1583    /// over it. Keeps a shared ambience continuous, so the cut is invisible —
1584    /// this is the default because it is what makes a multi-beat video read as
1585    /// one shot.
1586    #[default]
1587    Static,
1588    /// Each scene carries its own background, and both travel with their
1589    /// foreground. Use this when the beats are meant to look like different
1590    /// places rather than one continuous space.
1591    Travel,
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1595#[serde(rename_all = "snake_case")]
1596pub enum TransitionType {
1597    Fade,
1598    WipeLeft,
1599    WipeRight,
1600    WipeUp,
1601    WipeDown,
1602    ZoomIn,
1603    ZoomOut,
1604    Flip,
1605    ClockWipe,
1606    /// A mask that grows from `origin` to reveal the next scene — a plain
1607    /// disc by default. `shape` and `aspect` change its silhouette, `fill`
1608    /// paints it a flat colour first (held for `hold` seconds before the
1609    /// next scene fades in under it), `ring` traces its moving edge, and
1610    /// `reverse` closes the mask onto `origin` instead of opening from it.
1611    Iris,
1612    Slide,
1613    Dissolve,
1614    CornerReveal,
1615    PixelDissolve,
1616    CameraPan,
1617    /// A fast slide in which the reveal edge splits into its red and cyan
1618    /// channels at the peak and recombines as it lands — the glitch-flash
1619    /// cut. `direction` steers it, `aberration` scales the split.
1620    ChromaticWipe,
1621    /// A radial zoom blur: the outgoing frame streaks outward from `origin`
1622    /// while it fades, then the incoming frame is left standing alone — the
1623    /// "tunnel" cut. `strength` sets how far the streaks reach; `0`
1624    /// collapses it to a plain zoom with no smear. Zero at both ends of the
1625    /// transition, so no fringe bleeds into the next scene.
1626    ZoomBlur,
1627    /// A directional slide whose axis grows a motion-blur streak that peaks
1628    /// at the midpoint: the outgoing frame shoots off along `direction`
1629    /// while it fades, and the incoming frame arrives already streaked and
1630    /// lands sharp. `strength` sets how far the streak reaches; `0`
1631    /// collapses it to a plain `slide`. Zero at both ends of the
1632    /// transition, so no streak bleeds into the next scene.
1633    Whip,
1634    /// `iris` generalised to any silhouette: `silhouette` (a `polygon` or a
1635    /// `path`, the same grammar as `style.clip-path`) scales up about its own
1636    /// bounding-box centre, from `from_scale` to `to_scale`, centred on
1637    /// `origin`. `feather` and `band_color` soften and tint the moving edge.
1638    /// Byte-identical to the outgoing frame at `progress: 0` and to the
1639    /// incoming one at `progress: 1`, by construction rather than by tuning
1640    /// `from_scale`/`to_scale` precisely.
1641    Mask,
1642    /// An organic metaball silhouette — `lobes` control points at a radius
1643    /// wobbling by `wobble` around a circle, joined into a smooth closed
1644    /// blob — growing from `origin` exactly like `mask`, but with a covering
1645    /// radius solved for automatically the way `iris`'s is, instead of an
1646    /// author-supplied `to_scale`. `seed` picks which wobble the lobes get.
1647    /// `feather` and `band_color` behave as they do on `mask`.
1648    Blob,
1649    None,
1650}
1651
1652fn default_transition_cell() -> f32 {
1653    48.0
1654}
1655
1656fn default_transition_seed() -> u32 {
1657    11
1658}
1659
1660fn default_transition_aberration() -> f32 {
1661    1.0
1662}
1663
1664fn default_transition_strength() -> f32 {
1665    1.0
1666}
1667
1668fn default_transition_duration() -> f64 {
1669    0.5
1670}
1671
1672pub(crate) fn default_transition_easing() -> EasingType {
1673    EasingType::EaseInOut
1674}
1675
1676#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1677#[serde(rename_all = "snake_case")]
1678#[derive(Default)]
1679pub enum VideoCodec {
1680    #[default]
1681    H264,
1682    H265,
1683    Vp9,
1684    Prores,
1685}
1686
1687fn default_version() -> String {
1688    "1.0".to_string()
1689}
1690
1691fn default_fps() -> u32 {
1692    30
1693}
1694
1695fn default_background() -> String {
1696    "#000000".to_string()
1697}
1698
1699#[cfg(test)]
1700mod annotation_tests {
1701    use super::*;
1702
1703    const MINIMAL: &str = r#"{ "video": { "width": 1920, "height": 1080 }, "scenes": [] }"#;
1704
1705    const WITH_ANNOTATIONS: &str = r#"{
1706        "video": { "width": 1920, "height": 1080 },
1707        "scenes": [],
1708        "annotations": [
1709            {
1710                "id": "an_4f2a",
1711                "note": "reduce font-size",
1712                "status": "open",
1713                "frame": 142,
1714                "view": 0,
1715                "scene": 2,
1716                "target": {
1717                    "pointer": "/scenes/2/children/5",
1718                    "kind": "text",
1719                    "rect": [10.0, 20.0, 30.0, 40.0]
1720                }
1721            }
1722        ]
1723    }"#;
1724
1725    #[test]
1726    fn scenario_without_annotations_defaults_empty() {
1727        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
1728        assert!(s.annotations.is_empty());
1729    }
1730
1731    #[test]
1732    fn scenario_with_annotations_deserializes() {
1733        let s: Scenario = serde_json::from_str(WITH_ANNOTATIONS).unwrap();
1734        assert_eq!(s.annotations.len(), 1);
1735        let a = &s.annotations[0];
1736        assert_eq!(a.id, "an_4f2a");
1737        assert_eq!(a.status, AnnotationStatus::Open);
1738        assert_eq!(a.frame, Some(142));
1739        assert_eq!(a.view, Some(0));
1740        assert_eq!(a.scene, Some(2));
1741        assert_eq!(a.target.pointer, "/scenes/2/children/5");
1742        assert_eq!(a.target.kind.as_deref(), Some("text"));
1743        assert_eq!(a.target.rect, Some([10.0, 20.0, 30.0, 40.0]));
1744    }
1745
1746    #[test]
1747    fn empty_annotations_are_not_serialized() {
1748        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
1749        let json = serde_json::to_string(&s).unwrap();
1750        assert!(
1751            !json.contains("annotations"),
1752            "empty annotations must be skipped, got: {json}"
1753        );
1754    }
1755
1756    #[test]
1757    fn status_defaults_to_open_and_target_fields_optional() {
1758        let json = r#"{
1759            "video": { "width": 1, "height": 1 },
1760            "scenes": [],
1761            "annotations": [ { "id": "x", "note": "n", "target": { "pointer": "/scenes/0" } } ]
1762        }"#;
1763        let s: Scenario = serde_json::from_str(json).unwrap();
1764        assert_eq!(s.annotations[0].status, AnnotationStatus::Open);
1765        assert_eq!(s.annotations[0].target.kind, None);
1766        assert_eq!(s.annotations[0].target.rect, None);
1767        assert_eq!(s.annotations[0].frame, None);
1768    }
1769}
1770
1771#[cfg(test)]
1772mod scene_entry_error_tests {
1773    use super::*;
1774
1775    #[test]
1776    fn missing_duration_names_the_scene_index_and_field_not_the_untagged_message() {
1777        let json = r#"{
1778            "video": { "width": 100, "height": 100 },
1779            "scenes": [
1780                { "duration": 1.0, "children": [] },
1781                { "children": [] }
1782            ]
1783        }"#;
1784        let err = serde_json::from_str::<Scenario>(json).expect_err("missing duration must fail");
1785        let msg = err.to_string();
1786        assert!(
1787            !msg.contains("did not match any variant of untagged enum"),
1788            "must not regress to the opaque untagged message: {msg}"
1789        );
1790        assert!(
1791            msg.contains("scenes[1]"),
1792            "must name the offending scene index: {msg}"
1793        );
1794        assert!(
1795            msg.contains("duration"),
1796            "must name the missing field: {msg}"
1797        );
1798    }
1799
1800    #[test]
1801    fn misspelled_transition_type_names_itself() {
1802        let json = r#"{
1803            "video": { "width": 100, "height": 100 },
1804            "scenes": [
1805                {
1806                    "duration": 1.0,
1807                    "children": [],
1808                    "transition": { "type": "wip_left" }
1809                }
1810            ]
1811        }"#;
1812        let err =
1813            serde_json::from_str::<Scenario>(json).expect_err("bad transition type must fail");
1814        let msg = err.to_string();
1815        assert!(
1816            !msg.contains("did not match any variant of untagged enum"),
1817            "must not regress to the opaque untagged message: {msg}"
1818        );
1819        assert!(
1820            msg.contains("scenes[0]"),
1821            "must name the offending scene index: {msg}"
1822        );
1823        assert!(
1824            msg.contains("wip_left"),
1825            "must echo the bad value so the author can spot the typo: {msg}"
1826        );
1827    }
1828
1829    #[test]
1830    fn include_directive_still_works() {
1831        let json = r#"{
1832            "video": { "width": 100, "height": 100 },
1833            "scenes": [
1834                { "include": "does/not/matter.json" }
1835            ]
1836        }"#;
1837        let s: Scenario = serde_json::from_str(json).expect("include entry must parse");
1838        assert!(matches!(s.scenes[0], SceneEntry::Include(_)));
1839    }
1840
1841    #[test]
1842    fn broken_include_directive_names_itself() {
1843        let json = r#"{
1844            "video": { "width": 100, "height": 100 },
1845            "scenes": [
1846                { "include": "x.json", "scenes": "not-an-array" }
1847            ]
1848        }"#;
1849        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1850        let msg = err.to_string();
1851        assert!(msg.contains("scenes[0]"), "got: {msg}");
1852        assert!(msg.contains("include directive"), "got: {msg}");
1853    }
1854
1855    #[test]
1856    fn view_scenes_field_uses_the_same_precise_errors() {
1857        let json = r#"{
1858            "video": { "width": 100, "height": 100 },
1859            "composition": [
1860                { "type": "slide", "scenes": [ { "children": [] } ] }
1861            ]
1862        }"#;
1863        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1864        let msg = err.to_string();
1865        assert!(
1866            !msg.contains("did not match any variant of untagged enum"),
1867            "got: {msg}"
1868        );
1869        assert!(msg.contains("scenes[0]"), "got: {msg}");
1870        assert!(msg.contains("duration"), "got: {msg}");
1871    }
1872}
1873
1874#[cfg(test)]
1875mod strict_schema_tests {
1876    use super::*;
1877
1878    #[test]
1879    fn misspelled_scene_field_is_rejected() {
1880        let json = r#"{
1881            "video": { "width": 100, "height": 100 },
1882            "scenes": [ { "durration": 3.0, "children": [] } ]
1883        }"#;
1884        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1885        assert!(err.to_string().contains("durration"), "got: {err}");
1886    }
1887
1888    #[test]
1889    fn misspelled_video_field_is_rejected() {
1890        let json = r#"{
1891            "video": { "width": 100, "height": 100, "framerate": 30 },
1892            "scenes": [ { "duration": 1.0, "children": [] } ]
1893        }"#;
1894        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1895        assert!(err.to_string().contains("framerate"), "got: {err}");
1896    }
1897
1898    #[test]
1899    fn misspelled_top_level_scenario_field_is_rejected() {
1900        let json = r#"{
1901            "video": { "width": 100, "height": 100 },
1902            "scenes": [ { "duration": 1.0, "children": [] } ],
1903            "titel": "typo"
1904        }"#;
1905        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1906        assert!(err.to_string().contains("titel"), "got: {err}");
1907    }
1908
1909    #[test]
1910    fn misspelled_camera_field_is_rejected() {
1911        let json = r#"{
1912            "video": { "width": 100, "height": 100 },
1913            "scenes": [ {
1914                "duration": 1.0,
1915                "children": [],
1916                "camera": { "zooom": 1.5 }
1917            } ]
1918        }"#;
1919        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1920        assert!(err.to_string().contains("zooom"), "got: {err}");
1921    }
1922
1923    #[test]
1924    fn misspelled_scene_layout_field_is_rejected() {
1925        let json = r#"{
1926            "video": { "width": 100, "height": 100 },
1927            "scenes": [ {
1928                "duration": 1.0,
1929                "children": [],
1930                "layout": { "gapp": 10 }
1931            } ]
1932        }"#;
1933        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1934        assert!(err.to_string().contains("gapp"), "got: {err}");
1935    }
1936
1937    #[test]
1938    fn misspelled_view_field_is_rejected() {
1939        let json = r#"{
1940            "video": { "width": 100, "height": 100 },
1941            "composition": [ { "typ": "slide", "scenes": [] } ]
1942        }"#;
1943        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1944        assert!(err.to_string().contains("typ"), "got: {err}");
1945    }
1946
1947    #[test]
1948    fn valid_scenario_with_every_covered_struct_still_parses() {
1949        let json = r##"{
1950            "version": "1.0",
1951            "video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" },
1952            "scenes": [ {
1953                "duration": 1.0,
1954                "children": [],
1955                "layout": { "direction": "column", "gap": 10, "align_items": "center", "justify_content": "center", "padding": 5 },
1956                "transition": { "type": "fade", "duration": 0.5, "easing": "ease_in_out" },
1957                "camera": { "x": 0, "y": 0, "zoom": 1.0, "rotation": 0, "origin": { "x": 1, "y": 2 }, "keyframes": [ { "property": "zoom", "values": [ { "time": 0.0, "value": 1.0 } ], "easing": "linear" } ] }
1958            } ]
1959        }"##;
1960        let s: Scenario = serde_json::from_str(json).expect("valid scenario must still parse");
1961        assert_eq!(s.scenes.len(), 1);
1962    }
1963}
1964
1965#[cfg(test)]
1966mod camera_keyframe_property_tests {
1967    use super::*;
1968
1969    #[test]
1970    fn known_camera_properties_still_work() {
1971        for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] {
1972            let json = format!(
1973                r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"#
1974            );
1975            let kf: CameraKeyframe = serde_json::from_str(&json)
1976                .unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}"));
1977            assert_eq!(kf.property, prop);
1978        }
1979    }
1980
1981    #[test]
1982    fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() {
1983        let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
1984        let err = serde_json::from_str::<CameraKeyframe>(json).expect_err(
1985            "an unrecognised camera keyframe property must be rejected, not silently inert",
1986        );
1987        assert!(err.to_string().contains("tilt"), "got: {err}");
1988    }
1989
1990    #[test]
1991    fn misspelled_origin_property_is_a_named_error() {
1992        let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
1993        let err = serde_json::from_str::<CameraKeyframe>(json)
1994            .expect_err("origin_x must be rejected — the real property is origin.x");
1995        let msg = err.to_string();
1996        assert!(msg.contains("origin_x"), "got: {msg}");
1997        assert!(
1998            msg.contains("origin.x"),
1999            "expected a did-you-mean nudge toward origin.x, got: {msg}"
2000        );
2001    }
2002}
2003
2004#[cfg(test)]
2005mod scene_at_grammar_tests {
2006    use super::*;
2007
2008    fn scenario_with_at(at: &str) -> String {
2009        format!(
2010            r#"{{
2011                "video": {{ "width": 100, "height": 100 }},
2012                "scenes": [
2013                    {{ "duration": 1.0, "children": [] }},
2014                    {{ "duration": 1.0, "children": [], "at": {at} }}
2015                ]
2016            }}"#
2017        )
2018    }
2019
2020    #[test]
2021    fn syntactically_malformed_at_is_rejected_at_deserialize_time() {
2022        let json = scenario_with_at(r#""banana""#);
2023        let err = serde_json::from_str::<Scenario>(&json)
2024            .expect_err("a malformed `at` must fail to deserialize, not fall back silently");
2025        let msg = err.to_string();
2026        assert!(
2027            msg.contains("cannot parse time"),
2028            "expected the grammar error to surface, got: {msg}"
2029        );
2030        assert!(msg.contains("banana"), "got: {msg}");
2031    }
2032
2033    #[test]
2034    fn grammatically_valid_beat_unit_deserializes_even_with_no_bpm() {
2035        let json = scenario_with_at(r#""@8b""#);
2036        let scenario: Scenario =
2037            serde_json::from_str(&json).expect("grammar-valid `at` must deserialize");
2038        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
2039            panic!("expected a Scene entry");
2040        };
2041        assert!(matches!(scene.at, SceneStart::At(TimePoint::Spec(ref s)) if s == "@8b"));
2042    }
2043
2044    #[test]
2045    fn auto_and_plain_seconds_still_deserialize() {
2046        let json = scenario_with_at(r#""auto""#);
2047        let scenario: Scenario = serde_json::from_str(&json).expect("auto must deserialize");
2048        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
2049            panic!("expected a Scene entry");
2050        };
2051        assert!(matches!(scene.at, SceneStart::Auto(_)));
2052
2053        let json = scenario_with_at("2.5");
2054        let scenario: Scenario = serde_json::from_str(&json).expect("bare number must deserialize");
2055        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
2056            panic!("expected a Scene entry");
2057        };
2058        assert!(matches!(scene.at, SceneStart::At(TimePoint::Seconds(s)) if s == 2.5));
2059    }
2060}
2061
2062#[cfg(test)]
2063mod scene_shake_and_flash_tests {
2064    use super::*;
2065
2066    #[test]
2067    fn scene_shake_is_absent_by_default() {
2068        let json = r#"{ "duration": 1.0, "children": [] }"#;
2069        let scene: Scene = serde_json::from_str(json).unwrap();
2070        assert!(scene.shake.is_none());
2071    }
2072
2073    #[test]
2074    fn scene_shake_deserializes_with_six_impacts_on_the_beat_grid() {
2075        let json = r##"{
2076            "duration": 4.0,
2077            "children": [],
2078            "shake": {
2079                "impacts": [
2080                    { "at": "0b", "amplitude": 24.0 },
2081                    { "at": "4b", "amplitude": 20.0 },
2082                    { "at": "8b", "amplitude": 20.0 },
2083                    { "at": "12b", "amplitude": 16.0 },
2084                    { "at": "16b", "amplitude": 16.0 },
2085                    { "at": "20b", "amplitude": 12.0 }
2086                ],
2087                "decay": 12.0,
2088                "frequency": 22.0,
2089                "rotation": 0.3
2090            }
2091        }"##;
2092        let scene: Scene = serde_json::from_str(json).unwrap();
2093        let shake = scene.shake.expect("shake must deserialize");
2094        assert_eq!(shake.impacts.len(), 6);
2095        assert_eq!(shake.decay, 12.0);
2096        assert_eq!(shake.frequency, 22.0);
2097        assert_eq!(shake.rotation, 0.3);
2098    }
2099
2100    #[test]
2101    fn scene_shake_and_camera_coexist_on_the_same_scene() {
2102        let json = r#"{
2103            "duration": 2.0,
2104            "children": [],
2105            "camera": { "zoom": 1.2 },
2106            "shake": { "impacts": [ { "at": 0.5, "amplitude": 10.0 } ] }
2107        }"#;
2108        let scene: Scene = serde_json::from_str(json).unwrap();
2109        assert!(scene.camera.is_some());
2110        assert!(scene.shake.is_some());
2111    }
2112
2113    #[test]
2114    fn misspelled_shake_field_is_rejected() {
2115        let json = r#"{
2116            "video": { "width": 100, "height": 100 },
2117            "scenes": [ {
2118                "duration": 1.0,
2119                "children": [],
2120                "shake": { "impacts": [], "decayy": 5.0 }
2121            } ]
2122        }"#;
2123        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
2124        assert!(err.to_string().contains("decayy"), "got: {err}");
2125    }
2126
2127    #[test]
2128    fn post_effect_flash_deserializes_and_defaults() {
2129        let json = r#"{ "type": "flash", "at": "4b" }"#;
2130        let effect: PostEffect = serde_json::from_str(json).unwrap();
2131        match effect {
2132            PostEffect::Flash {
2133                at,
2134                color,
2135                intensity,
2136                duration,
2137            } => {
2138                assert_eq!(at, TimePoint::Spec("4b".to_string()));
2139                assert_eq!(color, "#FFFFFF");
2140                assert_eq!(intensity, 0.6);
2141                assert_eq!(duration, 0.15);
2142            }
2143            other => panic!("expected Flash, got {other:?}"),
2144        }
2145    }
2146
2147    #[test]
2148    fn post_effect_flash_accepts_every_known_field() {
2149        let json = r##"{
2150            "type": "flash",
2151            "at": 1.2,
2152            "color": "#FF3300",
2153            "intensity": 0.9,
2154            "duration": 0.08
2155        }"##;
2156        let effect: PostEffect = serde_json::from_str(json).unwrap();
2157        match effect {
2158            PostEffect::Flash {
2159                at,
2160                color,
2161                intensity,
2162                duration,
2163            } => {
2164                assert_eq!(at, TimePoint::Seconds(1.2));
2165                assert_eq!(color, "#FF3300");
2166                assert_eq!(intensity, 0.9);
2167                assert_eq!(duration, 0.08);
2168            }
2169            other => panic!("expected Flash, got {other:?}"),
2170        }
2171    }
2172
2173    #[test]
2174    fn scene_effects_accept_a_flash_alongside_other_post_effects() {
2175        let json = r#"{
2176            "duration": 1.0,
2177            "children": [],
2178            "effects": [
2179                { "type": "flash", "at": "0b" },
2180                { "type": "grain", "intensity": 0.1 }
2181            ]
2182        }"#;
2183        let scene: Scene = serde_json::from_str(json).unwrap();
2184        assert_eq!(scene.effects.len(), 2);
2185        assert!(matches!(scene.effects[0], PostEffect::Flash { .. }));
2186        assert!(matches!(scene.effects[1], PostEffect::Grain { .. }));
2187    }
2188}
2189
2190#[cfg(test)]
2191mod camera_keyframe_easing_tests {
2192    use super::*;
2193
2194    fn camera_with_x_track(easing_on_first_point: Option<EasingType>) -> Camera {
2195        let json = serde_json::json!({
2196            "keyframes": [{
2197                "property": "x",
2198                "easing": "linear",
2199                "values": [
2200                    { "time": 0.0, "value": 0.0, "easing": easing_on_first_point },
2201                    { "time": 1.0, "value": 100.0 }
2202                ]
2203            }]
2204        });
2205        serde_json::from_value(json).expect("camera with one x track deserializes")
2206    }
2207
2208    #[test]
2209    fn a_keyframes_own_easing_overrides_the_track_easing_for_its_segment() {
2210        let linear = camera_with_x_track(None);
2211        let linear_mid = linear.resolve_property("x", 0.5);
2212        assert!(
2213            (linear_mid - 50.0).abs() < 1e-4,
2214            "with no per-keyframe easing the track's own linear easing must still apply, \
2215             got {linear_mid}"
2216        );
2217
2218        let eased = camera_with_x_track(Some(EasingType::EaseIn));
2219        let eased_mid = eased.resolve_property("x", 0.5);
2220        assert!(
2221            (eased_mid - 12.5).abs() < 1e-4,
2222            "a per-keyframe `ease_in` on the segment's starting point must override the \
2223             track's `linear` for that segment only (ease_in_cubic(0.5) = 0.125, so \
2224             0 + 100 * 0.125 = 12.5), got {eased_mid}"
2225        );
2226    }
2227
2228    #[test]
2229    fn per_keyframe_easing_only_affects_the_segment_it_starts() {
2230        let json = serde_json::json!({
2231            "keyframes": [{
2232                "property": "x",
2233                "easing": "linear",
2234                "values": [
2235                    { "time": 0.0, "value": 0.0, "easing": "ease_in" },
2236                    { "time": 1.0, "value": 100.0 },
2237                    { "time": 2.0, "value": 200.0 }
2238                ]
2239            }]
2240        });
2241        let camera: Camera = serde_json::from_value(json).expect("camera deserializes");
2242
2243        let first_segment_mid = camera.resolve_property("x", 0.5);
2244        assert!(
2245            (first_segment_mid - 12.5).abs() < 1e-4,
2246            "the first segment must still use its own ease_in, got {first_segment_mid}"
2247        );
2248
2249        let second_segment_mid = camera.resolve_property("x", 1.5);
2250        assert!(
2251            (second_segment_mid - 150.0).abs() < 1e-4,
2252            "the second segment has no easing of its own and must fall back to the track's \
2253             linear easing, not inherit ease_in from the first segment, got {second_segment_mid}"
2254        );
2255    }
2256}