use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::animation::EasingType;
use super::background::{
deserialize_animated_backgrounds, deserialize_background_value, AnimatedBackground,
BackgroundValue, ResolvedBackground,
};
use super::shake::SceneShake;
use super::style::{CardAlign, CardDirection, CardJustify};
use super::time::TimePoint;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VariableDefinition {
#[serde(rename = "type")]
pub var_type: VariableType,
pub default: serde_json::Value,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum VariableType {
String,
Number,
Boolean,
Object,
Array,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComponentTemplateDef {
#[serde(default)]
pub params: HashMap<String, ComponentTemplateParam>,
pub template: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComponentTemplateParam {
#[serde(rename = "type")]
pub param_type: VariableType,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
#[serde(default = "default_version")]
pub version: String,
pub video: VideoConfig,
#[serde(default)]
pub audio: AudioValue,
#[serde(default)]
pub fonts: Vec<FontEntry>,
#[serde(default, deserialize_with = "deserialize_scene_entries")]
pub scenes: Vec<SceneEntry>,
#[serde(default)]
pub composition: Option<Vec<View>>,
#[serde(default)]
pub config: Option<HashMap<String, VariableDefinition>>,
#[serde(default)]
pub backgrounds: HashMap<String, serde_json::Value>,
#[serde(default)]
pub components: HashMap<String, ComponentTemplateDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
#[serde(default)]
pub bpm: Option<f64>,
#[serde(default)]
pub beat_offset: f64,
#[serde(default)]
pub duration: Option<f64>,
#[serde(default)]
pub timing: TimingMode,
#[serde(default)]
pub snap: Option<SnapMode>,
#[serde(default)]
pub vars: crate::vars::VarSet,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScenarioDe {
#[serde(default = "default_version")]
version: String,
video: VideoConfig,
#[serde(default)]
audio: AudioValue,
#[serde(default)]
fonts: Vec<FontEntry>,
#[serde(default, deserialize_with = "deserialize_scene_entries")]
scenes: Vec<SceneEntry>,
#[serde(default)]
composition: Option<Vec<View>>,
#[serde(default)]
config: Option<HashMap<String, VariableDefinition>>,
#[serde(default)]
backgrounds: HashMap<String, serde_json::Value>,
#[serde(default)]
components: HashMap<String, ComponentTemplateDef>,
#[serde(default)]
annotations: Vec<Annotation>,
#[serde(default)]
bpm: Option<f64>,
#[serde(default)]
beat_offset: f64,
#[serde(default)]
duration: Option<f64>,
#[serde(default)]
timing: TimingMode,
#[serde(default)]
snap: Option<SnapMode>,
#[serde(default)]
vars: crate::vars::VarSet,
}
impl<'de> Deserialize<'de> for Scenario {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = ScenarioDe::deserialize(deserializer)?;
let mut scenario = Scenario {
version: raw.version,
video: raw.video,
audio: raw.audio,
fonts: raw.fonts,
scenes: raw.scenes,
composition: raw.composition,
config: raw.config,
backgrounds: raw.backgrounds,
components: raw.components,
annotations: raw.annotations,
bpm: raw.bpm,
beat_offset: raw.beat_offset,
duration: raw.duration,
timing: raw.timing,
snap: raw.snap,
vars: raw.vars,
};
scenario.propagate_time_ctx();
Ok(scenario)
}
}
impl Scenario {
fn propagate_time_ctx(&mut self) {
let ctx = super::time::TimeCtx {
bpm: self.bpm,
beat_offset: self.beat_offset,
scene_start: 0.0,
};
let timing = self.timing;
let snap = self.snap;
let scenario_vars = self.vars.clone();
stamp_entries(&mut self.scenes, ctx, timing, snap, &scenario_vars);
if let Some(views) = &mut self.composition {
for view in views {
stamp_entries(&mut view.scenes, ctx, timing, snap, &scenario_vars);
}
}
}
}
fn stamp_entries(
entries: &mut [SceneEntry],
ctx: super::time::TimeCtx,
timing: TimingMode,
snap: Option<SnapMode>,
scenario_vars: &crate::vars::VarSet,
) {
for entry in entries {
if let SceneEntry::Scene(scene) = entry {
scene.resolved_time_ctx = ctx;
scene.resolved_timing = timing;
scene.resolved_snap = snap;
scene.resolved_scenario_vars = scenario_vars.clone();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TimingMode {
#[default]
V1,
V2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SnapMode {
Beat,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AnnotationStatus {
#[default]
Open,
Resolved,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AnnotationTarget {
pub pointer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rect: Option<[f32; 4]>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Annotation {
pub id: String,
pub note: String,
#[serde(default)]
pub status: AnnotationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scene: Option<usize>,
pub target: AnnotationTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewType {
Slide,
World,
}
fn default_view_type() -> ViewType {
ViewType::Slide
}
fn default_camera_pan_duration() -> f64 {
0.8
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct View {
#[serde(rename = "type", default = "default_view_type")]
pub view_type: ViewType,
#[serde(default, deserialize_with = "deserialize_scene_entries")]
pub scenes: Vec<SceneEntry>,
#[serde(default)]
pub transition: Option<Transition>,
#[serde(default, deserialize_with = "deserialize_background_value")]
pub background: Option<BackgroundValue>,
#[serde(
default,
rename = "animated-background",
deserialize_with = "deserialize_animated_backgrounds"
)]
pub animated_background: Vec<AnimatedBackground>,
#[serde(default = "default_transition_easing")]
pub camera_easing: EasingType,
#[serde(default = "default_camera_pan_duration")]
pub camera_pan_duration: f64,
}
#[derive(Debug)]
pub struct ResolvedScenario {
pub video: VideoConfig,
pub audio: Vec<AudioTrack>,
pub fonts: Vec<FontEntry>,
pub views: Vec<ResolvedView>,
pub included_paths: Vec<std::path::PathBuf>,
}
impl ResolvedScenario {
pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
self.views.iter().flat_map(|v| v.scenes.iter())
}
#[allow(dead_code)]
pub fn all_scenes_vec(&self) -> Vec<&Scene> {
self.all_scenes().collect()
}
}
#[derive(Debug)]
pub struct ResolvedView {
pub view_type: ViewType,
pub scenes: Vec<Scene>,
pub transition: Option<Transition>,
pub background: ResolvedBackground,
pub camera_easing: EasingType,
pub camera_pan_duration: f64,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum SceneEntry {
Scene(Scene),
Include(IncludeDirective),
}
fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result<Vec<SceneEntry>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
let mut out = Vec::with_capacity(raw.len());
for (i, entry) in raw.into_iter().enumerate() {
let is_include = entry.get("include").is_some();
if is_include {
let directive: IncludeDirective = serde_json::from_value(entry)
.map_err(|e| D::Error::custom(format!("scenes[{i}] (include directive): {e}")))?;
out.push(SceneEntry::Include(directive));
} else {
let scene: Scene = serde_json::from_value(entry)
.map_err(|e| D::Error::custom(format!("scenes[{i}]: {e}")))?;
out.push(SceneEntry::Scene(scene));
}
}
Ok(out)
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncludeDirective {
pub include: String,
#[serde(default)]
pub scenes: Option<Vec<usize>>,
#[serde(default)]
pub config: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FontEntry {
#[serde(default)]
pub path: Option<String>,
pub family: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub weights: Option<Vec<u16>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum AudioValue {
Tracks(Vec<AudioTrack>),
Config(Box<AudioConfig>),
}
impl Default for AudioValue {
fn default() -> Self {
AudioValue::Tracks(Vec::new())
}
}
impl AudioValue {
pub fn tracks(&self) -> &[AudioTrack] {
match self {
AudioValue::Tracks(tracks) => tracks,
AudioValue::Config(config) => &config.tracks,
}
}
pub fn into_tracks(self) -> Vec<AudioTrack> {
match self {
AudioValue::Tracks(tracks) => tracks,
AudioValue::Config(config) => config.tracks,
}
}
pub fn config(&self) -> Option<&AudioConfig> {
match self {
AudioValue::Tracks(_) => None,
AudioValue::Config(config) => Some(config),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AudioConfig {
#[serde(default)]
pub tracks: Vec<AudioTrack>,
#[serde(default)]
pub bpm: Option<f64>,
#[serde(default)]
pub beat_offset: Option<f64>,
#[serde(default)]
pub voices: HashMap<String, crate::audio::Voice>,
#[serde(default)]
pub score: Vec<crate::audio::ScoreEvent>,
#[serde(default)]
pub master: crate::audio::MasterBus,
}
impl AudioConfig {
pub fn has_synth(&self) -> bool {
!self.voices.is_empty() || !self.score.is_empty()
}
pub fn as_score(&self) -> crate::audio::Score {
crate::audio::Score {
voices: self.voices.clone(),
score: self.score.clone(),
master: self.master.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AudioTrack {
pub src: String,
#[serde(default)]
pub start: f64,
#[serde(default)]
pub end: Option<f64>,
#[serde(default = "default_volume")]
pub volume: f32,
#[serde(default)]
pub fade_in: Option<f64>,
#[serde(default)]
pub fade_out: Option<f64>,
#[serde(default)]
pub volume_keyframes: Vec<VolumeKeyframe>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VolumeKeyframe {
pub time: f64,
pub volume: f32,
#[serde(default)]
pub easing: EasingType,
}
fn default_volume() -> f32 {
1.0
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VideoConfig {
pub width: u32,
pub height: u32,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_background")]
pub background: String,
#[serde(default)]
pub codec: Option<VideoCodec>,
#[serde(default)]
pub crf: Option<u8>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct WorldPosition {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scene {
pub duration: f64,
#[serde(default, deserialize_with = "deserialize_background_value")]
pub background: Option<BackgroundValue>,
#[serde(default)]
pub children: Vec<serde_json::Value>,
#[serde(skip)]
#[schemars(skip)]
pub prepared_children: std::sync::OnceLock<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
#[serde(default)]
pub transition: Option<Transition>,
#[serde(default)]
pub freeze_at: Option<f64>,
#[serde(default)]
pub layout: Option<SceneLayout>,
#[serde(
default,
rename = "animated-background",
deserialize_with = "deserialize_animated_backgrounds"
)]
pub animated_background: Vec<AnimatedBackground>,
#[serde(default)]
pub camera: Option<Camera>,
#[serde(default)]
pub shake: Option<SceneShake>,
#[serde(default, rename = "world-position")]
pub world_position: Option<WorldPosition>,
#[serde(default)]
pub persist: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub light: Option<SceneLight>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<PostEffect>,
#[serde(default, skip_serializing_if = "SceneStart::is_auto")]
pub at: SceneStart,
#[serde(default, skip_serializing_if = "SceneTail::is_default_freeze")]
pub tail: SceneTail,
#[serde(skip)]
#[schemars(skip)]
pub resolved_background: ResolvedBackground,
#[serde(skip)]
#[schemars(skip)]
pub resolved_time_ctx: super::time::TimeCtx,
#[serde(skip)]
#[schemars(skip)]
pub resolved_timing: TimingMode,
#[serde(skip)]
#[schemars(skip)]
pub resolved_snap: Option<SnapMode>,
#[serde(default)]
pub vars: crate::vars::VarSet,
#[serde(skip)]
#[schemars(skip)]
pub resolved_scenario_vars: crate::vars::VarSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SceneStartAuto {
Auto,
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum SceneStart {
Auto(SceneStartAuto),
At(TimePoint),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SceneStartDe {
Auto(SceneStartAuto),
At(TimePoint),
}
impl<'de> Deserialize<'de> for SceneStart {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
match SceneStartDe::deserialize(deserializer)? {
SceneStartDe::Auto(a) => Ok(SceneStart::Auto(a)),
SceneStartDe::At(tp) => {
tp.validate_grammar().map_err(D::Error::custom)?;
Ok(SceneStart::At(tp))
}
}
}
}
impl Default for SceneStart {
fn default() -> Self {
SceneStart::Auto(SceneStartAuto::Auto)
}
}
impl SceneStart {
fn is_auto(&self) -> bool {
matches!(self, SceneStart::Auto(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SceneTail {
#[default]
Freeze,
Continue,
}
impl SceneTail {
fn is_default_freeze(&self) -> bool {
matches!(self, SceneTail::Freeze)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Camera {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
#[serde(default = "default_camera_zoom")]
pub zoom: f32,
#[serde(default)]
pub rotation: f32,
#[serde(default)]
pub origin: Option<CameraOrigin>,
#[serde(default = "default_camera_focus")]
pub focus: f32,
#[serde(default)]
pub aperture: f32,
#[serde(default)]
pub rotate_x: f32,
#[serde(default)]
pub rotate_y: f32,
#[serde(default)]
pub perspective: f32,
#[serde(default)]
pub keyframes: Vec<CameraKeyframe>,
#[serde(default)]
pub motion_blur: Option<CameraMotionBlurConfig>,
}
impl Camera {
pub fn resolve_property(&self, property: &str, time: f64) -> f32 {
let track = self
.keyframes
.iter()
.find(|k| k.property == property)
.filter(|t| !t.values.is_empty());
let Some(track) = track else {
return self.static_property(property);
};
let points = &track.values;
if time <= points[0].time {
return points[0].value;
}
if time >= points[points.len() - 1].time {
return points[points.len() - 1].value;
}
for i in 0..points.len() - 1 {
let p0 = &points[i];
let p1 = &points[i + 1];
if time >= p0.time && time <= p1.time {
let segment_duration = p1.time - p0.time;
let segment_t = if segment_duration.abs() < 1e-9 {
1.0
} else {
(time - p0.time) / segment_duration
};
let segment_easing = p0.easing.as_ref().unwrap_or(&track.easing);
let eased = crate::engine::animator::ease(segment_t, segment_easing) as f32;
return p0.value + (p1.value - p0.value) * eased;
}
}
points[points.len() - 1].value
}
fn static_property(&self, property: &str) -> f32 {
match property {
"x" => self.x,
"y" => self.y,
"zoom" => self.zoom,
"rotation" => self.rotation,
"focus" => self.focus,
"aperture" => self.aperture,
"rotate_x" => self.rotate_x,
"rotate_y" => self.rotate_y,
"perspective" => self.perspective,
"origin.x" => self.origin.as_ref().map(|o| o.x).unwrap_or(0.0),
"origin.y" => self.origin.as_ref().map(|o| o.y).unwrap_or(0.0),
_ => 0.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraMotionBlurConfig {
#[serde(default = "default_camera_motion_blur_samples")]
pub samples: u32,
#[serde(default = "default_camera_motion_blur_shutter")]
pub shutter: f64,
}
fn default_camera_motion_blur_samples() -> u32 {
8
}
fn default_camera_motion_blur_shutter() -> f64 {
0.5
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneLight {
#[serde(default = "default_light_x")]
pub x: f32,
#[serde(default = "default_light_y")]
pub y: f32,
#[serde(default = "default_light_intensity")]
pub intensity: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
fn default_light_x() -> f32 {
-0.35
}
fn default_light_y() -> f32 {
-0.8
}
fn default_light_intensity() -> f32 {
1.0
}
impl Default for SceneLight {
fn default() -> Self {
SceneLight {
x: default_light_x(),
y: default_light_y(),
intensity: default_light_intensity(),
color: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraOrigin {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
const KNOWN_CAMERA_PROPERTIES: &[&str] = &[
"x",
"y",
"zoom",
"rotation",
"origin.x",
"origin.y",
"focus",
"aperture",
"rotate_x",
"rotate_y",
"perspective",
];
fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
if KNOWN_CAMERA_PROPERTIES.contains(&value) {
return Ok(());
}
let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase();
let normalized = normalize(value);
if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES
.iter()
.find(|known| normalize(known) == normalized)
{
Err(E::custom(format!(
"unknown camera keyframe property '{value}' — did you mean '{suggestion}'?"
)))
} else {
Err(E::custom(format!(
"unknown camera keyframe property '{value}': expected one of {}",
KNOWN_CAMERA_PROPERTIES.join(", ")
)))
}
}
fn deserialize_camera_property<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
validate_camera_property::<D::Error>(&s)?;
Ok(s)
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframe {
#[serde(deserialize_with = "deserialize_camera_property")]
pub property: String,
pub values: Vec<CameraKeyframePoint>,
#[serde(default)]
pub easing: EasingType,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframePoint {
pub time: f64,
pub value: f32,
#[serde(default)]
pub easing: Option<EasingType>,
}
fn default_camera_focus() -> f32 {
1.0
}
fn default_camera_zoom() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BlurDirection {
Top,
#[default]
Bottom,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PostEffect {
Grain {
#[serde(default = "default_grain_intensity")]
intensity: f32,
#[serde(default = "default_grain_seed")]
seed: u64,
#[serde(default = "default_true")]
animated: bool,
},
Vignette {
#[serde(default = "default_vignette_intensity")]
intensity: f32,
#[serde(default = "default_vignette_radius")]
radius: f32,
},
Pixelate {
#[serde(default = "default_pixelate_size")]
size: u32,
},
ProgressiveBlur {
#[serde(default)]
direction: BlurDirection,
#[serde(default = "default_blur_start")]
start: f32,
#[serde(default = "default_blur_max_radius")]
max_radius: f32,
},
Flash {
at: TimePoint,
#[serde(default = "default_flash_color")]
color: String,
#[serde(default = "default_flash_intensity")]
intensity: f32,
#[serde(default = "default_flash_duration")]
duration: f32,
},
Vhs {
at: TimePoint,
#[serde(default = "default_vhs_duration")]
duration: f32,
#[serde(default = "default_vhs_bands")]
bands: u32,
#[serde(default = "default_vhs_offset")]
offset: f32,
#[serde(default = "default_vhs_noise")]
noise: f32,
#[serde(default = "default_vhs_scanlines")]
scanlines: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
tracking_line: Option<VhsTrackingLine>,
#[serde(default = "default_vhs_seed")]
seed: u64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VhsTrackingLine {
#[serde(default = "default_vhs_tracking_color")]
pub color: String,
#[serde(default = "default_vhs_tracking_speed")]
pub speed: f32,
#[serde(default = "default_vhs_tracking_thickness")]
pub thickness: f32,
}
fn default_vhs_duration() -> f32 {
0.5
}
fn default_vhs_bands() -> u32 {
12
}
fn default_vhs_offset() -> f32 {
40.0
}
fn default_vhs_noise() -> f32 {
0.25
}
fn default_vhs_scanlines() -> f32 {
0.2
}
fn default_vhs_seed() -> u64 {
1
}
fn default_vhs_tracking_color() -> String {
"#3DA5FF".to_string()
}
fn default_vhs_tracking_speed() -> f32 {
1.5
}
fn default_vhs_tracking_thickness() -> f32 {
3.0
}
fn default_grain_intensity() -> f32 {
0.15
}
fn default_grain_seed() -> u64 {
42
}
fn default_true() -> bool {
true
}
fn default_vignette_intensity() -> f32 {
0.5
}
fn default_vignette_radius() -> f32 {
0.75
}
fn default_pixelate_size() -> u32 {
8
}
fn default_blur_start() -> f32 {
0.5
}
fn default_blur_max_radius() -> f32 {
12.0
}
fn default_flash_color() -> String {
"#FFFFFF".to_string()
}
fn default_flash_intensity() -> f32 {
0.6
}
fn default_flash_duration() -> f32 {
0.15
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneLayout {
#[serde(default)]
pub direction: Option<CardDirection>,
#[serde(default)]
pub gap: Option<f32>,
#[serde(default)]
pub align_items: Option<CardAlign>,
#[serde(default)]
pub justify_content: Option<CardJustify>,
#[serde(default)]
pub padding: Option<f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelDissolveOrder {
#[default]
EdgesIn,
CenterOut,
Random,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionCorner {
#[default]
TopRight,
TopLeft,
BottomRight,
BottomLeft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionDirection {
#[default]
Left,
Right,
Up,
Down,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ZoomBlurOrigin {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum IrisShape {
#[default]
Circle,
Pill,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IrisRing {
pub color: String,
#[serde(default = "default_iris_ring_width")]
pub width: f32,
}
fn default_iris_ring_width() -> f32 {
12.0
}
fn default_iris_aspect() -> f32 {
1.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum MaskShape {
Polygon { points: Vec<(f32, f32)> },
Path { d: String },
}
fn default_mask_to_scale() -> f32 {
20.0
}
fn default_blob_lobes() -> u32 {
8
}
fn default_blob_wobble() -> f32 {
0.15
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Transition {
#[serde(rename = "type")]
pub transition_type: TransitionType,
#[serde(default)]
pub corner: TransitionCorner,
#[serde(default = "default_transition_cell")]
pub cell: f32,
#[serde(default = "default_transition_seed")]
pub seed: u32,
#[serde(default)]
pub order: PixelDissolveOrder,
#[serde(default)]
pub direction: TransitionDirection,
#[serde(default = "default_transition_aberration")]
pub aberration: f32,
#[serde(default = "default_transition_strength")]
pub strength: f32,
#[serde(default)]
pub origin: Option<ZoomBlurOrigin>,
#[serde(default)]
pub shape: IrisShape,
#[serde(default = "default_iris_aspect")]
pub aspect: f32,
#[serde(default)]
pub fill: Option<String>,
#[serde(default)]
pub hold: f32,
#[serde(default)]
pub ring: Option<IrisRing>,
#[serde(default)]
pub reverse: bool,
#[serde(default)]
pub silhouette: Option<MaskShape>,
#[serde(default)]
pub from_scale: f32,
#[serde(default = "default_mask_to_scale")]
pub to_scale: f32,
#[serde(default = "default_blob_lobes")]
pub lobes: u32,
#[serde(default = "default_blob_wobble")]
pub wobble: f32,
#[serde(default)]
pub feather: f32,
#[serde(default)]
pub band_color: Option<String>,
#[serde(default = "default_transition_duration")]
pub duration: f64,
#[serde(default = "default_transition_easing")]
pub easing: EasingType,
#[serde(default)]
pub background: PanBackground,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PanBackground {
#[default]
Static,
Travel,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionType {
Fade,
WipeLeft,
WipeRight,
WipeUp,
WipeDown,
ZoomIn,
ZoomOut,
Flip,
ClockWipe,
Iris,
Slide,
Dissolve,
CornerReveal,
PixelDissolve,
CameraPan,
ChromaticWipe,
ZoomBlur,
Whip,
Mask,
Blob,
None,
}
fn default_transition_cell() -> f32 {
48.0
}
fn default_transition_seed() -> u32 {
11
}
fn default_transition_aberration() -> f32 {
1.0
}
fn default_transition_strength() -> f32 {
1.0
}
fn default_transition_duration() -> f64 {
0.5
}
pub(crate) fn default_transition_easing() -> EasingType {
EasingType::EaseInOut
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum VideoCodec {
#[default]
H264,
H265,
Vp9,
Prores,
}
fn default_version() -> String {
"1.0".to_string()
}
fn default_fps() -> u32 {
30
}
fn default_background() -> String {
"#000000".to_string()
}
#[cfg(test)]
mod annotation_tests {
use super::*;
const MINIMAL: &str = r#"{ "video": { "width": 1920, "height": 1080 }, "scenes": [] }"#;
const WITH_ANNOTATIONS: &str = r#"{
"video": { "width": 1920, "height": 1080 },
"scenes": [],
"annotations": [
{
"id": "an_4f2a",
"note": "reduce font-size",
"status": "open",
"frame": 142,
"view": 0,
"scene": 2,
"target": {
"pointer": "/scenes/2/children/5",
"kind": "text",
"rect": [10.0, 20.0, 30.0, 40.0]
}
}
]
}"#;
#[test]
fn scenario_without_annotations_defaults_empty() {
let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
assert!(s.annotations.is_empty());
}
#[test]
fn scenario_with_annotations_deserializes() {
let s: Scenario = serde_json::from_str(WITH_ANNOTATIONS).unwrap();
assert_eq!(s.annotations.len(), 1);
let a = &s.annotations[0];
assert_eq!(a.id, "an_4f2a");
assert_eq!(a.status, AnnotationStatus::Open);
assert_eq!(a.frame, Some(142));
assert_eq!(a.view, Some(0));
assert_eq!(a.scene, Some(2));
assert_eq!(a.target.pointer, "/scenes/2/children/5");
assert_eq!(a.target.kind.as_deref(), Some("text"));
assert_eq!(a.target.rect, Some([10.0, 20.0, 30.0, 40.0]));
}
#[test]
fn empty_annotations_are_not_serialized() {
let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
let json = serde_json::to_string(&s).unwrap();
assert!(
!json.contains("annotations"),
"empty annotations must be skipped, got: {json}"
);
}
#[test]
fn status_defaults_to_open_and_target_fields_optional() {
let json = r#"{
"video": { "width": 1, "height": 1 },
"scenes": [],
"annotations": [ { "id": "x", "note": "n", "target": { "pointer": "/scenes/0" } } ]
}"#;
let s: Scenario = serde_json::from_str(json).unwrap();
assert_eq!(s.annotations[0].status, AnnotationStatus::Open);
assert_eq!(s.annotations[0].target.kind, None);
assert_eq!(s.annotations[0].target.rect, None);
assert_eq!(s.annotations[0].frame, None);
}
}
#[cfg(test)]
mod scene_entry_error_tests {
use super::*;
#[test]
fn missing_duration_names_the_scene_index_and_field_not_the_untagged_message() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "duration": 1.0, "children": [] },
{ "children": [] }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("missing duration must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"must not regress to the opaque untagged message: {msg}"
);
assert!(
msg.contains("scenes[1]"),
"must name the offending scene index: {msg}"
);
assert!(
msg.contains("duration"),
"must name the missing field: {msg}"
);
}
#[test]
fn misspelled_transition_type_names_itself() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{
"duration": 1.0,
"children": [],
"transition": { "type": "wip_left" }
}
]
}"#;
let err =
serde_json::from_str::<Scenario>(json).expect_err("bad transition type must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"must not regress to the opaque untagged message: {msg}"
);
assert!(
msg.contains("scenes[0]"),
"must name the offending scene index: {msg}"
);
assert!(
msg.contains("wip_left"),
"must echo the bad value so the author can spot the typo: {msg}"
);
}
#[test]
fn include_directive_still_works() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "include": "does/not/matter.json" }
]
}"#;
let s: Scenario = serde_json::from_str(json).expect("include entry must parse");
assert!(matches!(s.scenes[0], SceneEntry::Include(_)));
}
#[test]
fn broken_include_directive_names_itself() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [
{ "include": "x.json", "scenes": "not-an-array" }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
let msg = err.to_string();
assert!(msg.contains("scenes[0]"), "got: {msg}");
assert!(msg.contains("include directive"), "got: {msg}");
}
#[test]
fn view_scenes_field_uses_the_same_precise_errors() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"composition": [
{ "type": "slide", "scenes": [ { "children": [] } ] }
]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
let msg = err.to_string();
assert!(
!msg.contains("did not match any variant of untagged enum"),
"got: {msg}"
);
assert!(msg.contains("scenes[0]"), "got: {msg}");
assert!(msg.contains("duration"), "got: {msg}");
}
}
#[cfg(test)]
mod strict_schema_tests {
use super::*;
#[test]
fn misspelled_scene_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ { "durration": 3.0, "children": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("durration"), "got: {err}");
}
#[test]
fn misspelled_video_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100, "framerate": 30 },
"scenes": [ { "duration": 1.0, "children": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("framerate"), "got: {err}");
}
#[test]
fn misspelled_top_level_scenario_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ { "duration": 1.0, "children": [] } ],
"titel": "typo"
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("titel"), "got: {err}");
}
#[test]
fn misspelled_camera_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ {
"duration": 1.0,
"children": [],
"camera": { "zooom": 1.5 }
} ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("zooom"), "got: {err}");
}
#[test]
fn misspelled_scene_layout_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ {
"duration": 1.0,
"children": [],
"layout": { "gapp": 10 }
} ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("gapp"), "got: {err}");
}
#[test]
fn misspelled_view_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"composition": [ { "typ": "slide", "scenes": [] } ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("typ"), "got: {err}");
}
#[test]
fn valid_scenario_with_every_covered_struct_still_parses() {
let json = r##"{
"version": "1.0",
"video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" },
"scenes": [ {
"duration": 1.0,
"children": [],
"layout": { "direction": "column", "gap": 10, "align_items": "center", "justify_content": "center", "padding": 5 },
"transition": { "type": "fade", "duration": 0.5, "easing": "ease_in_out" },
"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" } ] }
} ]
}"##;
let s: Scenario = serde_json::from_str(json).expect("valid scenario must still parse");
assert_eq!(s.scenes.len(), 1);
}
}
#[cfg(test)]
mod camera_keyframe_property_tests {
use super::*;
#[test]
fn known_camera_properties_still_work() {
for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] {
let json = format!(
r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"#
);
let kf: CameraKeyframe = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}"));
assert_eq!(kf.property, prop);
}
}
#[test]
fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() {
let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
let err = serde_json::from_str::<CameraKeyframe>(json).expect_err(
"an unrecognised camera keyframe property must be rejected, not silently inert",
);
assert!(err.to_string().contains("tilt"), "got: {err}");
}
#[test]
fn misspelled_origin_property_is_a_named_error() {
let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
let err = serde_json::from_str::<CameraKeyframe>(json)
.expect_err("origin_x must be rejected — the real property is origin.x");
let msg = err.to_string();
assert!(msg.contains("origin_x"), "got: {msg}");
assert!(
msg.contains("origin.x"),
"expected a did-you-mean nudge toward origin.x, got: {msg}"
);
}
}
#[cfg(test)]
mod scene_at_grammar_tests {
use super::*;
fn scenario_with_at(at: &str) -> String {
format!(
r#"{{
"video": {{ "width": 100, "height": 100 }},
"scenes": [
{{ "duration": 1.0, "children": [] }},
{{ "duration": 1.0, "children": [], "at": {at} }}
]
}}"#
)
}
#[test]
fn syntactically_malformed_at_is_rejected_at_deserialize_time() {
let json = scenario_with_at(r#""banana""#);
let err = serde_json::from_str::<Scenario>(&json)
.expect_err("a malformed `at` must fail to deserialize, not fall back silently");
let msg = err.to_string();
assert!(
msg.contains("cannot parse time"),
"expected the grammar error to surface, got: {msg}"
);
assert!(msg.contains("banana"), "got: {msg}");
}
#[test]
fn grammatically_valid_beat_unit_deserializes_even_with_no_bpm() {
let json = scenario_with_at(r#""@8b""#);
let scenario: Scenario =
serde_json::from_str(&json).expect("grammar-valid `at` must deserialize");
let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
panic!("expected a Scene entry");
};
assert!(matches!(scene.at, SceneStart::At(TimePoint::Spec(ref s)) if s == "@8b"));
}
#[test]
fn auto_and_plain_seconds_still_deserialize() {
let json = scenario_with_at(r#""auto""#);
let scenario: Scenario = serde_json::from_str(&json).expect("auto must deserialize");
let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
panic!("expected a Scene entry");
};
assert!(matches!(scene.at, SceneStart::Auto(_)));
let json = scenario_with_at("2.5");
let scenario: Scenario = serde_json::from_str(&json).expect("bare number must deserialize");
let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
panic!("expected a Scene entry");
};
assert!(matches!(scene.at, SceneStart::At(TimePoint::Seconds(s)) if s == 2.5));
}
}
#[cfg(test)]
mod scene_shake_and_flash_tests {
use super::*;
#[test]
fn scene_shake_is_absent_by_default() {
let json = r#"{ "duration": 1.0, "children": [] }"#;
let scene: Scene = serde_json::from_str(json).unwrap();
assert!(scene.shake.is_none());
}
#[test]
fn scene_shake_deserializes_with_six_impacts_on_the_beat_grid() {
let json = r##"{
"duration": 4.0,
"children": [],
"shake": {
"impacts": [
{ "at": "0b", "amplitude": 24.0 },
{ "at": "4b", "amplitude": 20.0 },
{ "at": "8b", "amplitude": 20.0 },
{ "at": "12b", "amplitude": 16.0 },
{ "at": "16b", "amplitude": 16.0 },
{ "at": "20b", "amplitude": 12.0 }
],
"decay": 12.0,
"frequency": 22.0,
"rotation": 0.3
}
}"##;
let scene: Scene = serde_json::from_str(json).unwrap();
let shake = scene.shake.expect("shake must deserialize");
assert_eq!(shake.impacts.len(), 6);
assert_eq!(shake.decay, 12.0);
assert_eq!(shake.frequency, 22.0);
assert_eq!(shake.rotation, 0.3);
}
#[test]
fn scene_shake_and_camera_coexist_on_the_same_scene() {
let json = r#"{
"duration": 2.0,
"children": [],
"camera": { "zoom": 1.2 },
"shake": { "impacts": [ { "at": 0.5, "amplitude": 10.0 } ] }
}"#;
let scene: Scene = serde_json::from_str(json).unwrap();
assert!(scene.camera.is_some());
assert!(scene.shake.is_some());
}
#[test]
fn misspelled_shake_field_is_rejected() {
let json = r#"{
"video": { "width": 100, "height": 100 },
"scenes": [ {
"duration": 1.0,
"children": [],
"shake": { "impacts": [], "decayy": 5.0 }
} ]
}"#;
let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
assert!(err.to_string().contains("decayy"), "got: {err}");
}
#[test]
fn post_effect_flash_deserializes_and_defaults() {
let json = r#"{ "type": "flash", "at": "4b" }"#;
let effect: PostEffect = serde_json::from_str(json).unwrap();
match effect {
PostEffect::Flash {
at,
color,
intensity,
duration,
} => {
assert_eq!(at, TimePoint::Spec("4b".to_string()));
assert_eq!(color, "#FFFFFF");
assert_eq!(intensity, 0.6);
assert_eq!(duration, 0.15);
}
other => panic!("expected Flash, got {other:?}"),
}
}
#[test]
fn post_effect_flash_accepts_every_known_field() {
let json = r##"{
"type": "flash",
"at": 1.2,
"color": "#FF3300",
"intensity": 0.9,
"duration": 0.08
}"##;
let effect: PostEffect = serde_json::from_str(json).unwrap();
match effect {
PostEffect::Flash {
at,
color,
intensity,
duration,
} => {
assert_eq!(at, TimePoint::Seconds(1.2));
assert_eq!(color, "#FF3300");
assert_eq!(intensity, 0.9);
assert_eq!(duration, 0.08);
}
other => panic!("expected Flash, got {other:?}"),
}
}
#[test]
fn scene_effects_accept_a_flash_alongside_other_post_effects() {
let json = r#"{
"duration": 1.0,
"children": [],
"effects": [
{ "type": "flash", "at": "0b" },
{ "type": "grain", "intensity": 0.1 }
]
}"#;
let scene: Scene = serde_json::from_str(json).unwrap();
assert_eq!(scene.effects.len(), 2);
assert!(matches!(scene.effects[0], PostEffect::Flash { .. }));
assert!(matches!(scene.effects[1], PostEffect::Grain { .. }));
}
}
#[cfg(test)]
mod camera_keyframe_easing_tests {
use super::*;
fn camera_with_x_track(easing_on_first_point: Option<EasingType>) -> Camera {
let json = serde_json::json!({
"keyframes": [{
"property": "x",
"easing": "linear",
"values": [
{ "time": 0.0, "value": 0.0, "easing": easing_on_first_point },
{ "time": 1.0, "value": 100.0 }
]
}]
});
serde_json::from_value(json).expect("camera with one x track deserializes")
}
#[test]
fn a_keyframes_own_easing_overrides_the_track_easing_for_its_segment() {
let linear = camera_with_x_track(None);
let linear_mid = linear.resolve_property("x", 0.5);
assert!(
(linear_mid - 50.0).abs() < 1e-4,
"with no per-keyframe easing the track's own linear easing must still apply, \
got {linear_mid}"
);
let eased = camera_with_x_track(Some(EasingType::EaseIn));
let eased_mid = eased.resolve_property("x", 0.5);
assert!(
(eased_mid - 12.5).abs() < 1e-4,
"a per-keyframe `ease_in` on the segment's starting point must override the \
track's `linear` for that segment only (ease_in_cubic(0.5) = 0.125, so \
0 + 100 * 0.125 = 12.5), got {eased_mid}"
);
}
#[test]
fn per_keyframe_easing_only_affects_the_segment_it_starts() {
let json = serde_json::json!({
"keyframes": [{
"property": "x",
"easing": "linear",
"values": [
{ "time": 0.0, "value": 0.0, "easing": "ease_in" },
{ "time": 1.0, "value": 100.0 },
{ "time": 2.0, "value": 200.0 }
]
}]
});
let camera: Camera = serde_json::from_value(json).expect("camera deserializes");
let first_segment_mid = camera.resolve_property("x", 0.5);
assert!(
(first_segment_mid - 12.5).abs() < 1e-4,
"the first segment must still use its own ease_in, got {first_segment_mid}"
);
let second_segment_mid = camera.resolve_property("x", 1.5);
assert!(
(second_segment_mid - 150.0).abs() < 1e-4,
"the second segment has no easing of its own and must fall back to the track's \
linear easing, not inherit ease_in from the first segment, got {second_segment_mid}"
);
}
}