bevy_director 0.5.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,
            }],
            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}");
    }

    /// 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());
    }
}