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::style::{CardAlign, CardDirection, CardJustify};
12
13/// Definition of a variable in a structural component.
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15pub struct VariableDefinition {
16    #[serde(rename = "type")]
17    pub var_type: VariableType,
18    pub default: serde_json::Value,
19    /// Optional description for documentation/schema.
20    #[serde(default)]
21    pub description: Option<String>,
22}
23
24/// Supported variable types.
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum VariableType {
28    String,
29    Number,
30    Boolean,
31    Object,
32    Array,
33}
34
35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct Scenario {
38    #[serde(default = "default_version")]
39    pub version: String,
40    pub video: VideoConfig,
41    #[serde(default)]
42    pub audio: Vec<AudioTrack>,
43    #[serde(default)]
44    pub fonts: Vec<FontEntry>,
45    #[serde(default, deserialize_with = "deserialize_scene_entries")]
46    pub scenes: Vec<SceneEntry>,
47    /// Composition: a sequence of views (slide or world). Mutually exclusive with top-level `scenes`.
48    #[serde(default)]
49    pub composition: Option<Vec<View>>,
50    /// Config definitions for structural components. Each config entry has a type and default value.
51    #[serde(default)]
52    pub config: Option<HashMap<String, VariableDefinition>>,
53    /// Named background templates that scenes can reference via `$ref`.
54    #[serde(default)]
55    pub backgrounds: HashMap<String, serde_json::Value>,
56    /// Studio feedback annotations. Persisted in the scenario but never read by
57    /// the renderer or the geometry validator. Skipped on serialization when empty.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub annotations: Vec<Annotation>,
60}
61
62/// Lifecycle of a studio annotation.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "snake_case")]
65#[derive(Default)]
66pub enum AnnotationStatus {
67    #[default]
68    Open,
69    Resolved,
70}
71
72/// What an annotation points at: a JSON Pointer into the source scenario,
73/// plus optional context captured at click time.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
75pub struct AnnotationTarget {
76    /// RFC 6901 JSON Pointer into the source scenario (e.g. "/scenes/2/children/5").
77    pub pointer: String,
78    /// Component kind label captured at click time (e.g. "text", "card").
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub kind: Option<String>,
81    /// Bounding box [x, y, w, h] in video coords at the capture frame.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub rect: Option<[f32; 4]>,
84}
85
86/// A single studio feedback note attached to an element at a moment in time.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
88pub struct Annotation {
89    /// Stable id (generated by the studio).
90    pub id: String,
91    /// Free-text change request for the agent/skill.
92    pub note: String,
93    #[serde(default)]
94    pub status: AnnotationStatus,
95    /// Frame index at capture time (global playhead).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub frame: Option<u32>,
98    /// Resolved view index (convenience for the skill).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub view: Option<usize>,
101    /// Resolved scene index (convenience for the skill).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub scene: Option<usize>,
104    pub target: AnnotationTarget,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum ViewType {
110    Slide,
111    World,
112}
113
114fn default_view_type() -> ViewType {
115    ViewType::Slide
116}
117
118fn default_camera_pan_duration() -> f64 {
119    0.8
120}
121
122#[derive(Debug, Serialize, Deserialize, JsonSchema)]
123#[serde(deny_unknown_fields)]
124pub struct View {
125    #[serde(rename = "type", default = "default_view_type")]
126    pub view_type: ViewType,
127    #[serde(default, deserialize_with = "deserialize_scene_entries")]
128    pub scenes: Vec<SceneEntry>,
129    /// Transition entering this view (between views).
130    #[serde(default)]
131    pub transition: Option<Transition>,
132    /// (world) Shared background: color string, animated entry, or array.
133    // Constat #5: `background`'s `deserialize_with` bypasses the normal
134    // derive, so schemars had nothing to infer a schema from — hence the
135    // `#[schemars(skip)]` this used to carry. But `View` is also
136    // `deny_unknown_fields` (-> `additionalProperties: false` in the
137    // exported schema), so skipping the property didn't just leave it
138    // undocumented: it made the exported schema declare invalid every view
139    // that actually sets `background`. `BackgroundValue` now has a real
140    // (manual) `JsonSchema` impl — see `background.rs` — so this can be a
141    // normal declared property again.
142    #[serde(default, deserialize_with = "deserialize_background_value")]
143    pub background: Option<BackgroundValue>,
144    /// (world) Legacy shared animated backgrounds.
145    #[serde(
146        default,
147        rename = "animated-background",
148        deserialize_with = "deserialize_animated_backgrounds"
149    )]
150    pub animated_background: Vec<AnimatedBackground>,
151    /// (world) Easing for camera pan between scenes.
152    #[serde(default = "default_transition_easing")]
153    pub camera_easing: EasingType,
154    /// (world) Duration of camera pan between scenes (default 0.8s).
155    #[serde(default = "default_camera_pan_duration")]
156    pub camera_pan_duration: f64,
157}
158
159/// A scenario with all includes expanded — safe to pass to the rendering pipeline.
160#[derive(Debug)]
161pub struct ResolvedScenario {
162    pub video: VideoConfig,
163    pub audio: Vec<AudioTrack>,
164    pub fonts: Vec<FontEntry>,
165    pub views: Vec<ResolvedView>,
166    /// Local file paths that were included during resolution (for watch mode).
167    pub included_paths: Vec<std::path::PathBuf>,
168}
169
170impl ResolvedScenario {
171    /// Iterate over all scenes across all views (for prefetch, validation, etc.)
172    pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
173        self.views.iter().flat_map(|v| v.scenes.iter())
174    }
175
176    /// Collect all scenes into a flat Vec (for indexed access)
177    #[allow(dead_code)]
178    pub fn all_scenes_vec(&self) -> Vec<&Scene> {
179        self.all_scenes().collect()
180    }
181}
182
183#[derive(Debug)]
184pub struct ResolvedView {
185    pub view_type: ViewType,
186    pub scenes: Vec<Scene>,
187    pub transition: Option<Transition>,
188    pub background: ResolvedBackground,
189    pub camera_easing: EasingType,
190    pub camera_pan_duration: f64,
191}
192
193/// An entry in the `scenes` array: either a concrete scene or an include directive.
194///
195/// `#[serde(untagged)]` is kept so [`schemars`] still emits the correct
196/// (flat, non-wrapped) JSON Schema for this enum, and so a direct
197/// `SceneEntry::deserialize` call elsewhere keeps working. It is **not**
198/// how `Scenario.scenes` / `View.scenes` actually deserialize an entry from
199/// JSON, though: those two fields use [`deserialize_scene_entries`] instead
200/// (see its doc comment for why — M6, issue #110).
201#[derive(Debug, Serialize, Deserialize, JsonSchema)]
202#[serde(untagged)]
203#[allow(clippy::large_enum_variant)] // untagged serde enum; boxing Scene would break all match arms
204pub enum SceneEntry {
205    /// A regular scene defined inline.
206    Scene(Scene),
207    /// A reference to an external scenario file whose scenes will be injected here.
208    Include(IncludeDirective),
209}
210
211/// Deserializer for `Scenario.scenes` / `View.scenes`, used in place of
212/// `SceneEntry`'s derived `#[serde(untagged)]` deserialization (M6, issue
213/// #110 / #102).
214///
215/// `#[serde(untagged)]` deserializes by trying each variant in declaration
216/// order and keeping the first one that succeeds; when *all* variants fail
217/// (e.g. a scene missing its required `duration`, or a `transition.type`
218/// typo three levels down) serde discards every per-variant error and
219/// reports only `data did not match any variant of untagged enum
220/// SceneEntry` — no scene index, no field name. The wave-2 audit called
221/// this the worst diagnostic in the product.
222///
223/// The two variants are unambiguous by shape — `IncludeDirective`'s only
224/// required field is `include`; a `Scene` never has that key — so this
225/// classifies each entry explicitly instead of trying-and-discarding, then
226/// deserializes it as its concrete type and reports *that* type's real
227/// error, prefixed with the entry's index in the `scenes` array.
228///
229/// Note on precision: this still deserializes from an already-parsed
230/// [`serde_json::Value`] (as the untagged path did too), which has no
231/// source line/column to report — the fix here is naming the scene index
232/// and field, not a source position that doesn't exist at this layer.
233fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result<Vec<SceneEntry>, D::Error>
234where
235    D: serde::Deserializer<'de>,
236{
237    use serde::de::Error as _;
238
239    let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
240    let mut out = Vec::with_capacity(raw.len());
241    for (i, entry) in raw.into_iter().enumerate() {
242        let is_include = entry.get("include").is_some();
243        if is_include {
244            let directive: IncludeDirective = serde_json::from_value(entry)
245                .map_err(|e| D::Error::custom(format!("scenes[{i}] (include directive): {e}")))?;
246            out.push(SceneEntry::Include(directive));
247        } else {
248            let scene: Scene = serde_json::from_value(entry)
249                .map_err(|e| D::Error::custom(format!("scenes[{i}]: {e}")))?;
250            out.push(SceneEntry::Scene(scene));
251        }
252    }
253    Ok(out)
254}
255
256/// Directive to inject scenes from an external scenario file.
257#[derive(Debug, Serialize, Deserialize, JsonSchema)]
258#[serde(deny_unknown_fields)]
259pub struct IncludeDirective {
260    /// Path (relative to parent file) or URL (http/https) to a scenario JSON file.
261    pub include: String,
262    /// Only include scenes at these 0-based indices. When absent, all scenes are included.
263    #[serde(default)]
264    pub scenes: Option<Vec<usize>>,
265    /// Config overrides to pass to the included structural component.
266    #[serde(default)]
267    pub config: Option<HashMap<String, serde_json::Value>>,
268}
269
270/// Font file to load at startup.
271///
272/// Two mutually exclusive modes:
273/// - **Local**: `path` (required) + `family`. Loads a `.ttf`/`.otf` file directly.
274/// - **Google Fonts**: `source = "google"` + `family` + optional `weights` (default [400]).
275///   The font is downloaded from the Google Fonts CSS2 API and cached in
276///   `~/.cache/rustmotion/fonts` (or `%LOCALAPPDATA%\rustmotion\fonts` on Windows).
277///   Subsequent renders with a warm cache make zero network calls.
278#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct FontEntry {
280    /// Local file path (.ttf/.otf). Required when `source` is absent.
281    #[serde(default)]
282    pub path: Option<String>,
283    /// Font family name (e.g. "Inter", "JetBrains Mono").
284    pub family: String,
285    /// Font source. Currently the only recognised value is `"google"`.
286    /// When set, `path` must be absent.
287    #[serde(default)]
288    pub source: Option<String>,
289    /// Font weights to download (Google Fonts only). Defaults to `[400]`.
290    #[serde(default)]
291    pub weights: Option<Vec<u16>>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
295pub struct AudioTrack {
296    pub src: String,
297    #[serde(default)]
298    pub start: f64,
299    #[serde(default)]
300    pub end: Option<f64>,
301    #[serde(default = "default_volume")]
302    pub volume: f32,
303    #[serde(default)]
304    pub fade_in: Option<f64>,
305    #[serde(default)]
306    pub fade_out: Option<f64>,
307    #[serde(default)]
308    pub volume_keyframes: Vec<VolumeKeyframe>,
309}
310
311/// Dynamic volume control point
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct VolumeKeyframe {
314    pub time: f64,
315    pub volume: f32,
316    #[serde(default)]
317    pub easing: EasingType,
318}
319
320fn default_volume() -> f32 {
321    1.0
322}
323
324#[derive(Debug, Serialize, Deserialize, JsonSchema)]
325#[serde(deny_unknown_fields)]
326pub struct VideoConfig {
327    pub width: u32,
328    pub height: u32,
329    #[serde(default = "default_fps")]
330    pub fps: u32,
331    #[serde(default = "default_background")]
332    pub background: String,
333    #[serde(default)]
334    pub codec: Option<VideoCodec>,
335    #[serde(default)]
336    pub crf: Option<u8>,
337}
338
339#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
340pub struct WorldPosition {
341    #[serde(default)]
342    pub x: f32,
343    #[serde(default)]
344    pub y: f32,
345}
346
347#[derive(Debug, Serialize, Deserialize, JsonSchema)]
348#[serde(deny_unknown_fields)]
349pub struct Scene {
350    pub duration: f64,
351    /// Unified background: color string, animated entry (with optional $ref), or array.
352    // Constat #5: see the identical note on `View::background` — same
353    // `#[schemars(skip)]` + `deny_unknown_fields` combination made the
354    // exported schema declare invalid every `examples/*.json` scene that
355    // sets `background` (which is most of them).
356    #[serde(default, deserialize_with = "deserialize_background_value")]
357    pub background: Option<BackgroundValue>,
358    #[serde(default)]
359    pub children: Vec<serde_json::Value>,
360    #[serde(default)]
361    pub transition: Option<Transition>,
362    #[serde(default)]
363    pub freeze_at: Option<f64>,
364    /// Flex layout for automatic layer positioning
365    #[serde(default)]
366    pub layout: Option<SceneLayout>,
367    /// Legacy animated background (kept for backward compat)
368    #[serde(
369        default,
370        rename = "animated-background",
371        deserialize_with = "deserialize_animated_backgrounds"
372    )]
373    pub animated_background: Vec<AnimatedBackground>,
374    /// Virtual camera with animatable x, y, zoom, rotation.
375    #[serde(default)]
376    pub camera: Option<Camera>,
377    /// Position of this scene in the 2D world (used by world views).
378    #[serde(default, rename = "world-position")]
379    pub world_position: Option<WorldPosition>,
380    /// (world) Keep this scene visible after its time window ends.
381    #[serde(default)]
382    pub persist: bool,
383    /// Post-processing effects applied to the full frame buffer after Skia renders.
384    /// Effects are additive and applied in declaration order.
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub effects: Vec<PostEffect>,
387    /// Post-resolution background (populated by include.rs, ignored by serde).
388    #[serde(skip)]
389    #[schemars(skip)]
390    pub resolved_background: ResolvedBackground,
391}
392
393/// Virtual camera for pan/zoom/rotation effects at the scene level.
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
395#[serde(deny_unknown_fields)]
396pub struct Camera {
397    /// Camera center X offset from scene center (pixels). Default: 0.
398    #[serde(default)]
399    pub x: f32,
400    /// Camera center Y offset from scene center (pixels). Default: 0.
401    #[serde(default)]
402    pub y: f32,
403    /// Zoom factor. 1.0 = no zoom, 2.0 = 2x zoom in, 0.5 = zoom out.
404    #[serde(default = "default_camera_zoom")]
405    pub zoom: f32,
406    /// Rotation in degrees around the camera origin. Default: 0.
407    #[serde(default)]
408    pub rotation: f32,
409    /// Focal point for zoom/rotation, in frame pixels. Absent = frame centre
410    /// (the historical behaviour). When the object is present, `x`/`y`
411    /// default to 0 (top-left corner) — set both explicitly.
412    #[serde(default)]
413    pub origin: Option<CameraOrigin>,
414    /// Keyframe animations for camera properties.
415    #[serde(default)]
416    pub keyframes: Vec<CameraKeyframe>,
417}
418
419/// Focal point of the camera in frame pixels.
420#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
421#[serde(deny_unknown_fields)]
422pub struct CameraOrigin {
423    #[serde(default)]
424    pub x: f32,
425    #[serde(default)]
426    pub y: f32,
427}
428
429/// Every camera property `interpolate_camera_property`
430/// (`crates/rustmotion/src/engine/render/scene.rs`, owned by the sibling
431/// GEO workstream this wave — read-only here) actually looks up via
432/// `camera.keyframes.iter().find(|k| k.property == property)`. Constat #4:
433/// a `CameraKeyframe.property` outside this fixed set (or the dotted
434/// `origin.x`/`origin.y` convention misspelled as `origin_x`/`originX`)
435/// never matches that lookup — the keyframe track is silently ignored and
436/// the camera just uses its static value for that property, with no error.
437const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"];
438
439fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
440    if KNOWN_CAMERA_PROPERTIES.contains(&value) {
441        return Ok(());
442    }
443    let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase();
444    let normalized = normalize(value);
445    if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES
446        .iter()
447        .find(|known| normalize(known) == normalized)
448    {
449        Err(E::custom(format!(
450            "unknown camera keyframe property '{value}' — did you mean '{suggestion}'?"
451        )))
452    } else {
453        Err(E::custom(format!(
454            "unknown camera keyframe property '{value}': expected one of {}",
455            KNOWN_CAMERA_PROPERTIES.join(", ")
456        )))
457    }
458}
459
460fn deserialize_camera_property<'de, D>(deserializer: D) -> Result<String, D::Error>
461where
462    D: serde::Deserializer<'de>,
463{
464    let s = String::deserialize(deserializer)?;
465    validate_camera_property::<D::Error>(&s)?;
466    Ok(s)
467}
468
469/// A keyframe for a camera property.
470#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471#[serde(deny_unknown_fields)]
472pub struct CameraKeyframe {
473    /// The camera property to animate: "x", "y", "zoom", "rotation",
474    /// "origin.x", "origin.y" (dotted form, matching the component keyframe
475    /// convention for compound properties).
476    #[serde(deserialize_with = "deserialize_camera_property")]
477    pub property: String,
478    /// Time-value pairs for the animation.
479    pub values: Vec<CameraKeyframePoint>,
480    /// Easing function for interpolation.
481    #[serde(default)]
482    pub easing: EasingType,
483}
484
485/// A single time-value point in a camera keyframe.
486#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
487#[serde(deny_unknown_fields)]
488pub struct CameraKeyframePoint {
489    /// Time in seconds (relative to scene start).
490    pub time: f64,
491    /// Value at this time.
492    pub value: f32,
493}
494
495fn default_camera_zoom() -> f32 {
496    1.0
497}
498
499/// Direction for progressive blur.
500#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
501#[serde(rename_all = "snake_case")]
502#[derive(Default)]
503pub enum BlurDirection {
504    Top,
505    #[default]
506    Bottom,
507}
508
509/// A post-processing effect applied to the full frame buffer after Skia renders.
510/// Effects are pure Rust, deterministic, and applied in declaration order.
511#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
512#[serde(tag = "type", rename_all = "snake_case")]
513pub enum PostEffect {
514    /// Film grain noise overlaid on every pixel.
515    Grain {
516        /// Noise strength clamped to 0..1. Default: 0.15.
517        #[serde(default = "default_grain_intensity")]
518        intensity: f32,
519        /// Base seed for the noise hash. Default: 42.
520        #[serde(default = "default_grain_seed")]
521        seed: u64,
522        /// When true, the pattern changes per frame (`seed ^ frame_index`). Default: true.
523        #[serde(default = "default_true")]
524        animated: bool,
525    },
526    /// Darken the frame edges towards the corners.
527    Vignette {
528        /// Darkness strength clamped to 0..1. Default: 0.5.
529        #[serde(default = "default_vignette_intensity")]
530        intensity: f32,
531        /// Fraction of the half-diagonal where darkening starts. Default: 0.75.
532        #[serde(default = "default_vignette_radius")]
533        radius: f32,
534    },
535    /// Reduce spatial resolution by averaging square pixel blocks.
536    Pixelate {
537        /// Block size in pixels, clamped to 1..=256. Default: 8.
538        #[serde(default = "default_pixelate_size")]
539        size: u32,
540    },
541    /// Blur that grows from zero at `start` to `max_radius` at the frame edge.
542    ProgressiveBlur {
543        /// Which edge gets the maximum blur. Default: bottom.
544        #[serde(default)]
545        direction: BlurDirection,
546        /// Fraction of the frame height where blur begins (0.0..1.0). Default: 0.5.
547        #[serde(default = "default_blur_start")]
548        start: f32,
549        /// Maximum box-blur radius in pixels at the far edge. Default: 12.0.
550        #[serde(default = "default_blur_max_radius")]
551        max_radius: f32,
552    },
553}
554
555fn default_grain_intensity() -> f32 {
556    0.15
557}
558fn default_grain_seed() -> u64 {
559    42
560}
561fn default_true() -> bool {
562    true
563}
564fn default_vignette_intensity() -> f32 {
565    0.5
566}
567fn default_vignette_radius() -> f32 {
568    0.75
569}
570fn default_pixelate_size() -> u32 {
571    8
572}
573fn default_blur_start() -> f32 {
574    0.5
575}
576fn default_blur_max_radius() -> f32 {
577    12.0
578}
579
580/// Scene-level flex layout configuration
581#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
582#[serde(deny_unknown_fields)]
583pub struct SceneLayout {
584    #[serde(default)]
585    pub direction: Option<CardDirection>,
586    #[serde(default)]
587    pub gap: Option<f32>,
588    #[serde(default)]
589    pub align_items: Option<CardAlign>,
590    #[serde(default)]
591    pub justify_content: Option<CardJustify>,
592    #[serde(default)]
593    pub padding: Option<f32>,
594}
595
596/// The order `pixel_dissolve` turns its cells in.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
598#[serde(rename_all = "snake_case")]
599pub enum PixelDissolveOrder {
600    /// From the frame's border inward, so the centre — where the subject
601    /// usually is — is the last thing to go. This is what the reference piece
602    /// does, and it is the default for that reason.
603    #[default]
604    EdgesIn,
605    /// The mirror: the centre opens first and the border closes last.
606    CenterOut,
607    /// No spatial order at all — every cell on its own draw.
608    Random,
609}
610
611/// The corner a `corner_reveal` is anchored to.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
613#[serde(rename_all = "snake_case")]
614pub enum TransitionCorner {
615    /// Measured default: the reference piece grows its reveal from here, with
616    /// the right and top edges pinned and the left and bottom edges travelling.
617    #[default]
618    TopRight,
619    TopLeft,
620    BottomRight,
621    BottomLeft,
622}
623
624/// Which way a directional transition travels.
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
626#[serde(rename_all = "snake_case")]
627pub enum TransitionDirection {
628    /// Both frames travel leftwards; the incoming one enters from the right.
629    #[default]
630    Left,
631    Right,
632    Up,
633    Down,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
637#[serde(deny_unknown_fields)]
638pub struct Transition {
639    #[serde(rename = "type")]
640    pub transition_type: TransitionType,
641    /// Which corner a `corner_reveal` grows from. Ignored by every other type.
642    #[serde(default)]
643    pub corner: TransitionCorner,
644    /// Cell edge in px for `pixel_dissolve`. Ignored by every other type.
645    #[serde(default = "default_transition_cell")]
646    pub cell: f32,
647    /// `pixel_dissolve` only: stable scatter selector. Two transitions with the
648    /// same seed dissolve in the same order.
649    #[serde(default = "default_transition_seed")]
650    pub seed: u32,
651    /// `pixel_dissolve` only: which cells turn first.
652    #[serde(default)]
653    pub order: PixelDissolveOrder,
654    /// Which way a `chromatic_wipe` travels. Ignored by every other type —
655    /// the `wipe_*`/`slide` family encodes its direction in the type name.
656    #[serde(default)]
657    pub direction: TransitionDirection,
658    /// `chromatic_wipe` only: how far the red and cyan channels split at the
659    /// peak of the wipe, as a multiple of the tuned default. `0` removes the
660    /// colour flash and leaves a plain fast slide; `2` doubles it.
661    #[serde(default = "default_transition_aberration")]
662    pub aberration: f32,
663    #[serde(default = "default_transition_duration")]
664    pub duration: f64,
665    #[serde(default = "default_transition_easing")]
666    pub easing: EasingType,
667    /// How the background behaves during a `camera_pan`. Ignored by every
668    /// other transition type, which composite two finished frames and have no
669    /// separate background layer to move.
670    #[serde(default)]
671    pub background: PanBackground,
672}
673
674/// Whether a `camera_pan` treats the background as a fixed backdrop the scenes
675/// slide across, or as part of each scene, travelling with it.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
677#[serde(rename_all = "snake_case")]
678pub enum PanBackground {
679    /// The outgoing scene's background stays put while both foregrounds slide
680    /// over it. Keeps a shared ambience continuous, so the cut is invisible —
681    /// this is the default because it is what makes a multi-beat video read as
682    /// one shot.
683    #[default]
684    Static,
685    /// Each scene carries its own background, and both travel with their
686    /// foreground. Use this when the beats are meant to look like different
687    /// places rather than one continuous space.
688    Travel,
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
692#[serde(rename_all = "snake_case")]
693pub enum TransitionType {
694    Fade,
695    WipeLeft,
696    WipeRight,
697    WipeUp,
698    WipeDown,
699    ZoomIn,
700    ZoomOut,
701    Flip,
702    ClockWipe,
703    Iris,
704    Slide,
705    Dissolve,
706    CornerReveal,
707    PixelDissolve,
708    CameraPan,
709    /// A fast slide in which the reveal edge splits into its red and cyan
710    /// channels at the peak and recombines as it lands — the glitch-flash
711    /// cut. `direction` steers it, `aberration` scales the split.
712    ChromaticWipe,
713    None,
714}
715
716fn default_transition_cell() -> f32 {
717    48.0
718}
719
720fn default_transition_seed() -> u32 {
721    11
722}
723
724fn default_transition_aberration() -> f32 {
725    1.0
726}
727
728fn default_transition_duration() -> f64 {
729    0.5
730}
731
732pub(crate) fn default_transition_easing() -> EasingType {
733    EasingType::EaseInOut
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
737#[serde(rename_all = "snake_case")]
738#[derive(Default)]
739pub enum VideoCodec {
740    #[default]
741    H264,
742    H265,
743    Vp9,
744    Prores,
745}
746
747// --- Default functions ---
748
749fn default_version() -> String {
750    "1.0".to_string()
751}
752
753fn default_fps() -> u32 {
754    30
755}
756
757fn default_background() -> String {
758    "#000000".to_string()
759}
760
761#[cfg(test)]
762mod annotation_tests {
763    use super::*;
764
765    const MINIMAL: &str = r#"{ "video": { "width": 1920, "height": 1080 }, "scenes": [] }"#;
766
767    const WITH_ANNOTATIONS: &str = r#"{
768        "video": { "width": 1920, "height": 1080 },
769        "scenes": [],
770        "annotations": [
771            {
772                "id": "an_4f2a",
773                "note": "reduce font-size",
774                "status": "open",
775                "frame": 142,
776                "view": 0,
777                "scene": 2,
778                "target": {
779                    "pointer": "/scenes/2/children/5",
780                    "kind": "text",
781                    "rect": [10.0, 20.0, 30.0, 40.0]
782                }
783            }
784        ]
785    }"#;
786
787    #[test]
788    fn scenario_without_annotations_defaults_empty() {
789        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
790        assert!(s.annotations.is_empty());
791    }
792
793    #[test]
794    fn scenario_with_annotations_deserializes() {
795        let s: Scenario = serde_json::from_str(WITH_ANNOTATIONS).unwrap();
796        assert_eq!(s.annotations.len(), 1);
797        let a = &s.annotations[0];
798        assert_eq!(a.id, "an_4f2a");
799        assert_eq!(a.status, AnnotationStatus::Open);
800        assert_eq!(a.frame, Some(142));
801        assert_eq!(a.view, Some(0));
802        assert_eq!(a.scene, Some(2));
803        assert_eq!(a.target.pointer, "/scenes/2/children/5");
804        assert_eq!(a.target.kind.as_deref(), Some("text"));
805        assert_eq!(a.target.rect, Some([10.0, 20.0, 30.0, 40.0]));
806    }
807
808    #[test]
809    fn empty_annotations_are_not_serialized() {
810        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
811        let json = serde_json::to_string(&s).unwrap();
812        assert!(
813            !json.contains("annotations"),
814            "empty annotations must be skipped, got: {json}"
815        );
816    }
817
818    #[test]
819    fn status_defaults_to_open_and_target_fields_optional() {
820        let json = r#"{
821            "video": { "width": 1, "height": 1 },
822            "scenes": [],
823            "annotations": [ { "id": "x", "note": "n", "target": { "pointer": "/scenes/0" } } ]
824        }"#;
825        let s: Scenario = serde_json::from_str(json).unwrap();
826        assert_eq!(s.annotations[0].status, AnnotationStatus::Open);
827        assert_eq!(s.annotations[0].target.kind, None);
828        assert_eq!(s.annotations[0].target.rect, None);
829        assert_eq!(s.annotations[0].frame, None);
830    }
831}
832
833/// M6 (issue #110 / #102): `SceneEntry` used to be a bare `#[serde(untagged)]`
834/// enum, so a bad `scenes[]` entry collapsed to "data did not match any
835/// variant of untagged enum SceneEntry" — no scene index, no field name.
836/// `deserialize_scene_entries` replaces the auto-try-each-variant behaviour
837/// with an explicit classify-then-deserialize pass that keeps the real
838/// per-scene error and prefixes it with the entry's index.
839#[cfg(test)]
840mod scene_entry_error_tests {
841    use super::*;
842
843    #[test]
844    fn missing_duration_names_the_scene_index_and_field_not_the_untagged_message() {
845        let json = r#"{
846            "video": { "width": 100, "height": 100 },
847            "scenes": [
848                { "duration": 1.0, "children": [] },
849                { "children": [] }
850            ]
851        }"#;
852        let err = serde_json::from_str::<Scenario>(json).expect_err("missing duration must fail");
853        let msg = err.to_string();
854        assert!(
855            !msg.contains("did not match any variant of untagged enum"),
856            "must not regress to the opaque untagged message: {msg}"
857        );
858        assert!(
859            msg.contains("scenes[1]"),
860            "must name the offending scene index: {msg}"
861        );
862        assert!(
863            msg.contains("duration"),
864            "must name the missing field: {msg}"
865        );
866    }
867
868    #[test]
869    fn misspelled_transition_type_names_itself() {
870        let json = r#"{
871            "video": { "width": 100, "height": 100 },
872            "scenes": [
873                {
874                    "duration": 1.0,
875                    "children": [],
876                    "transition": { "type": "wip_left" }
877                }
878            ]
879        }"#;
880        let err =
881            serde_json::from_str::<Scenario>(json).expect_err("bad transition type must fail");
882        let msg = err.to_string();
883        assert!(
884            !msg.contains("did not match any variant of untagged enum"),
885            "must not regress to the opaque untagged message: {msg}"
886        );
887        assert!(
888            msg.contains("scenes[0]"),
889            "must name the offending scene index: {msg}"
890        );
891        assert!(
892            msg.contains("wip_left"),
893            "must echo the bad value so the author can spot the typo: {msg}"
894        );
895    }
896
897    #[test]
898    fn include_directive_still_works() {
899        let json = r#"{
900            "video": { "width": 100, "height": 100 },
901            "scenes": [
902                { "include": "does/not/matter.json" }
903            ]
904        }"#;
905        let s: Scenario = serde_json::from_str(json).expect("include entry must parse");
906        assert!(matches!(s.scenes[0], SceneEntry::Include(_)));
907    }
908
909    #[test]
910    fn broken_include_directive_names_itself() {
911        // `scenes` on IncludeDirective must be an array of indices — a typo'd
912        // shape should not silently be mistaken for a Scene.
913        let json = r#"{
914            "video": { "width": 100, "height": 100 },
915            "scenes": [
916                { "include": "x.json", "scenes": "not-an-array" }
917            ]
918        }"#;
919        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
920        let msg = err.to_string();
921        assert!(msg.contains("scenes[0]"), "got: {msg}");
922        assert!(msg.contains("include directive"), "got: {msg}");
923    }
924
925    #[test]
926    fn view_scenes_field_uses_the_same_precise_errors() {
927        let json = r#"{
928            "video": { "width": 100, "height": 100 },
929            "composition": [
930                { "type": "slide", "scenes": [ { "children": [] } ] }
931            ]
932        }"#;
933        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
934        let msg = err.to_string();
935        assert!(
936            !msg.contains("did not match any variant of untagged enum"),
937            "got: {msg}"
938        );
939        assert!(msg.contains("scenes[0]"), "got: {msg}");
940        assert!(msg.contains("duration"), "got: {msg}");
941    }
942}
943
944/// M5 (issue #110 / #102): the unknown-attribute checker only ever inspected
945/// the top level of each *component*; a typo in `Scenario`/`Scene`/`View`/
946/// `VideoConfig`/`SceneLayout`/`Transition`/`Camera` (e.g. `durration` on a
947/// scene, `framerate` on `video`) passed silently because nothing checked
948/// those structs at all. `deny_unknown_fields` closes that gap directly at
949/// parse time, for every one of them.
950#[cfg(test)]
951mod strict_schema_tests {
952    use super::*;
953
954    #[test]
955    fn misspelled_scene_field_is_rejected() {
956        let json = r#"{
957            "video": { "width": 100, "height": 100 },
958            "scenes": [ { "durration": 3.0, "children": [] } ]
959        }"#;
960        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
961        assert!(err.to_string().contains("durration"), "got: {err}");
962    }
963
964    #[test]
965    fn misspelled_video_field_is_rejected() {
966        let json = r#"{
967            "video": { "width": 100, "height": 100, "framerate": 30 },
968            "scenes": [ { "duration": 1.0, "children": [] } ]
969        }"#;
970        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
971        assert!(err.to_string().contains("framerate"), "got: {err}");
972    }
973
974    #[test]
975    fn misspelled_top_level_scenario_field_is_rejected() {
976        let json = r#"{
977            "video": { "width": 100, "height": 100 },
978            "scenes": [ { "duration": 1.0, "children": [] } ],
979            "titel": "typo"
980        }"#;
981        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
982        assert!(err.to_string().contains("titel"), "got: {err}");
983    }
984
985    #[test]
986    fn misspelled_camera_field_is_rejected() {
987        let json = r#"{
988            "video": { "width": 100, "height": 100 },
989            "scenes": [ {
990                "duration": 1.0,
991                "children": [],
992                "camera": { "zooom": 1.5 }
993            } ]
994        }"#;
995        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
996        assert!(err.to_string().contains("zooom"), "got: {err}");
997    }
998
999    #[test]
1000    fn misspelled_scene_layout_field_is_rejected() {
1001        let json = r#"{
1002            "video": { "width": 100, "height": 100 },
1003            "scenes": [ {
1004                "duration": 1.0,
1005                "children": [],
1006                "layout": { "gapp": 10 }
1007            } ]
1008        }"#;
1009        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1010        assert!(err.to_string().contains("gapp"), "got: {err}");
1011    }
1012
1013    #[test]
1014    fn misspelled_view_field_is_rejected() {
1015        let json = r#"{
1016            "video": { "width": 100, "height": 100 },
1017            "composition": [ { "typ": "slide", "scenes": [] } ]
1018        }"#;
1019        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
1020        assert!(err.to_string().contains("typ"), "got: {err}");
1021    }
1022
1023    #[test]
1024    fn valid_scenario_with_every_covered_struct_still_parses() {
1025        // Regression guard: deny_unknown_fields must not reject any
1026        // currently-valid field across Scenario/Scene/View/VideoConfig/
1027        // SceneLayout/Transition/Camera.
1028        let json = r##"{
1029            "version": "1.0",
1030            "video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" },
1031            "scenes": [ {
1032                "duration": 1.0,
1033                "children": [],
1034                "layout": { "direction": "column", "gap": 10, "align_items": "center", "justify_content": "center", "padding": 5 },
1035                "transition": { "type": "fade", "duration": 0.5, "easing": "ease_in_out" },
1036                "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" } ] }
1037            } ]
1038        }"##;
1039        let s: Scenario = serde_json::from_str(json).expect("valid scenario must still parse");
1040        assert_eq!(s.scenes.len(), 1);
1041    }
1042}
1043
1044/// Constat #4 (camera half): `CameraKeyframe.property` is consumed by
1045/// `interpolate_camera_property` in `crates/rustmotion/src/engine/render/
1046/// scene.rs` (read-only for this workstream — owned by the sibling GEO
1047/// workstream this wave), which looks up
1048/// `camera.keyframes.iter().find(|k| k.property == property)` for each of a
1049/// *fixed* set of six properties (`"x"`, `"y"`, `"zoom"`, `"rotation"`,
1050/// `"origin.x"`, `"origin.y"`). A misspelled or wrongly-cased
1051/// `CameraKeyframe.property` simply never matches that lookup — the track
1052/// silently falls back to the camera's static value and never animates,
1053/// with no error anywhere.
1054#[cfg(test)]
1055mod camera_keyframe_property_tests {
1056    use super::*;
1057
1058    #[test]
1059    fn known_camera_properties_still_work() {
1060        for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] {
1061            let json = format!(
1062                r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"#
1063            );
1064            let kf: CameraKeyframe = serde_json::from_str(&json)
1065                .unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}"));
1066            assert_eq!(kf.property, prop);
1067        }
1068    }
1069
1070    #[test]
1071    fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() {
1072        let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
1073        let err = serde_json::from_str::<CameraKeyframe>(json).expect_err(
1074            "an unrecognised camera keyframe property must be rejected, not silently inert",
1075        );
1076        assert!(err.to_string().contains("tilt"), "got: {err}");
1077    }
1078
1079    #[test]
1080    fn misspelled_origin_property_is_a_named_error() {
1081        // The documented dotted-compound-property convention
1082        // (`origin.x`/`origin.y`) is easy to get wrong (`originX`,
1083        // `origin_x`) — before this fix, any of those silently never
1084        // animated the camera origin, with the keyframes block accepted
1085        // and simply ignored.
1086        let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
1087        let err = serde_json::from_str::<CameraKeyframe>(json)
1088            .expect_err("origin_x must be rejected — the real property is origin.x");
1089        let msg = err.to_string();
1090        assert!(msg.contains("origin_x"), "got: {msg}");
1091        assert!(
1092            msg.contains("origin.x"),
1093            "expected a did-you-mean nudge toward origin.x, got: {msg}"
1094        );
1095    }
1096}