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"]
}
}
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)?)
}
#[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(())
}
#[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));
}
#[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}");
}
#[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}");
}
#[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());
assert_eq!(to_ron_string(&parsed).unwrap(), ron);
}
#[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);
}
#[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"));
}
#[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());
}
}