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#[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 #[serde(default)]
21 pub description: Option<String>,
22}
23
24#[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 #[serde(default)]
49 pub composition: Option<Vec<View>>,
50 #[serde(default)]
52 pub config: Option<HashMap<String, VariableDefinition>>,
53 #[serde(default)]
55 pub backgrounds: HashMap<String, serde_json::Value>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub annotations: Vec<Annotation>,
60}
61
62#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
75pub struct AnnotationTarget {
76 pub pointer: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub kind: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub rect: Option<[f32; 4]>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
88pub struct Annotation {
89 pub id: String,
91 pub note: String,
93 #[serde(default)]
94 pub status: AnnotationStatus,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub frame: Option<u32>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub view: Option<usize>,
101 #[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 #[serde(default)]
131 pub transition: Option<Transition>,
132 #[serde(default, deserialize_with = "deserialize_background_value")]
143 pub background: Option<BackgroundValue>,
144 #[serde(
146 default,
147 rename = "animated-background",
148 deserialize_with = "deserialize_animated_backgrounds"
149 )]
150 pub animated_background: Vec<AnimatedBackground>,
151 #[serde(default = "default_transition_easing")]
153 pub camera_easing: EasingType,
154 #[serde(default = "default_camera_pan_duration")]
156 pub camera_pan_duration: f64,
157}
158
159#[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 pub included_paths: Vec<std::path::PathBuf>,
168}
169
170impl ResolvedScenario {
171 pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
173 self.views.iter().flat_map(|v| v.scenes.iter())
174 }
175
176 #[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
202#[serde(untagged)]
203#[allow(clippy::large_enum_variant)] pub enum SceneEntry {
205 Scene(Scene),
207 Include(IncludeDirective),
209}
210
211fn 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
258#[serde(deny_unknown_fields)]
259pub struct IncludeDirective {
260 pub include: String,
262 #[serde(default)]
264 pub scenes: Option<Vec<usize>>,
265 #[serde(default)]
267 pub config: Option<HashMap<String, serde_json::Value>>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct FontEntry {
280 #[serde(default)]
282 pub path: Option<String>,
283 pub family: String,
285 #[serde(default)]
288 pub source: Option<String>,
289 #[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#[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 #[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 #[serde(default)]
366 pub layout: Option<SceneLayout>,
367 #[serde(
369 default,
370 rename = "animated-background",
371 deserialize_with = "deserialize_animated_backgrounds"
372 )]
373 pub animated_background: Vec<AnimatedBackground>,
374 #[serde(default)]
376 pub camera: Option<Camera>,
377 #[serde(default, rename = "world-position")]
379 pub world_position: Option<WorldPosition>,
380 #[serde(default)]
382 pub persist: bool,
383 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub effects: Vec<PostEffect>,
387 #[serde(skip)]
389 #[schemars(skip)]
390 pub resolved_background: ResolvedBackground,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
395#[serde(deny_unknown_fields)]
396pub struct Camera {
397 #[serde(default)]
399 pub x: f32,
400 #[serde(default)]
402 pub y: f32,
403 #[serde(default = "default_camera_zoom")]
405 pub zoom: f32,
406 #[serde(default)]
408 pub rotation: f32,
409 #[serde(default)]
413 pub origin: Option<CameraOrigin>,
414 #[serde(default)]
416 pub keyframes: Vec<CameraKeyframe>,
417}
418
419#[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
429const 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471#[serde(deny_unknown_fields)]
472pub struct CameraKeyframe {
473 #[serde(deserialize_with = "deserialize_camera_property")]
477 pub property: String,
478 pub values: Vec<CameraKeyframePoint>,
480 #[serde(default)]
482 pub easing: EasingType,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
487#[serde(deny_unknown_fields)]
488pub struct CameraKeyframePoint {
489 pub time: f64,
491 pub value: f32,
493}
494
495fn default_camera_zoom() -> f32 {
496 1.0
497}
498
499#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
512#[serde(tag = "type", rename_all = "snake_case")]
513pub enum PostEffect {
514 Grain {
516 #[serde(default = "default_grain_intensity")]
518 intensity: f32,
519 #[serde(default = "default_grain_seed")]
521 seed: u64,
522 #[serde(default = "default_true")]
524 animated: bool,
525 },
526 Vignette {
528 #[serde(default = "default_vignette_intensity")]
530 intensity: f32,
531 #[serde(default = "default_vignette_radius")]
533 radius: f32,
534 },
535 Pixelate {
537 #[serde(default = "default_pixelate_size")]
539 size: u32,
540 },
541 ProgressiveBlur {
543 #[serde(default)]
545 direction: BlurDirection,
546 #[serde(default = "default_blur_start")]
548 start: f32,
549 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
598#[serde(rename_all = "snake_case")]
599pub enum PixelDissolveOrder {
600 #[default]
604 EdgesIn,
605 CenterOut,
607 Random,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
613#[serde(rename_all = "snake_case")]
614pub enum TransitionCorner {
615 #[default]
618 TopRight,
619 TopLeft,
620 BottomRight,
621 BottomLeft,
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
626#[serde(rename_all = "snake_case")]
627pub enum TransitionDirection {
628 #[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 #[serde(default)]
643 pub corner: TransitionCorner,
644 #[serde(default = "default_transition_cell")]
646 pub cell: f32,
647 #[serde(default = "default_transition_seed")]
650 pub seed: u32,
651 #[serde(default)]
653 pub order: PixelDissolveOrder,
654 #[serde(default)]
657 pub direction: TransitionDirection,
658 #[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 #[serde(default)]
671 pub background: PanBackground,
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
677#[serde(rename_all = "snake_case")]
678pub enum PanBackground {
679 #[default]
684 Static,
685 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 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
747fn 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#[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 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#[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 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#[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 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}