bevy_director 0.7.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation
//! `.dir.ron` in and out. Parsing is a plain function so tests and tools
//! can use it without an asset server; the loader is a thin shell.

use bevy::{
    asset::{AssetLoader, LoadContext, io::Reader},
    reflect::TypePath,
};
use thiserror::Error;

use crate::sequence::SequenceAsset;

#[derive(Error, Debug)]
pub enum SequenceError {
    #[error("could not read sequence: {0}")]
    Io(#[from] std::io::Error),
    #[error("could not parse sequence RON: {0}")]
    Parse(#[from] ron::error::SpannedError),
    #[error("could not serialize sequence: {0}")]
    Serialize(#[from] ron::Error),
}

pub fn parse_sequence(bytes: &[u8]) -> Result<SequenceAsset, SequenceError> {
    Ok(ron::de::from_bytes(bytes)?)
}

#[derive(Default, TypePath)]
pub struct SequenceLoader;

impl AssetLoader for SequenceLoader {
    type Asset = SequenceAsset;
    type Settings = ();
    type Error = SequenceError;

    async fn load(
        &self,
        reader: &mut dyn Reader,
        _settings: &(),
        _load_context: &mut LoadContext<'_>,
    ) -> Result<Self::Asset, Self::Error> {
        let mut bytes = Vec::new();
        reader.read_to_end(&mut bytes).await?;
        parse_sequence(&bytes)
    }

    fn extensions(&self) -> &[&str] {
        &["dir.ron"]
    }
}

/// Pretty RON for a sequence, the same shape the loader reads.
pub fn to_ron_string(sequence: &SequenceAsset) -> Result<String, SequenceError> {
    let pretty = ron::ser::PrettyConfig::new()
        .depth_limit(6)
        .indentor("    ");
    Ok(ron::ser::to_string_pretty(sequence, pretty)?)
}

/// Write a sequence where the asset server will find it. Viewfinder-only,
/// native-only: shipping builds and wasm have no business writing assets.
#[cfg(all(feature = "viewfinder", not(target_arch = "wasm32")))]
pub fn save_sequence(
    sequence: &SequenceAsset,
    path: &std::path::Path,
) -> Result<(), SequenceError> {
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir)?;
    }
    std::fs::write(path, to_ron_string(sequence)?)?;
    Ok(())
}

/// Read a sequence directly from disk for native authoring tools.
#[cfg(all(feature = "viewfinder", not(target_arch = "wasm32")))]
pub fn load_sequence(path: &std::path::Path) -> Result<SequenceAsset, SequenceError> {
    parse_sequence(&std::fs::read(path)?)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sequence::*;
    use bevy::prelude::*;

    fn sample() -> SequenceAsset {
        SequenceAsset {
            name: "roundtrip".into(),
            shots: vec![Shot {
                start: 0.0,
                duration: 3.0,
                blend_in: Some(Blend {
                    secs: 0.5,
                    ease: EaseFunction::SmoothStep,
                }),
                rig: Rig::Keys {
                    keys: vec![Key {
                        time: 0.0,
                        pos: Vec3::new(1.0, 2.0, 3.0),
                        rot: Some(Quat::IDENTITY),
                        ease: EaseFunction::Linear,
                    }],
                    interp: KeyInterp::Eased,
                },
                look: Look::Free,
                lens: Lens::default(),
                shake: None,
                camera: None,
            }],
            markers: vec![Marker {
                time: 1.0,
                name: "beat".into(),
            }],
            texts: vec![
                TextBlock::at(0.5, 2.0, "EXT. RIDGE - DAWN")
                    .anchor(TextAnchor::Center)
                    .fades(0.5, 0.5)
                    .style(TextBlockStyle {
                        font_size: 36.0,
                        background: Some(Color::srgba(0.0, 0.0, 0.0, 0.6)),
                        ..Default::default()
                    }),
            ],
            actors: vec![
                ActorTrack::entity("hero")
                    .cue(ActorCue::at(0.0, 1.5, "cutscene.sleeping").fades(0.0, 0.4))
                    .cue(
                        ActorCue::at(1.5, 1.5, "cutscene.wake_up")
                            .fades(0.4, 0.0)
                            .speed(0.9),
                    ),
            ],
            blend_out: Some(Blend {
                secs: 1.5,
                ease: EaseFunction::SmoothStep,
            }),
        }
    }

    #[test]
    fn ron_round_trip_is_stable() {
        let first = to_ron_string(&sample()).unwrap();
        let back = parse_sequence(first.as_bytes()).unwrap();
        let second = to_ron_string(&back).unwrap();
        assert_eq!(first, second);
        assert_eq!(back.name, "roundtrip");
        assert_eq!(back.shots.len(), 1);
        assert_eq!(back.markers[0].name, "beat");
        assert_eq!(back.texts.len(), 1);
        assert_eq!(back.texts[0].text, "EXT. RIDGE - DAWN");
        assert_eq!(back.texts[0].anchor, TextAnchor::Center);
        assert_eq!(back.actors.len(), 1);
        assert_eq!(back.actors[0].cues.len(), 2);
        assert_eq!(back.actors[0].cues[1].anim, "cutscene.wake_up");
        assert!((back.actors[0].cues[1].speed - 0.9).abs() < 1e-6);
    }

    #[test]
    fn parse_accepts_minimal_and_defaults_the_rest() {
        let text = r#"SequenceAsset(
            name: "minimal",
            shots: [Shot(
                start: 0.0, duration: 2.0,
                rig: Keys(keys: [Key(time: 0.0, pos: (0.0, 1.0, 2.0), rot: Some((0.0, 0.0, 0.0, 1.0)))]),
            )],
        )"#;
        let seq = parse_sequence(text.as_bytes()).unwrap();
        assert!(seq.markers.is_empty());
        assert!(seq.texts.is_empty());
        assert!(seq.blend_out.is_none());
        assert!(seq.shots[0].blend_in.is_none());
        assert!(matches!(seq.shots[0].look, Look::Free));
    }

    /// Text-free sequences serialize without a `texts` field at all, so
    /// files written by 0.4 stay byte-identical to what 0.3 wrote.
    #[test]
    fn texts_absent_from_ron_when_empty() {
        let mut sequence = sample();
        sequence.texts.clear();
        let ron = to_ron_string(&sequence).unwrap();
        assert!(!ron.contains("texts"), "unexpected texts field:\n{ron}");
    }

    /// Actor-free sequences serialize without an `actors` field, so files
    /// written by 0.5 stay byte-identical to what 0.4 wrote.
    #[test]
    fn actors_absent_from_ron_when_empty() {
        let mut sequence = sample();
        sequence.actors.clear();
        let ron = to_ron_string(&sequence).unwrap();
        assert!(!ron.contains("actors"), "unexpected actors field:\n{ron}");
    }

    /// The filmic tracks and the cut-to camera are per-shot extras, so a
    /// sequence that uses none of them writes exactly what 0.4 wrote.
    #[test]
    fn filmic_and_camera_absent_from_ron_when_unused() {
        let ron = to_ron_string(&sample()).unwrap();
        for field in [
            "vignette",
            "distortion",
            "aberration",
            "motion_blur",
            "grading",
            "camera",
        ] {
            assert!(!ron.contains(field), "unexpected {field} field:\n{ron}");
        }
    }

    #[test]
    fn filmic_and_camera_round_trip() {
        let mut sequence = sample();
        let shot = &mut sequence.shots[0];
        shot.camera = Some("crane".into());
        shot.lens.vignette = Some(ScalarTrack::constant(0.35));
        shot.lens.motion_blur = Some(MotionBlurSpec {
            shutter_angle: 0.75,
            samples: 4,
        });
        shot.lens.grading = Some(GradeTrack {
            temperature: Some(ScalarTrack::constant(0.2)),
            saturation: Some(ScalarTrack::constant(0.8)),
            ..Default::default()
        });
        let ron = to_ron_string(&sequence).unwrap();
        let parsed = parse_sequence(ron.as_bytes()).unwrap();
        let parsed_shot = &parsed.shots[0];
        assert_eq!(parsed_shot.camera.as_deref(), Some("crane"));
        assert_eq!(
            parsed_shot.lens.vignette.as_ref().unwrap().sample(0.0),
            0.35
        );
        assert_eq!(
            parsed_shot.lens.motion_blur,
            Some(MotionBlurSpec {
                shutter_angle: 0.75,
                samples: 4,
            })
        );
        let grading = parsed_shot.lens.grading.as_ref().unwrap();
        assert_eq!(grading.temperature.as_ref().unwrap().sample(0.0), 0.2);
        assert!(grading.exposure.is_none());
        // Stable across a second pass, like the base roundtrip test.
        assert_eq!(to_ron_string(&parsed).unwrap(), ron);
    }

    /// A basic four-field shake writes exactly what it always wrote; the
    /// new channels only appear when authored.
    #[test]
    fn shake_extras_absent_from_ron_when_unused() {
        let mut sequence = sample();
        sequence.shots[0].shake = Some(Shake {
            amplitude_deg: 1.0,
            frequency_hz: 8.0,
            seed: 1,
            pos_amplitude: 0.02,
            ramp: None,
            fov_amplitude_deg: None,
            focus_amplitude: None,
        });
        let ron = to_ron_string(&sequence).unwrap();
        for field in ["ramp", "fov_amplitude_deg", "focus_amplitude"] {
            assert!(!ron.contains(field), "unexpected {field} field:\n{ron}");
        }
        assert_eq!(
            to_ron_string(&parse_sequence(ron.as_bytes()).unwrap()).unwrap(),
            ron
        );
    }

    #[test]
    fn held_rig_and_shake_extras_round_trip() {
        let mut sequence = sample();
        sequence.shots[0].rig = Rig::Held { damping: Some(6.0) };
        sequence.shots[0].shake = Some(Shake {
            amplitude_deg: 1.2,
            frequency_hz: 7.0,
            seed: 11,
            pos_amplitude: 0.05,
            ramp: Some(ScalarTrack::constant(0.5)),
            fov_amplitude_deg: Some(0.8),
            focus_amplitude: Some(0.6),
        });
        let ron = to_ron_string(&sequence).unwrap();
        let parsed = parse_sequence(ron.as_bytes()).unwrap();
        assert!(matches!(parsed.shots[0].rig, Rig::Held { damping: Some(d) } if d == 6.0));
        let shake = parsed.shots[0].shake.as_ref().unwrap();
        assert_eq!(shake.fov_amplitude_deg, Some(0.8));
        assert_eq!(shake.focus_amplitude, Some(0.6));
        assert_eq!(shake.ramp.as_ref().unwrap().sample(0.0), 0.5);
        assert_eq!(to_ron_string(&parsed).unwrap(), ron);
    }

    /// A hand-written held shot parses with its damping defaulted off.
    #[test]
    fn parse_accepts_a_hand_written_held_shot() {
        let text = r#"SequenceAsset(
            name: "mirror",
            shots: [Shot(start: 0.0, duration: 2.0, rig: Held(), camera: Some("mirror"))],
        )"#;
        let seq = parse_sequence(text.as_bytes()).unwrap();
        assert!(matches!(seq.shots[0].rig, Rig::Held { damping: None }));
        assert_eq!(seq.shots[0].camera.as_deref(), Some("mirror"));
    }

    /// Older crate versions must keep reading files that carry fields
    /// they do not know: serde skips unknown struct fields by default.
    #[test]
    fn parse_ignores_unknown_fields() {
        let text = r#"SequenceAsset(
            name: "forward",
            shots: [Shot(
                start: 0.0, duration: 2.0,
                rig: Keys(keys: [Key(time: 0.0, pos: (0.0, 1.0, 2.0), rot: Some((0.0, 0.0, 0.0, 1.0)))]),
            )],
            future_field: 1,
        )"#;
        let seq = parse_sequence(text.as_bytes()).unwrap();
        assert_eq!(seq.name, "forward");
    }

    #[test]
    fn parse_rejects_garbage() {
        assert!(parse_sequence(b"not a sequence at all").is_err());
        assert!(parse_sequence(b"SequenceAsset(name: 3)").is_err());
    }
}