1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub const META_SCHEMA_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
16pub struct Meta {
17 pub version: u32,
19 #[serde(default)]
20 pub markers: BTreeMap<String, f64>,
22 #[serde(default)]
23 pub eases: BTreeMap<String, [f64; 4]>,
25 #[serde(default)]
26 pub tunables: BTreeMap<String, f64>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub audio: Option<AudioMeta>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub transition: Option<TransitionMeta>,
34 #[serde(flatten)]
35 #[cfg_attr(feature = "ts", ts(skip))]
36 pub extra: BTreeMap<String, Value>,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
46pub struct TransitionMeta {
47 pub seconds: f64,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
54pub struct AudioMeta {
55 pub file: String,
57 #[serde(default)]
58 pub offset: f64,
60 #[serde(flatten)]
61 #[cfg_attr(feature = "ts", ts(skip))]
62 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#[derive(Debug)]
82pub enum MetaError {
83 Json(serde_json::Error),
85 UnsupportedVersion(u32),
87 InvalidName {
89 section: &'static str,
91 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
121pub 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 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 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 pub fn to_pretty_string(&self) -> String {
170 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}