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