Skip to main content

moq_msf/
lib.rs

1//! MSF (MOQT Streaming Format) catalog types.
2//!
3//! This crate provides types for the MSF catalog format as defined in
4//! draft-ietf-moq-msf-00, with additional support for CMAF packaging
5//! from draft-ietf-moq-cmsf-00.
6//!
7//! References:
8//! - <https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.txt>
9//! - <https://www.ietf.org/archive/id/draft-ietf-moq-cmsf-00.txt>
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16/// The default track name for the MSF catalog.
17pub const DEFAULT_NAME: &str = "catalog";
18
19/// Root MSF catalog object.
20#[serde_with::skip_serializing_none]
21#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
22#[serde(rename_all = "camelCase")]
23pub struct Catalog {
24	/// MSF version — always 1 for this draft.
25	pub version: u32,
26
27	/// Array of track descriptions.
28	pub tracks: Vec<Track>,
29}
30
31/// A single track in the MSF catalog.
32#[serde_with::skip_serializing_none]
33#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct Track {
36	/// Unique track name (case-sensitive).
37	pub name: String,
38
39	/// Packaging mode.
40	pub packaging: Packaging,
41
42	/// Whether new objects will be appended.
43	pub is_live: bool,
44
45	/// Content role.
46	pub role: Option<Role>,
47
48	/// WebCodecs codec string.
49	pub codec: Option<String>,
50
51	/// Video frame width in pixels.
52	pub width: Option<u32>,
53
54	/// Video frame height in pixels.
55	pub height: Option<u32>,
56
57	/// Video frame rate.
58	pub framerate: Option<f64>,
59
60	/// Audio sample rate in Hz.
61	pub samplerate: Option<u32>,
62
63	/// Audio channel configuration.
64	pub channel_config: Option<String>,
65
66	/// Bitrate in bits per second.
67	pub bitrate: Option<u64>,
68
69	/// Base64-encoded initialization data.
70	pub init_data: Option<String>,
71
72	/// Render group for synchronized playback.
73	pub render_group: Option<u32>,
74
75	/// Alternate group for quality switching.
76	pub alt_group: Option<u32>,
77}
78
79impl Catalog {
80	/// Serialize the MSF catalog to a JSON string.
81	pub fn to_string(&self) -> Result<String, serde_json::Error> {
82		serde_json::to_string(self)
83	}
84
85	/// Deserialize an MSF catalog from a JSON string.
86	#[allow(clippy::should_implement_trait)]
87	pub fn from_str(s: &str) -> Result<Self, serde_json::Error> {
88		serde_json::from_str(s)
89	}
90}
91
92/// Packaging mode for an MSF track.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Packaging {
95	/// Low Overhead Container (MSF).
96	Loc,
97	/// CMAF fragmented MP4 (CMSF).
98	Cmaf,
99	/// Legacy container format (timestamp + raw codec payload).
100	Legacy,
101	/// Media timeline.
102	MediaTimeline,
103	/// Event timeline.
104	EventTimeline,
105	/// Unknown packaging type.
106	Unknown(String),
107}
108
109impl fmt::Display for Packaging {
110	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111		match self {
112			Packaging::Loc => write!(f, "loc"),
113			Packaging::Cmaf => write!(f, "cmaf"),
114			Packaging::Legacy => write!(f, "legacy"),
115			Packaging::MediaTimeline => write!(f, "mediatimeline"),
116			Packaging::EventTimeline => write!(f, "eventtimeline"),
117			Packaging::Unknown(s) => write!(f, "{s}"),
118		}
119	}
120}
121
122impl FromStr for Packaging {
123	type Err = std::convert::Infallible;
124
125	fn from_str(s: &str) -> Result<Self, Self::Err> {
126		Ok(match s {
127			"loc" => Packaging::Loc,
128			"cmaf" => Packaging::Cmaf,
129			"legacy" => Packaging::Legacy,
130			"mediatimeline" => Packaging::MediaTimeline,
131			"eventtimeline" => Packaging::EventTimeline,
132			other => Packaging::Unknown(other.to_string()),
133		})
134	}
135}
136
137impl Serialize for Packaging {
138	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
139		serializer.serialize_str(&self.to_string())
140	}
141}
142
143impl<'de> Deserialize<'de> for Packaging {
144	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
145		let s = String::deserialize(deserializer)?;
146		// FromStr is infallible so unwrap is safe.
147		Ok(Packaging::from_str(&s).unwrap())
148	}
149}
150
151/// Content role for an MSF track.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum Role {
154	/// Visual content.
155	Video,
156	/// Audio content.
157	Audio,
158	/// Audio description for visually impaired.
159	AudioDescription,
160	/// Textual representation of audio.
161	Caption,
162	/// Transcription of spoken dialogue.
163	Subtitle,
164	/// Visual track for hearing impaired.
165	SignLanguage,
166	/// Unknown role.
167	Unknown(String),
168}
169
170impl fmt::Display for Role {
171	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172		match self {
173			Role::Video => write!(f, "video"),
174			Role::Audio => write!(f, "audio"),
175			Role::AudioDescription => write!(f, "audiodescription"),
176			Role::Caption => write!(f, "caption"),
177			Role::Subtitle => write!(f, "subtitle"),
178			Role::SignLanguage => write!(f, "signlanguage"),
179			Role::Unknown(s) => write!(f, "{s}"),
180		}
181	}
182}
183
184impl FromStr for Role {
185	type Err = std::convert::Infallible;
186
187	fn from_str(s: &str) -> Result<Self, Self::Err> {
188		Ok(match s {
189			"video" => Role::Video,
190			"audio" => Role::Audio,
191			"audiodescription" => Role::AudioDescription,
192			"caption" => Role::Caption,
193			"subtitle" => Role::Subtitle,
194			"signlanguage" => Role::SignLanguage,
195			other => Role::Unknown(other.to_string()),
196		})
197	}
198}
199
200impl Serialize for Role {
201	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
202		serializer.serialize_str(&self.to_string())
203	}
204}
205
206impl<'de> Deserialize<'de> for Role {
207	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
208		let s = String::deserialize(deserializer)?;
209		// FromStr is infallible so unwrap is safe.
210		Ok(Role::from_str(&s).unwrap())
211	}
212}
213
214#[cfg(test)]
215mod test {
216	use super::*;
217
218	#[test]
219	fn serialize_video_track() {
220		let catalog = Catalog {
221			version: 1,
222			tracks: vec![Track {
223				name: "video0".to_string(),
224				packaging: Packaging::Legacy,
225				is_live: true,
226				role: Some(Role::Video),
227				codec: Some("avc3.64001f".to_string()),
228				width: Some(1280),
229				height: Some(720),
230				framerate: Some(30.0),
231				samplerate: None,
232				channel_config: None,
233				bitrate: Some(6_000_000),
234				init_data: None,
235				render_group: Some(1),
236				alt_group: None,
237			}],
238		};
239
240		let json = catalog.to_string().unwrap();
241		let parsed = Catalog::from_str(&json).unwrap();
242		assert_eq!(catalog, parsed);
243
244		// Verify audio fields are not present in JSON.
245		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
246		let track = &value["tracks"][0];
247		assert!(track.get("samplerate").is_none());
248		assert!(track.get("channelConfig").is_none());
249	}
250
251	#[test]
252	fn serialize_audio_track() {
253		let catalog = Catalog {
254			version: 1,
255			tracks: vec![Track {
256				name: "audio0".to_string(),
257				packaging: Packaging::Legacy,
258				is_live: true,
259				role: Some(Role::Audio),
260				codec: Some("opus".to_string()),
261				width: None,
262				height: None,
263				framerate: None,
264				samplerate: Some(48_000),
265				channel_config: Some("2".to_string()),
266				bitrate: Some(128_000),
267				init_data: None,
268				render_group: Some(1),
269				alt_group: None,
270			}],
271		};
272
273		let json = catalog.to_string().unwrap();
274		let parsed = Catalog::from_str(&json).unwrap();
275		assert_eq!(catalog, parsed);
276
277		// Verify video fields are not present in JSON.
278		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
279		let track = &value["tracks"][0];
280		assert!(track.get("width").is_none());
281		assert!(track.get("height").is_none());
282		assert!(track.get("framerate").is_none());
283	}
284
285	#[test]
286	fn packaging_roundtrip() {
287		for (s, expected) in [
288			("loc", Packaging::Loc),
289			("cmaf", Packaging::Cmaf),
290			("legacy", Packaging::Legacy),
291			("mediatimeline", Packaging::MediaTimeline),
292			("eventtimeline", Packaging::EventTimeline),
293			("custom", Packaging::Unknown("custom".to_string())),
294		] {
295			let packaging: Packaging = s.parse().unwrap();
296			assert_eq!(packaging, expected);
297			assert_eq!(packaging.to_string(), s);
298		}
299	}
300
301	#[test]
302	fn role_roundtrip() {
303		for (s, expected) in [
304			("video", Role::Video),
305			("audio", Role::Audio),
306			("audiodescription", Role::AudioDescription),
307			("caption", Role::Caption),
308			("subtitle", Role::Subtitle),
309			("signlanguage", Role::SignLanguage),
310			("custom", Role::Unknown("custom".to_string())),
311		] {
312			let role: Role = s.parse().unwrap();
313			assert_eq!(role, expected);
314			assert_eq!(role.to_string(), s);
315		}
316	}
317
318	#[test]
319	fn roundtrip_empty() {
320		let catalog = Catalog {
321			version: 1,
322			tracks: vec![],
323		};
324		let json = catalog.to_string().unwrap();
325		let parsed = Catalog::from_str(&json).unwrap();
326		assert_eq!(catalog, parsed);
327	}
328
329	#[test]
330	fn cmaf_packaging() {
331		let catalog = Catalog {
332			version: 1,
333			tracks: vec![Track {
334				name: "hd".to_string(),
335				packaging: Packaging::Cmaf,
336				is_live: true,
337				role: Some(Role::Video),
338				codec: Some("avc1.640028".to_string()),
339				width: Some(1920),
340				height: Some(1080),
341				framerate: Some(30.0),
342				samplerate: None,
343				channel_config: None,
344				bitrate: Some(5_000_000),
345				init_data: Some("AQID".to_string()),
346				render_group: Some(1),
347				alt_group: Some(1),
348			}],
349		};
350
351		let json = catalog.to_string().unwrap();
352		assert!(json.contains("\"packaging\":\"cmaf\""));
353		let parsed = Catalog::from_str(&json).unwrap();
354		assert_eq!(catalog, parsed);
355	}
356}