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#[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 #[serde(default)]
23 pub description: Option<String>,
24}
25
26#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct ComponentTemplateDef {
47 #[serde(default)]
54 pub params: HashMap<String, ComponentTemplateParam>,
55 pub template: serde_json::Value,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct ComponentTemplateParam {
67 #[serde(rename = "type")]
68 pub param_type: VariableType,
69 #[serde(default)]
71 pub default: Option<serde_json::Value>,
72 #[serde(default)]
73 pub description: Option<String>,
74}
75
76#[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 #[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 #[serde(default)]
102 pub composition: Option<Vec<View>>,
103 #[serde(default)]
105 pub config: Option<HashMap<String, VariableDefinition>>,
106 #[serde(default)]
108 pub backgrounds: HashMap<String, serde_json::Value>,
109 #[serde(default)]
128 pub components: HashMap<String, ComponentTemplateDef>,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub annotations: Vec<Annotation>,
133 #[serde(default)]
138 pub bpm: Option<f64>,
139 #[serde(default)]
143 pub beat_offset: f64,
144 #[serde(default)]
150 pub duration: Option<f64>,
151 #[serde(default)]
153 pub timing: TimingMode,
154 #[serde(default)]
156 pub snap: Option<SnapMode>,
157 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
271#[serde(rename_all = "snake_case")]
272pub enum TimingMode {
273 #[default]
279 V1,
280 V2,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum SnapMode {
295 Beat,
297}
298
299#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
312pub struct AnnotationTarget {
313 pub pointer: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub kind: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub rect: Option<[f32; 4]>,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
325pub struct Annotation {
326 pub id: String,
328 pub note: String,
330 #[serde(default)]
331 pub status: AnnotationStatus,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub frame: Option<u32>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub view: Option<usize>,
338 #[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 #[serde(default)]
368 pub transition: Option<Transition>,
369 #[serde(default, deserialize_with = "deserialize_background_value")]
371 pub background: Option<BackgroundValue>,
372 #[serde(
374 default,
375 rename = "animated-background",
376 deserialize_with = "deserialize_animated_backgrounds"
377 )]
378 pub animated_background: Vec<AnimatedBackground>,
379 #[serde(default = "default_transition_easing")]
381 pub camera_easing: EasingType,
382 #[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
426#[serde(untagged)]
427#[allow(clippy::large_enum_variant)]
428pub enum SceneEntry {
429 Scene(Scene),
431 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
460#[serde(deny_unknown_fields)]
461pub struct IncludeDirective {
462 pub include: String,
464 #[serde(default)]
466 pub scenes: Option<Vec<usize>>,
467 #[serde(default)]
469 pub config: Option<HashMap<String, serde_json::Value>>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
481pub struct FontEntry {
482 #[serde(default)]
484 pub path: Option<String>,
485 pub family: String,
487 #[serde(default)]
490 pub source: Option<String>,
491 #[serde(default)]
493 pub weights: Option<Vec<u16>>,
494}
495
496#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
553#[serde(deny_unknown_fields)]
554pub struct AudioConfig {
555 #[serde(default)]
556 pub tracks: Vec<AudioTrack>,
557 #[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#[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 #[serde(default, deserialize_with = "deserialize_background_value")]
645 pub background: Option<BackgroundValue>,
646 #[serde(default)]
647 pub children: Vec<serde_json::Value>,
648 #[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 #[serde(default)]
660 pub layout: Option<SceneLayout>,
661 #[serde(
663 default,
664 rename = "animated-background",
665 deserialize_with = "deserialize_animated_backgrounds"
666 )]
667 pub animated_background: Vec<AnimatedBackground>,
668 #[serde(default)]
670 pub camera: Option<Camera>,
671 #[serde(default)]
678 pub shake: Option<SceneShake>,
679 #[serde(default, rename = "world-position")]
681 pub world_position: Option<WorldPosition>,
682 #[serde(default)]
684 pub persist: bool,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub light: Option<SceneLight>,
694 #[serde(default, skip_serializing_if = "Vec::is_empty")]
697 pub effects: Vec<PostEffect>,
698 #[serde(default, skip_serializing_if = "SceneStart::is_auto")]
706 pub at: SceneStart,
707 #[serde(default, skip_serializing_if = "SceneTail::is_default_freeze")]
711 pub tail: SceneTail,
712 #[serde(skip)]
714 #[schemars(skip)]
715 pub resolved_background: ResolvedBackground,
716 #[serde(skip)]
725 #[schemars(skip)]
726 pub resolved_time_ctx: super::time::TimeCtx,
727 #[serde(skip)]
730 #[schemars(skip)]
731 pub resolved_timing: TimingMode,
732 #[serde(skip)]
735 #[schemars(skip)]
736 pub resolved_snap: Option<SnapMode>,
737 #[serde(default)]
745 pub vars: crate::vars::VarSet,
746 #[serde(skip)]
755 #[schemars(skip)]
756 pub resolved_scenario_vars: crate::vars::VarSet,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
764#[serde(rename_all = "snake_case")]
765pub enum SceneStartAuto {
766 Auto,
767}
768
769#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
779#[serde(untagged)]
780pub enum SceneStart {
781 Auto(SceneStartAuto),
784 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
832#[serde(rename_all = "snake_case")]
833pub enum SceneTail {
834 #[default]
837 Freeze,
838 Continue,
841}
842
843impl SceneTail {
844 fn is_default_freeze(&self) -> bool {
845 matches!(self, SceneTail::Freeze)
846 }
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
851#[serde(deny_unknown_fields)]
852pub struct Camera {
853 #[serde(default)]
855 pub x: f32,
856 #[serde(default)]
858 pub y: f32,
859 #[serde(default = "default_camera_zoom")]
861 pub zoom: f32,
862 #[serde(default)]
864 pub rotation: f32,
865 #[serde(default)]
869 pub origin: Option<CameraOrigin>,
870 #[serde(default = "default_camera_focus")]
875 pub focus: f32,
876 #[serde(default)]
886 pub aperture: f32,
887 #[serde(default)]
891 pub rotate_x: f32,
892 #[serde(default)]
894 pub rotate_y: f32,
895 #[serde(default)]
900 pub perspective: f32,
901 #[serde(default)]
903 pub keyframes: Vec<CameraKeyframe>,
904 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
970#[serde(deny_unknown_fields)]
971pub struct CameraMotionBlurConfig {
972 #[serde(default = "default_camera_motion_blur_samples")]
975 pub samples: u32,
976 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
994#[serde(deny_unknown_fields)]
995pub struct SceneLight {
996 #[serde(default = "default_light_x")]
999 pub x: f32,
1000 #[serde(default = "default_light_y")]
1005 pub y: f32,
1006 #[serde(default = "default_light_intensity")]
1009 pub intensity: f32,
1010 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1094#[serde(deny_unknown_fields)]
1095pub struct CameraKeyframe {
1096 #[serde(deserialize_with = "deserialize_camera_property")]
1100 pub property: String,
1101 pub values: Vec<CameraKeyframePoint>,
1103 #[serde(default)]
1105 pub easing: EasingType,
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1110#[serde(deny_unknown_fields)]
1111pub struct CameraKeyframePoint {
1112 pub time: f64,
1114 pub value: f32,
1116 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1144#[serde(tag = "type", rename_all = "snake_case")]
1145pub enum PostEffect {
1146 Grain {
1148 #[serde(default = "default_grain_intensity")]
1150 intensity: f32,
1151 #[serde(default = "default_grain_seed")]
1153 seed: u64,
1154 #[serde(default = "default_true")]
1156 animated: bool,
1157 },
1158 Vignette {
1160 #[serde(default = "default_vignette_intensity")]
1162 intensity: f32,
1163 #[serde(default = "default_vignette_radius")]
1165 radius: f32,
1166 },
1167 Pixelate {
1169 #[serde(default = "default_pixelate_size")]
1171 size: u32,
1172 },
1173 ProgressiveBlur {
1175 #[serde(default)]
1177 direction: BlurDirection,
1178 #[serde(default = "default_blur_start")]
1180 start: f32,
1181 #[serde(default = "default_blur_max_radius")]
1183 max_radius: f32,
1184 },
1185 Flash {
1194 at: TimePoint,
1196 #[serde(default = "default_flash_color")]
1198 color: String,
1199 #[serde(default = "default_flash_intensity")]
1201 intensity: f32,
1202 #[serde(default = "default_flash_duration")]
1204 duration: f32,
1205 },
1206 Vhs {
1212 at: TimePoint,
1214 #[serde(default = "default_vhs_duration")]
1216 duration: f32,
1217 #[serde(default = "default_vhs_bands")]
1219 bands: u32,
1220 #[serde(default = "default_vhs_offset")]
1222 offset: f32,
1223 #[serde(default = "default_vhs_noise")]
1225 noise: f32,
1226 #[serde(default = "default_vhs_scanlines")]
1228 scanlines: f32,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 tracking_line: Option<VhsTrackingLine>,
1233 #[serde(default = "default_vhs_seed")]
1236 seed: u64,
1237 },
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1242#[serde(deny_unknown_fields)]
1243pub struct VhsTrackingLine {
1244 #[serde(default = "default_vhs_tracking_color")]
1246 pub color: String,
1247 #[serde(default = "default_vhs_tracking_speed")]
1249 pub speed: f32,
1250 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1335#[serde(rename_all = "snake_case")]
1336pub enum PixelDissolveOrder {
1337 #[default]
1341 EdgesIn,
1342 CenterOut,
1344 Random,
1346}
1347
1348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1350#[serde(rename_all = "snake_case")]
1351pub enum TransitionCorner {
1352 #[default]
1355 TopRight,
1356 TopLeft,
1357 BottomRight,
1358 BottomLeft,
1359}
1360
1361#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1363#[serde(rename_all = "snake_case")]
1364pub enum TransitionDirection {
1365 #[default]
1367 Left,
1368 Right,
1369 Up,
1370 Down,
1371}
1372
1373#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1377#[serde(deny_unknown_fields)]
1378pub struct ZoomBlurOrigin {
1379 #[serde(default)]
1381 pub x: f32,
1382 #[serde(default)]
1384 pub y: f32,
1385}
1386
1387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1389#[serde(rename_all = "snake_case")]
1390pub enum IrisShape {
1391 #[default]
1393 Circle,
1394 Pill,
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1403#[serde(deny_unknown_fields)]
1404pub struct IrisRing {
1405 pub color: String,
1407 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1429#[serde(tag = "kind", rename_all = "kebab-case")]
1430pub enum MaskShape {
1431 Polygon { points: Vec<(f32, f32)> },
1433 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 #[serde(default)]
1456 pub corner: TransitionCorner,
1457 #[serde(default = "default_transition_cell")]
1459 pub cell: f32,
1460 #[serde(default = "default_transition_seed")]
1465 pub seed: u32,
1466 #[serde(default)]
1468 pub order: PixelDissolveOrder,
1469 #[serde(default)]
1473 pub direction: TransitionDirection,
1474 #[serde(default = "default_transition_aberration")]
1478 pub aberration: f32,
1479 #[serde(default = "default_transition_strength")]
1484 pub strength: f32,
1485 #[serde(default)]
1491 pub origin: Option<ZoomBlurOrigin>,
1492 #[serde(default)]
1494 pub shape: IrisShape,
1495 #[serde(default = "default_iris_aspect")]
1498 pub aspect: f32,
1499 #[serde(default)]
1504 pub fill: Option<String>,
1505 #[serde(default)]
1509 pub hold: f32,
1510 #[serde(default)]
1513 pub ring: Option<IrisRing>,
1514 #[serde(default)]
1519 pub reverse: bool,
1520 #[serde(default)]
1525 pub silhouette: Option<MaskShape>,
1526 #[serde(default)]
1533 pub from_scale: f32,
1534 #[serde(default = "default_mask_to_scale")]
1540 pub to_scale: f32,
1541 #[serde(default = "default_blob_lobes")]
1544 pub lobes: u32,
1545 #[serde(default = "default_blob_wobble")]
1550 pub wobble: f32,
1551 #[serde(default)]
1556 pub feather: f32,
1557 #[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 #[serde(default)]
1574 pub background: PanBackground,
1575}
1576
1577#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
1580#[serde(rename_all = "snake_case")]
1581pub enum PanBackground {
1582 #[default]
1587 Static,
1588 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 Iris,
1612 Slide,
1613 Dissolve,
1614 CornerReveal,
1615 PixelDissolve,
1616 CameraPan,
1617 ChromaticWipe,
1621 ZoomBlur,
1627 Whip,
1634 Mask,
1642 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}