use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_with::DurationMilliSeconds;
pub const DEFAULT_NAME: &str = "catalog";
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Catalog {
pub tracks: Vec<Track>,
}
#[serde_with::serde_as]
#[serde_with::skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Track {
pub name: String,
pub packaging: Packaging,
#[serde(default)]
pub is_live: bool,
pub role: Option<Role>,
pub codec: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub framerate: Option<f64>,
pub samplerate: Option<u32>,
pub channel_config: Option<String>,
pub bitrate: Option<u64>,
pub init_data: Option<String>,
init_ref: Option<String>,
pub render_group: Option<u32>,
pub alt_group: Option<u32>,
#[serde(rename = "maxGrpSapStartingType")]
pub max_grp_sap_starting_type: Option<u8>,
#[serde(rename = "maxObjSapStartingType")]
pub max_obj_sap_starting_type: Option<u8>,
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
pub jitter: Option<Duration>,
}
impl Catalog {
pub fn to_string(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
}
const CURRENT_VERSION: &str = "draft-01";
impl Serialize for Catalog {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use std::collections::HashMap;
let mut init_data_list: Vec<InitData> = Vec::new();
let mut ids: HashMap<String, String> = HashMap::new();
let mut tracks = Vec::with_capacity(self.tracks.len());
for track in &self.tracks {
let mut track = track.clone();
if let Some(payload) = track.init_data.take() {
let id = if let Some(id) = ids.get(&payload) {
id.clone()
} else {
let id = format!("init{}", init_data_list.len());
init_data_list.push(InitData {
id: id.clone(),
kind: "inline".to_string(),
data: payload.clone(),
});
ids.insert(payload, id.clone());
id
};
track.init_ref = Some(id);
}
tracks.push(track);
}
Wire {
version: WireVersion,
tracks,
init_data_list: (!init_data_list.is_empty()).then_some(init_data_list),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Catalog {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use std::collections::HashMap;
let wire = Wire::deserialize(deserializer)?;
let init_data_list = wire.init_data_list.unwrap_or_default();
let inline: HashMap<&str, &str> = init_data_list
.iter()
.filter(|e| e.kind == "inline")
.map(|e| (e.id.as_str(), e.data.as_str()))
.collect();
let tracks = wire
.tracks
.into_iter()
.map(|mut track| {
if track.init_data.is_none() {
if let Some(id) = track.init_ref.take() {
track.init_data = inline.get(id.as_str()).map(|data| data.to_string());
}
}
track.init_ref = None;
track
})
.collect();
Ok(Catalog { tracks })
}
}
#[serde_with::skip_serializing_none]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Wire {
version: WireVersion,
#[serde(default)]
tracks: Vec<Track>,
init_data_list: Option<Vec<InitData>>,
}
struct WireVersion;
impl Serialize for WireVersion {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(CURRENT_VERSION)
}
}
impl<'de> Deserialize<'de> for WireVersion {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct VersionVisitor;
impl serde::de::Visitor<'_> for VersionVisitor {
type Value = WireVersion;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("the JSON number 1 (draft-00) or a \"draft-XX\" version string")
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<WireVersion, E> {
match v {
1 => Ok(WireVersion),
other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))),
}
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<WireVersion, E> {
if v == 1 {
Ok(WireVersion)
} else {
Err(E::custom(format!("unsupported MSF catalog version: {v}")))
}
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<WireVersion, E> {
if v == 1.0 {
Ok(WireVersion)
} else {
Err(E::custom(format!("unsupported MSF catalog version: {v}")))
}
}
fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<WireVersion, E> {
Ok(WireVersion)
}
}
deserializer.deserialize_any(VersionVisitor)
}
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InitData {
id: String,
#[serde(rename = "type")]
kind: String,
data: String,
}
impl Track {
pub fn new(name: impl Into<String>, packaging: Packaging) -> Self {
Self {
name: name.into(),
packaging,
is_live: false,
role: None,
codec: None,
width: None,
height: None,
framerate: None,
samplerate: None,
channel_config: None,
bitrate: None,
init_data: None,
init_ref: None,
render_group: None,
alt_group: None,
max_grp_sap_starting_type: None,
max_obj_sap_starting_type: None,
jitter: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Packaging {
Loc,
Cmaf,
Legacy,
MediaTimeline,
EventTimeline,
Unknown(String),
}
impl fmt::Display for Packaging {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Packaging::Loc => write!(f, "loc"),
Packaging::Cmaf => write!(f, "cmaf"),
Packaging::Legacy => write!(f, "legacy"),
Packaging::MediaTimeline => write!(f, "mediatimeline"),
Packaging::EventTimeline => write!(f, "eventtimeline"),
Packaging::Unknown(s) => write!(f, "{s}"),
}
}
}
impl FromStr for Packaging {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"loc" => Packaging::Loc,
"cmaf" => Packaging::Cmaf,
"legacy" => Packaging::Legacy,
"mediatimeline" => Packaging::MediaTimeline,
"eventtimeline" => Packaging::EventTimeline,
other => Packaging::Unknown(other.to_string()),
})
}
}
impl Serialize for Packaging {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Packaging {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Packaging::from_str(&s).unwrap())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Role {
Video,
Audio,
AudioDescription,
Caption,
Subtitle,
SignLanguage,
Unknown(String),
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Role::Video => write!(f, "video"),
Role::Audio => write!(f, "audio"),
Role::AudioDescription => write!(f, "audiodescription"),
Role::Caption => write!(f, "caption"),
Role::Subtitle => write!(f, "subtitle"),
Role::SignLanguage => write!(f, "signlanguage"),
Role::Unknown(s) => write!(f, "{s}"),
}
}
}
impl FromStr for Role {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"video" => Role::Video,
"audio" => Role::Audio,
"audiodescription" => Role::AudioDescription,
"caption" => Role::Caption,
"subtitle" => Role::Subtitle,
"signlanguage" => Role::SignLanguage,
other => Role::Unknown(other.to_string()),
})
}
}
impl Serialize for Role {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Role {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Role::from_str(&s).unwrap())
}
}
#[cfg(test)]
mod test {
use super::*;
fn video_track() -> Track {
Track {
name: "video0".to_string(),
packaging: Packaging::Legacy,
is_live: true,
role: Some(Role::Video),
codec: Some("avc3.64001f".to_string()),
width: Some(1280),
height: Some(720),
framerate: Some(30.0),
samplerate: None,
channel_config: None,
bitrate: Some(6_000_000),
init_data: None,
init_ref: None,
render_group: Some(1),
alt_group: None,
max_grp_sap_starting_type: None,
max_obj_sap_starting_type: None,
jitter: None,
}
}
fn audio_track() -> Track {
Track {
name: "audio0".to_string(),
packaging: Packaging::Legacy,
is_live: true,
role: Some(Role::Audio),
codec: Some("opus".to_string()),
width: None,
height: None,
framerate: None,
samplerate: Some(48_000),
channel_config: Some("2".to_string()),
bitrate: Some(128_000),
init_data: None,
init_ref: None,
render_group: Some(1),
alt_group: None,
max_grp_sap_starting_type: None,
max_obj_sap_starting_type: None,
jitter: None,
}
}
fn track_with_sap_and_jitter() -> Track {
Track {
name: "video0".to_string(),
packaging: Packaging::Cmaf,
is_live: true,
role: Some(Role::Video),
codec: Some("avc1.640028".to_string()),
width: Some(1920),
height: Some(1080),
framerate: Some(30.0),
samplerate: None,
channel_config: None,
bitrate: Some(5_000_000),
init_data: None,
init_ref: None,
render_group: Some(1),
alt_group: None,
max_grp_sap_starting_type: Some(1),
max_obj_sap_starting_type: Some(2),
jitter: Some(Duration::from_millis(15)),
}
}
#[test]
fn serialize_video_track() {
let catalog = Catalog {
tracks: vec![video_track()],
};
let json = catalog.to_string().unwrap();
let parsed = Catalog::from_str(&json).unwrap();
assert_eq!(catalog, parsed);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let track = &value["tracks"][0];
assert!(track.get("samplerate").is_none());
assert!(track.get("channelConfig").is_none());
assert!(track.get("maxGrpSapStartingType").is_none());
assert!(track.get("maxObjSapStartingType").is_none());
assert!(track.get("jitter").is_none());
}
#[test]
fn serialize_audio_track() {
let catalog = Catalog {
tracks: vec![audio_track()],
};
let json = catalog.to_string().unwrap();
let parsed = Catalog::from_str(&json).unwrap();
assert_eq!(catalog, parsed);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let track = &value["tracks"][0];
assert!(track.get("width").is_none());
assert!(track.get("height").is_none());
assert!(track.get("framerate").is_none());
}
#[test]
fn packaging_roundtrip() {
for (s, expected) in [
("loc", Packaging::Loc),
("cmaf", Packaging::Cmaf),
("legacy", Packaging::Legacy),
("mediatimeline", Packaging::MediaTimeline),
("eventtimeline", Packaging::EventTimeline),
("custom", Packaging::Unknown("custom".to_string())),
] {
let packaging: Packaging = s.parse().unwrap();
assert_eq!(packaging, expected);
assert_eq!(packaging.to_string(), s);
}
}
#[test]
fn role_roundtrip() {
for (s, expected) in [
("video", Role::Video),
("audio", Role::Audio),
("audiodescription", Role::AudioDescription),
("caption", Role::Caption),
("subtitle", Role::Subtitle),
("signlanguage", Role::SignLanguage),
("custom", Role::Unknown("custom".to_string())),
] {
let role: Role = s.parse().unwrap();
assert_eq!(role, expected);
assert_eq!(role.to_string(), s);
}
}
#[test]
fn roundtrip_empty() {
let catalog = Catalog { tracks: vec![] };
let json = catalog.to_string().unwrap();
let parsed = Catalog::from_str(&json).unwrap();
assert_eq!(catalog, parsed);
}
#[test]
fn cmaf_packaging() {
let mut track = track_with_sap_and_jitter();
track.name = "hd".to_string();
track.alt_group = Some(1);
track.max_grp_sap_starting_type = None;
track.max_obj_sap_starting_type = None;
track.jitter = None;
track.init_data = Some("AQID".to_string());
let catalog = Catalog { tracks: vec![track] };
let json = catalog.to_string().unwrap();
assert!(json.contains("\"packaging\":\"cmaf\""));
let parsed = Catalog::from_str(&json).unwrap();
assert_eq!(catalog, parsed);
assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
}
#[test]
fn serialize_sap_fields() {
let catalog = Catalog {
tracks: vec![track_with_sap_and_jitter()],
};
let json = catalog.to_string().unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let track = &value["tracks"][0];
assert_eq!(track.get("maxGrpSapStartingType"), Some(&serde_json::json!(1)));
assert_eq!(track.get("maxObjSapStartingType"), Some(&serde_json::json!(2)));
assert_eq!(track.get("jitter"), Some(&serde_json::json!(15)));
assert!(track.get("max_grp_sap_starting_type").is_none());
assert!(track.get("max_obj_sap_starting_type").is_none());
}
#[test]
fn deserialize_without_sap_fields() {
let json = r#"{
"version": 1,
"tracks": [{
"name": "video0",
"packaging": "cmaf",
"isLive": true,
"role": "video",
"codec": "avc1.640028",
"width": 1920,
"height": 1080,
"framerate": 30.0,
"bitrate": 5000000,
"renderGroup": 1
}]
}"#;
let catalog = Catalog::from_str(json).unwrap();
let track = &catalog.tracks[0];
assert_eq!(track.max_grp_sap_starting_type, None);
assert_eq!(track.max_obj_sap_starting_type, None);
assert_eq!(track.jitter, None);
}
#[test]
fn sap_and_jitter_roundtrip() {
let original = Catalog {
tracks: vec![track_with_sap_and_jitter()],
};
let json = original.to_string().unwrap();
let parsed = Catalog::from_str(&json).unwrap();
assert_eq!(original, parsed);
assert_eq!(parsed.tracks[0].max_grp_sap_starting_type, Some(1));
assert_eq!(parsed.tracks[0].max_obj_sap_starting_type, Some(2));
assert_eq!(parsed.tracks[0].jitter, Some(Duration::from_millis(15)));
}
#[test]
fn serialize_emits_draft01_version() {
let json = Catalog::default().to_string().unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["version"], serde_json::json!("draft-01"));
}
#[test]
fn draft00_numeric_version_decodes_and_normalizes() {
let catalog = Catalog::from_str(r#"{"version":1,"tracks":[]}"#).unwrap();
assert!(catalog.tracks.is_empty());
let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap();
assert_eq!(value["version"], serde_json::json!("draft-01"));
}
#[test]
fn draft01_string_version_decodes() {
let catalog = Catalog::from_str(r#"{"version":"draft-01","tracks":[]}"#).unwrap();
assert!(catalog.tracks.is_empty());
}
#[test]
fn unknown_version_string_is_accepted() {
assert!(Catalog::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok());
}
#[test]
fn unsupported_numeric_version_errors() {
assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err());
}
#[test]
fn float_numeric_version_is_accepted() {
assert!(Catalog::from_str(r#"{"version":1.0,"tracks":[]}"#).is_ok());
assert!(Catalog::from_str(r#"{"version":2.0,"tracks":[]}"#).is_err());
}
#[test]
fn unresolved_init_ref_leaves_init_data_none() {
let json = r#"{
"version": "draft-01",
"initDataList": [
{ "id": "v0", "type": "url", "data": "https://example.com/init" }
],
"tracks": [
{ "name": "a", "packaging": "cmaf", "isLive": true, "role": "video",
"codec": "avc1.640028", "initRef": "missing" },
{ "name": "b", "packaging": "cmaf", "isLive": true, "role": "video",
"codec": "avc1.640028", "initRef": "v0" }
]
}"#;
let catalog = Catalog::from_str(json).unwrap();
assert_eq!(catalog.tracks[0].init_data, None);
assert_eq!(catalog.tracks[1].init_data, None);
}
#[test]
fn draft01_init_ref_resolves_to_inline() {
let json = r#"{
"version": "draft-01",
"initDataList": [
{ "id": "v0", "type": "inline", "data": "AQID" }
],
"tracks": [
{ "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
"codec": "avc1.640028", "initRef": "v0" }
]
}"#;
let catalog = Catalog::from_str(json).unwrap();
assert_eq!(catalog.tracks[0].init_data.as_deref(), Some("AQID"));
}
#[test]
fn serialize_hoists_and_dedups_init_data() {
let mut a = video_track();
a.name = "a".to_string();
a.init_data = Some("AQID".to_string());
let mut b = video_track();
b.name = "b".to_string();
b.init_data = Some("AQID".to_string());
let catalog = Catalog { tracks: vec![a, b] };
let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap();
let list = value["initDataList"].as_array().expect("initDataList present");
assert_eq!(list.len(), 1, "identical payloads should dedup to one entry");
assert_eq!(list[0]["data"], serde_json::json!("AQID"));
assert_eq!(list[0]["type"], serde_json::json!("inline"));
let id = list[0]["id"].as_str().unwrap();
for t in value["tracks"].as_array().unwrap() {
assert_eq!(t["initRef"], serde_json::json!(id));
assert!(t.get("initData").is_none(), "no inline initData on the wire");
}
let parsed = Catalog::from_str(&catalog.to_string().unwrap()).unwrap();
assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
assert_eq!(parsed.tracks[1].init_data.as_deref(), Some("AQID"));
}
#[test]
fn draft00_example_av_decodes() {
let json = r#"{
"version": 1,
"generatedAt": 1746104606044,
"tracks": [
{
"name": "1080p-video",
"namespace": "conference.example.com/conference123/alice",
"packaging": "loc",
"isLive": true,
"targetLatency": 2000,
"role": "video",
"renderGroup": 1,
"codec": "av01.0.08M.10.0.110.09",
"width": 1920,
"height": 1080,
"framerate": 30,
"bitrate": 1500000
},
{
"name": "audio",
"namespace": "conference.example.com/conference123/alice",
"packaging": "loc",
"isLive": true,
"targetLatency": 2000,
"role": "audio",
"codec": "opus",
"samplerate": 48000,
"channelConfig": "2",
"bitrate": 32000
}
]
}"#;
let catalog = Catalog::from_str(json).expect("draft-00 AV catalog must decode");
assert_eq!(catalog.tracks.len(), 2);
assert_eq!(catalog.tracks[0].framerate, Some(30.0));
assert_eq!(catalog.tracks[1].channel_config.as_deref(), Some("2"));
}
#[test]
fn draft00_example_timeline_tracks_decode() {
let json = r#"{
"version": 1,
"generatedAt": 1746104606044,
"tracks": [
{
"name": "history",
"namespace": "conference.example.com/conference123/alice",
"packaging": "mediatimeline",
"mimetype": "application/json",
"depends": ["1080p-video", "audio"]
},
{
"name": "1080p-video",
"namespace": "conference.example.com/conference123/alice",
"packaging": "loc",
"isLive": true,
"role": "video",
"codec": "av01.0.08M.10.0.110.09",
"width": 1920,
"height": 1080,
"framerate": 30,
"bitrate": 1500000
}
]
}"#;
let catalog = Catalog::from_str(json).expect("draft-00 timeline catalog must decode");
assert_eq!(catalog.tracks.len(), 2);
assert!(!catalog.tracks[0].is_live);
assert_eq!(catalog.tracks[0].packaging, Packaging::MediaTimeline);
}
#[test]
fn draft00_example_complete_decodes() {
let json = r#"{
"version": 1,
"generatedAt": 1746104606044,
"isComplete": true,
"tracks": []
}"#;
let catalog = Catalog::from_str(json).expect("draft-00 completion catalog must decode");
assert!(catalog.tracks.is_empty());
}
}