use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const META_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
pub struct Meta {
pub version: u32,
#[serde(default)]
pub markers: BTreeMap<String, f64>,
#[serde(default)]
pub eases: BTreeMap<String, [f64; 4]>,
#[serde(default)]
pub tunables: BTreeMap<String, f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio: Option<AudioMeta>,
#[serde(flatten)]
#[cfg_attr(feature = "ts", ts(skip))]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))]
pub struct AudioMeta {
pub file: String,
#[serde(default)]
pub offset: f64,
#[serde(flatten)]
#[cfg_attr(feature = "ts", ts(skip))]
pub extra: BTreeMap<String, Value>,
}
impl Default for Meta {
fn default() -> Self {
Self {
version: META_SCHEMA_VERSION,
markers: BTreeMap::new(),
eases: BTreeMap::new(),
tunables: BTreeMap::new(),
audio: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Debug)]
pub enum MetaError {
Json(serde_json::Error),
UnsupportedVersion(u32),
InvalidName {
section: &'static str,
name: String,
},
}
impl fmt::Display for MetaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Json(e) => write!(f, "invalid meta.json: {e}"),
Self::UnsupportedVersion(v) => write!(
f,
"meta.json schema version {v} is not supported (expected {META_SCHEMA_VERSION})"
),
Self::InvalidName { section, name } => write!(
f,
"invalid name {name:?} in `{section}`: names must match [a-z0-9_-]+"
),
}
}
}
impl std::error::Error for MetaError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Json(e) => Some(e),
_ => None,
}
}
}
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
impl Meta {
pub fn parse(text: &str) -> Result<Self, MetaError> {
let meta: Meta = serde_json::from_str(text).map_err(MetaError::Json)?;
if meta.version != META_SCHEMA_VERSION {
return Err(MetaError::UnsupportedVersion(meta.version));
}
meta.validate()?;
Ok(meta)
}
pub fn validate(&self) -> Result<(), MetaError> {
let sections: [(&'static str, Vec<&String>); 3] = [
("markers", self.markers.keys().collect()),
("eases", self.eases.keys().collect()),
("tunables", self.tunables.keys().collect()),
];
for (section, names) in sections {
if let Some(bad) = names.into_iter().find(|n| !is_valid_name(n)) {
return Err(MetaError::InvalidName {
section,
name: bad.clone(),
});
}
}
Ok(())
}
pub fn to_pretty_string(&self) -> String {
let value = serde_json::to_value(self).expect("Meta is always serializable");
let mut out = serde_json::to_string_pretty(&value).expect("Value is always serializable");
out.push('\n');
out
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"version": 1,
"markers": { "beat": 1.25 },
"eases": { "slide": [0.65, 0, 0.35, 1] },
"tunables": { "speed": 1.4 },
"audio": { "file": "assets/voice.mp3", "offset": 0.0 }
}"#;
#[test]
fn parses_spec_sample() {
let meta = Meta::parse(SAMPLE).unwrap();
assert_eq!(meta.markers["beat"], 1.25);
assert_eq!(meta.eases["slide"], [0.65, 0.0, 0.35, 1.0]);
assert_eq!(meta.tunables["speed"], 1.4);
assert_eq!(meta.audio.unwrap().file, "assets/voice.mp3");
}
#[test]
fn preserves_unknown_keys_and_sorts() {
let text =
r#"{"zeta": {"b": 1, "a": 2}, "version": 1, "audio": {"file": "a.mp3", "gain": 0.5}}"#;
let meta = Meta::parse(text).unwrap();
let out = meta.to_pretty_string();
let expected = r#"{
"audio": {
"file": "a.mp3",
"gain": 0.5,
"offset": 0.0
},
"eases": {},
"markers": {},
"tunables": {},
"version": 1,
"zeta": {
"a": 2,
"b": 1
}
}
"#;
assert_eq!(out, expected);
assert_eq!(Meta::parse(&out).unwrap(), meta);
}
#[test]
fn rejects_bad_names_and_versions() {
assert!(matches!(
Meta::parse(r#"{"version": 1, "markers": {"Beat": 1}}"#),
Err(MetaError::InvalidName { .. })
));
assert!(matches!(
Meta::parse(r#"{"version": 2}"#),
Err(MetaError::UnsupportedVersion(2))
));
}
}