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///
33/// Marked `#[non_exhaustive]` because the CMSF/MSF drafts continue to grow
34/// optional fields. External callers build a track with [`Track::new`] and
35/// then assign whichever optional fields they need; struct-literal
36/// construction (with or without `..base`) is not available outside this
37/// crate.
38#[serde_with::skip_serializing_none]
39#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
40#[serde(rename_all = "camelCase")]
41#[non_exhaustive]
42pub struct Track {
43	/// Unique track name (case-sensitive).
44	pub name: String,
45
46	/// Packaging mode.
47	pub packaging: Packaging,
48
49	/// Whether new objects will be appended.
50	pub is_live: bool,
51
52	/// Content role.
53	pub role: Option<Role>,
54
55	/// WebCodecs codec string.
56	pub codec: Option<String>,
57
58	/// Video frame width in pixels.
59	pub width: Option<u32>,
60
61	/// Video frame height in pixels.
62	pub height: Option<u32>,
63
64	/// Video frame rate.
65	pub framerate: Option<f64>,
66
67	/// Audio sample rate in Hz.
68	pub samplerate: Option<u32>,
69
70	/// Audio channel configuration.
71	pub channel_config: Option<String>,
72
73	/// Bitrate in bits per second.
74	pub bitrate: Option<u64>,
75
76	/// Base64-encoded initialization data.
77	pub init_data: Option<String>,
78
79	/// Render group for synchronized playback.
80	pub render_group: Option<u32>,
81
82	/// Alternate group for quality switching.
83	pub alt_group: Option<u32>,
84
85	/// Maximum SAP starting type for groups (CMSF 3.5.2).
86	/// A value of 1 means every group starts with a closed-GOP IDR.
87	// Explicit rename to lock the wire name independent of rename_all.
88	#[serde(rename = "maxGrpSapStartingType")]
89	pub max_grp_sap_starting_type: Option<u8>,
90
91	/// Maximum SAP starting type for objects (CMSF 3.5.2).
92	/// A value of 1 means every object starts with a closed-GOP IDR.
93	// Explicit rename to lock the wire name independent of rename_all.
94	#[serde(rename = "maxObjSapStartingType")]
95	pub max_obj_sap_starting_type: Option<u8>,
96
97	/// Jitter in milliseconds (non-standard extension, matches JS implementation).
98	pub jitter: Option<f64>,
99}
100
101impl Catalog {
102	/// Serialize the MSF catalog to a JSON string.
103	pub fn to_string(&self) -> Result<String, serde_json::Error> {
104		serde_json::to_string(self)
105	}
106
107	/// Deserialize an MSF catalog from a JSON string.
108	#[allow(clippy::should_implement_trait)]
109	pub fn from_str(s: &str) -> Result<Self, serde_json::Error> {
110		serde_json::from_str(s)
111	}
112}
113
114impl Track {
115	/// Construct a track with the required identity fields set and every
116	/// optional field cleared. Fields are `pub`, so callers set whatever they
117	/// need by assignment afterwards.
118	///
119	/// This is the only path external crates have to build a `Track` since the
120	/// type is `#[non_exhaustive]`.
121	pub fn new(name: impl Into<String>, packaging: Packaging) -> Self {
122		Self {
123			name: name.into(),
124			packaging,
125			is_live: false,
126			role: None,
127			codec: None,
128			width: None,
129			height: None,
130			framerate: None,
131			samplerate: None,
132			channel_config: None,
133			bitrate: None,
134			init_data: None,
135			render_group: None,
136			alt_group: None,
137			max_grp_sap_starting_type: None,
138			max_obj_sap_starting_type: None,
139			jitter: None,
140		}
141	}
142}
143
144/// Packaging mode for an MSF track.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Packaging {
147	/// Low Overhead Container (MSF).
148	Loc,
149	/// CMAF fragmented MP4 (CMSF).
150	Cmaf,
151	/// Legacy container format (timestamp + raw codec payload).
152	Legacy,
153	/// Media timeline.
154	MediaTimeline,
155	/// Event timeline.
156	EventTimeline,
157	/// Unknown packaging type.
158	Unknown(String),
159}
160
161impl fmt::Display for Packaging {
162	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163		match self {
164			Packaging::Loc => write!(f, "loc"),
165			Packaging::Cmaf => write!(f, "cmaf"),
166			Packaging::Legacy => write!(f, "legacy"),
167			Packaging::MediaTimeline => write!(f, "mediatimeline"),
168			Packaging::EventTimeline => write!(f, "eventtimeline"),
169			Packaging::Unknown(s) => write!(f, "{s}"),
170		}
171	}
172}
173
174impl FromStr for Packaging {
175	type Err = std::convert::Infallible;
176
177	fn from_str(s: &str) -> Result<Self, Self::Err> {
178		Ok(match s {
179			"loc" => Packaging::Loc,
180			"cmaf" => Packaging::Cmaf,
181			"legacy" => Packaging::Legacy,
182			"mediatimeline" => Packaging::MediaTimeline,
183			"eventtimeline" => Packaging::EventTimeline,
184			other => Packaging::Unknown(other.to_string()),
185		})
186	}
187}
188
189impl Serialize for Packaging {
190	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
191		serializer.serialize_str(&self.to_string())
192	}
193}
194
195impl<'de> Deserialize<'de> for Packaging {
196	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
197		let s = String::deserialize(deserializer)?;
198		// FromStr is infallible so unwrap is safe.
199		Ok(Packaging::from_str(&s).unwrap())
200	}
201}
202
203/// Content role for an MSF track.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum Role {
206	/// Visual content.
207	Video,
208	/// Audio content.
209	Audio,
210	/// Audio description for visually impaired.
211	AudioDescription,
212	/// Textual representation of audio.
213	Caption,
214	/// Transcription of spoken dialogue.
215	Subtitle,
216	/// Visual track for hearing impaired.
217	SignLanguage,
218	/// Unknown role.
219	Unknown(String),
220}
221
222impl fmt::Display for Role {
223	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224		match self {
225			Role::Video => write!(f, "video"),
226			Role::Audio => write!(f, "audio"),
227			Role::AudioDescription => write!(f, "audiodescription"),
228			Role::Caption => write!(f, "caption"),
229			Role::Subtitle => write!(f, "subtitle"),
230			Role::SignLanguage => write!(f, "signlanguage"),
231			Role::Unknown(s) => write!(f, "{s}"),
232		}
233	}
234}
235
236impl FromStr for Role {
237	type Err = std::convert::Infallible;
238
239	fn from_str(s: &str) -> Result<Self, Self::Err> {
240		Ok(match s {
241			"video" => Role::Video,
242			"audio" => Role::Audio,
243			"audiodescription" => Role::AudioDescription,
244			"caption" => Role::Caption,
245			"subtitle" => Role::Subtitle,
246			"signlanguage" => Role::SignLanguage,
247			other => Role::Unknown(other.to_string()),
248		})
249	}
250}
251
252impl Serialize for Role {
253	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
254		serializer.serialize_str(&self.to_string())
255	}
256}
257
258impl<'de> Deserialize<'de> for Role {
259	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
260		let s = String::deserialize(deserializer)?;
261		// FromStr is infallible so unwrap is safe.
262		Ok(Role::from_str(&s).unwrap())
263	}
264}
265
266#[cfg(test)]
267mod test {
268	use super::*;
269
270	#[test]
271	fn serialize_video_track() {
272		let catalog = Catalog {
273			version: 1,
274			tracks: vec![Track {
275				name: "video0".to_string(),
276				packaging: Packaging::Legacy,
277				is_live: true,
278				role: Some(Role::Video),
279				codec: Some("avc3.64001f".to_string()),
280				width: Some(1280),
281				height: Some(720),
282				framerate: Some(30.0),
283				samplerate: None,
284				channel_config: None,
285				bitrate: Some(6_000_000),
286				init_data: None,
287				render_group: Some(1),
288				alt_group: None,
289				max_grp_sap_starting_type: None,
290				max_obj_sap_starting_type: None,
291				jitter: None,
292			}],
293		};
294
295		let json = catalog.to_string().unwrap();
296		let parsed = Catalog::from_str(&json).unwrap();
297		assert_eq!(catalog, parsed);
298
299		// Verify audio fields are not present in JSON.
300		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
301		let track = &value["tracks"][0];
302		assert!(track.get("samplerate").is_none());
303		assert!(track.get("channelConfig").is_none());
304
305		// Verify skip_serializing_none omits the new optional fields when None.
306		assert!(track.get("maxGrpSapStartingType").is_none());
307		assert!(track.get("maxObjSapStartingType").is_none());
308		assert!(track.get("jitter").is_none());
309	}
310
311	#[test]
312	fn serialize_audio_track() {
313		let catalog = Catalog {
314			version: 1,
315			tracks: vec![Track {
316				name: "audio0".to_string(),
317				packaging: Packaging::Legacy,
318				is_live: true,
319				role: Some(Role::Audio),
320				codec: Some("opus".to_string()),
321				width: None,
322				height: None,
323				framerate: None,
324				samplerate: Some(48_000),
325				channel_config: Some("2".to_string()),
326				bitrate: Some(128_000),
327				init_data: None,
328				render_group: Some(1),
329				alt_group: None,
330				max_grp_sap_starting_type: None,
331				max_obj_sap_starting_type: None,
332				jitter: None,
333			}],
334		};
335
336		let json = catalog.to_string().unwrap();
337		let parsed = Catalog::from_str(&json).unwrap();
338		assert_eq!(catalog, parsed);
339
340		// Verify video fields are not present in JSON.
341		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
342		let track = &value["tracks"][0];
343		assert!(track.get("width").is_none());
344		assert!(track.get("height").is_none());
345		assert!(track.get("framerate").is_none());
346	}
347
348	#[test]
349	fn packaging_roundtrip() {
350		for (s, expected) in [
351			("loc", Packaging::Loc),
352			("cmaf", Packaging::Cmaf),
353			("legacy", Packaging::Legacy),
354			("mediatimeline", Packaging::MediaTimeline),
355			("eventtimeline", Packaging::EventTimeline),
356			("custom", Packaging::Unknown("custom".to_string())),
357		] {
358			let packaging: Packaging = s.parse().unwrap();
359			assert_eq!(packaging, expected);
360			assert_eq!(packaging.to_string(), s);
361		}
362	}
363
364	#[test]
365	fn role_roundtrip() {
366		for (s, expected) in [
367			("video", Role::Video),
368			("audio", Role::Audio),
369			("audiodescription", Role::AudioDescription),
370			("caption", Role::Caption),
371			("subtitle", Role::Subtitle),
372			("signlanguage", Role::SignLanguage),
373			("custom", Role::Unknown("custom".to_string())),
374		] {
375			let role: Role = s.parse().unwrap();
376			assert_eq!(role, expected);
377			assert_eq!(role.to_string(), s);
378		}
379	}
380
381	#[test]
382	fn roundtrip_empty() {
383		let catalog = Catalog {
384			version: 1,
385			tracks: vec![],
386		};
387		let json = catalog.to_string().unwrap();
388		let parsed = Catalog::from_str(&json).unwrap();
389		assert_eq!(catalog, parsed);
390	}
391
392	#[test]
393	fn cmaf_packaging() {
394		let catalog = Catalog {
395			version: 1,
396			tracks: vec![Track {
397				name: "hd".to_string(),
398				packaging: Packaging::Cmaf,
399				is_live: true,
400				role: Some(Role::Video),
401				codec: Some("avc1.640028".to_string()),
402				width: Some(1920),
403				height: Some(1080),
404				framerate: Some(30.0),
405				samplerate: None,
406				channel_config: None,
407				bitrate: Some(5_000_000),
408				init_data: Some("AQID".to_string()),
409				render_group: Some(1),
410				alt_group: Some(1),
411				max_grp_sap_starting_type: None,
412				max_obj_sap_starting_type: None,
413				jitter: None,
414			}],
415		};
416
417		let json = catalog.to_string().unwrap();
418		assert!(json.contains("\"packaging\":\"cmaf\""));
419		let parsed = Catalog::from_str(&json).unwrap();
420		assert_eq!(catalog, parsed);
421	}
422
423	fn track_with_sap_and_jitter() -> Track {
424		Track {
425			name: "video0".to_string(),
426			packaging: Packaging::Cmaf,
427			is_live: true,
428			role: Some(Role::Video),
429			codec: Some("avc1.640028".to_string()),
430			width: Some(1920),
431			height: Some(1080),
432			framerate: Some(30.0),
433			samplerate: None,
434			channel_config: None,
435			bitrate: Some(5_000_000),
436			init_data: None,
437			render_group: Some(1),
438			alt_group: None,
439			max_grp_sap_starting_type: Some(1),
440			max_obj_sap_starting_type: Some(2),
441			jitter: Some(15.5),
442		}
443	}
444
445	#[test]
446	fn serialize_sap_fields() {
447		let catalog = Catalog {
448			version: 1,
449			tracks: vec![track_with_sap_and_jitter()],
450		};
451
452		let json = catalog.to_string().unwrap();
453
454		// Verify wire-format field names use the explicit camelCase renames and the
455		// auto-renamed jitter field.
456		let value: serde_json::Value = serde_json::from_str(&json).unwrap();
457		let track = &value["tracks"][0];
458		assert_eq!(track.get("maxGrpSapStartingType"), Some(&serde_json::json!(1)));
459		assert_eq!(track.get("maxObjSapStartingType"), Some(&serde_json::json!(2)));
460		assert_eq!(track.get("jitter"), Some(&serde_json::json!(15.5)));
461
462		// Snake-case names must NOT appear on the wire.
463		assert!(track.get("max_grp_sap_starting_type").is_none());
464		assert!(track.get("max_obj_sap_starting_type").is_none());
465	}
466
467	#[test]
468	fn deserialize_without_sap_fields() {
469		// Backward compatibility: catalogs produced before SAP/jitter were added
470		// must still deserialize, with the new fields defaulting to None.
471		let json = r#"{
472			"version": 1,
473			"tracks": [{
474				"name": "video0",
475				"packaging": "cmaf",
476				"isLive": true,
477				"role": "video",
478				"codec": "avc1.640028",
479				"width": 1920,
480				"height": 1080,
481				"framerate": 30.0,
482				"bitrate": 5000000,
483				"renderGroup": 1
484			}]
485		}"#;
486
487		let catalog = Catalog::from_str(json).unwrap();
488		let track = &catalog.tracks[0];
489		assert_eq!(track.max_grp_sap_starting_type, None);
490		assert_eq!(track.max_obj_sap_starting_type, None);
491		assert_eq!(track.jitter, None);
492	}
493
494	#[test]
495	fn sap_and_jitter_roundtrip() {
496		let original = Catalog {
497			version: 1,
498			tracks: vec![track_with_sap_and_jitter()],
499		};
500
501		let json = original.to_string().unwrap();
502		let parsed = Catalog::from_str(&json).unwrap();
503		assert_eq!(original, parsed);
504		assert_eq!(parsed.tracks[0].max_grp_sap_starting_type, Some(1));
505		assert_eq!(parsed.tracks[0].max_obj_sap_starting_type, Some(2));
506		assert_eq!(parsed.tracks[0].jitter, Some(15.5));
507	}
508}