Skip to main content

oxframe_protocol/
meta.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Current version of the `meta.json` schema.
8pub const META_SCHEMA_VERSION: u32 = 1;
9
10/// Contents of `<scene>.meta.json`.
11///
12/// The file is the source of truth for markers, eases and tunables; scene
13/// code only provides defaults. Unknown keys are preserved verbatim.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
16pub struct Meta {
17    /// Schema version, [`META_SCHEMA_VERSION`].
18    pub version: u32,
19    #[serde(default)]
20    /// Marker times in seconds from the start of the scene.
21    pub markers: BTreeMap<String, f64>,
22    #[serde(default)]
23    /// Named eases as `[x1, y1, x2, y2]`.
24    pub eases: BTreeMap<String, [f64; 4]>,
25    #[serde(default)]
26    /// Tunable values.
27    pub tunables: BTreeMap<String, f64>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    /// Soundtrack placement.
30    pub audio: Option<AudioMeta>,
31    #[serde(flatten)]
32    #[cfg_attr(feature = "ts", ts(skip))]
33    /// Keys this version does not know, kept as they are.
34    pub extra: BTreeMap<String, Value>,
35}
36
37/// Where the soundtrack plays.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
40pub struct AudioMeta {
41    /// Audio file, relative to the project.
42    pub file: String,
43    #[serde(default)]
44    /// Start of the audio in seconds on the project timeline.
45    pub offset: f64,
46    #[serde(flatten)]
47    #[cfg_attr(feature = "ts", ts(skip))]
48    /// Keys this version does not know, kept as they are.
49    pub extra: BTreeMap<String, Value>,
50}
51
52impl Default for Meta {
53    fn default() -> Self {
54        Self {
55            version: META_SCHEMA_VERSION,
56            markers: BTreeMap::new(),
57            eases: BTreeMap::new(),
58            tunables: BTreeMap::new(),
59            audio: None,
60            extra: BTreeMap::new(),
61        }
62    }
63}
64
65/// Why a `meta.json` could not be used.
66#[derive(Debug)]
67pub enum MetaError {
68    /// Not valid JSON, or wrong types.
69    Json(serde_json::Error),
70    /// Written by a newer oxframe.
71    UnsupportedVersion(u32),
72    /// A name does not match `[a-z0-9_-]+`.
73    InvalidName {
74        /// `"markers"`, `"eases"` or `"tunables"`.
75        section: &'static str,
76        /// The offending name.
77        name: String,
78    },
79}
80
81impl fmt::Display for MetaError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Json(e) => write!(f, "invalid meta.json: {e}"),
85            Self::UnsupportedVersion(v) => write!(
86                f,
87                "meta.json schema version {v} is not supported (expected {META_SCHEMA_VERSION})"
88            ),
89            Self::InvalidName { section, name } => write!(
90                f,
91                "invalid name {name:?} in `{section}`: names must match [a-z0-9_-]+"
92            ),
93        }
94    }
95}
96
97impl std::error::Error for MetaError {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        match self {
100            Self::Json(e) => Some(e),
101            _ => None,
102        }
103    }
104}
105
106/// Names of markers, eases and tunables: `[a-z0-9_-]+`.
107pub fn is_valid_name(name: &str) -> bool {
108    !name.is_empty()
109        && name
110            .bytes()
111            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
112}
113
114impl Meta {
115    /// Parses and validates a `meta.json` file.
116    ///
117    /// # Errors
118    ///
119    /// If the text is not valid JSON of this shape, has another schema
120    /// version, or contains an invalid name.
121    pub fn parse(text: &str) -> Result<Self, MetaError> {
122        let meta: Meta = serde_json::from_str(text).map_err(MetaError::Json)?;
123        if meta.version != META_SCHEMA_VERSION {
124            return Err(MetaError::UnsupportedVersion(meta.version));
125        }
126        meta.validate()?;
127        Ok(meta)
128    }
129
130    /// Checks that all names are valid.
131    ///
132    /// # Errors
133    ///
134    /// [`MetaError::InvalidName`] for the first name that is not `[a-z0-9_-]+`.
135    pub fn validate(&self) -> Result<(), MetaError> {
136        let sections: [(&'static str, Vec<&String>); 3] = [
137            ("markers", self.markers.keys().collect()),
138            ("eases", self.eases.keys().collect()),
139            ("tunables", self.tunables.keys().collect()),
140        ];
141        for (section, names) in sections {
142            if let Some(bad) = names.into_iter().find(|n| !is_valid_name(n)) {
143                return Err(MetaError::InvalidName {
144                    section,
145                    name: bad.clone(),
146                });
147            }
148        }
149        Ok(())
150    }
151
152    /// Deterministic formatting: keys sorted at every level, two-space
153    /// indent, trailing newline — so diffs in git stay readable.
154    pub fn to_pretty_string(&self) -> String {
155        // Going through `Value` sorts the top-level keys too (serde_json's
156        // default map is a `BTreeMap`).
157        let value = serde_json::to_value(self).expect("Meta is always serializable");
158        let mut out = serde_json::to_string_pretty(&value).expect("Value is always serializable");
159        out.push('\n');
160        out
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    const SAMPLE: &str = r#"{
169  "version": 1,
170  "markers":  { "beat": 1.25 },
171  "eases":    { "slide": [0.65, 0, 0.35, 1] },
172  "tunables": { "speed": 1.4 },
173  "audio":    { "file": "assets/voice.mp3", "offset": 0.0 }
174}"#;
175
176    #[test]
177    fn parses_spec_sample() {
178        let meta = Meta::parse(SAMPLE).unwrap();
179        assert_eq!(meta.markers["beat"], 1.25);
180        assert_eq!(meta.eases["slide"], [0.65, 0.0, 0.35, 1.0]);
181        assert_eq!(meta.tunables["speed"], 1.4);
182        assert_eq!(meta.audio.unwrap().file, "assets/voice.mp3");
183    }
184
185    #[test]
186    fn preserves_unknown_keys_and_sorts() {
187        let text =
188            r#"{"zeta": {"b": 1, "a": 2}, "version": 1, "audio": {"file": "a.mp3", "gain": 0.5}}"#;
189        let meta = Meta::parse(text).unwrap();
190        let out = meta.to_pretty_string();
191        let expected = r#"{
192  "audio": {
193    "file": "a.mp3",
194    "gain": 0.5,
195    "offset": 0.0
196  },
197  "eases": {},
198  "markers": {},
199  "tunables": {},
200  "version": 1,
201  "zeta": {
202    "a": 2,
203    "b": 1
204  }
205}
206"#;
207        assert_eq!(out, expected);
208        assert_eq!(Meta::parse(&out).unwrap(), meta);
209    }
210
211    #[test]
212    fn rejects_bad_names_and_versions() {
213        assert!(matches!(
214            Meta::parse(r#"{"version": 1, "markers": {"Beat": 1}}"#),
215            Err(MetaError::InvalidName { .. })
216        ));
217        assert!(matches!(
218            Meta::parse(r#"{"version": 2}"#),
219            Err(MetaError::UnsupportedVersion(2))
220        ));
221    }
222}