1use std::fmt;
20use std::str::FromStr;
21use std::time::Duration;
22
23use serde::{Deserialize, Serialize};
24use serde_with::DurationMilliSecondsWithFrac;
25
26pub const DEFAULT_NAME: &str = "catalog";
28
29#[derive(Debug, Clone, PartialEq, Default)]
37pub struct Catalog {
38 pub tracks: Vec<Track>,
40}
41
42#[serde_with::serde_as]
50#[serde_with::skip_serializing_none]
51#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
52#[serde(rename_all = "camelCase")]
53#[non_exhaustive]
54pub struct Track {
55 pub name: String,
57
58 pub packaging: Packaging,
60
61 #[serde(default)]
67 pub is_live: bool,
68
69 pub role: Option<Role>,
71
72 pub codec: Option<String>,
74
75 pub width: Option<u32>,
77
78 pub height: Option<u32>,
80
81 pub framerate: Option<f64>,
83
84 pub samplerate: Option<u32>,
86
87 pub channel_config: Option<String>,
89
90 pub bitrate: Option<u64>,
92
93 pub init_data: Option<String>,
99
100 init_ref: Option<String>,
104
105 pub render_group: Option<u32>,
107
108 pub alt_group: Option<u32>,
110
111 #[serde(rename = "maxGrpSapStartingType")]
115 pub max_grp_sap_starting_type: Option<u8>,
116
117 #[serde(rename = "maxObjSapStartingType")]
121 pub max_obj_sap_starting_type: Option<u8>,
122
123 #[serde_as(as = "Option<DurationMilliSecondsWithFrac>")]
127 pub jitter: Option<Duration>,
128}
129
130impl Catalog {
131 pub fn to_string(&self) -> Result<String, serde_json::Error> {
133 serde_json::to_string(self)
134 }
135
136 #[allow(clippy::should_implement_trait)]
138 pub fn from_str(s: &str) -> Result<Self, serde_json::Error> {
139 serde_json::from_str(s)
140 }
141}
142
143const CURRENT_VERSION: &str = "draft-01";
145
146impl Serialize for Catalog {
147 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
148 use std::collections::HashMap;
149
150 let mut init_data_list: Vec<InitData> = Vec::new();
154 let mut ids: HashMap<String, String> = HashMap::new();
155 let mut tracks = Vec::with_capacity(self.tracks.len());
156
157 for track in &self.tracks {
158 let mut track = track.clone();
159 if let Some(payload) = track.init_data.take() {
160 let id = if let Some(id) = ids.get(&payload) {
161 id.clone()
162 } else {
163 let id = format!("init{}", init_data_list.len());
164 init_data_list.push(InitData {
165 id: id.clone(),
166 kind: "inline".to_string(),
167 data: payload.clone(),
168 });
169 ids.insert(payload, id.clone());
170 id
171 };
172 track.init_ref = Some(id);
173 }
174 tracks.push(track);
175 }
176
177 Wire {
178 version: WireVersion,
179 tracks,
180 init_data_list: (!init_data_list.is_empty()).then_some(init_data_list),
181 }
182 .serialize(serializer)
183 }
184}
185
186impl<'de> Deserialize<'de> for Catalog {
187 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
188 use std::collections::HashMap;
189
190 let wire = Wire::deserialize(deserializer)?;
191 let init_data_list = wire.init_data_list.unwrap_or_default();
192
193 let inline: HashMap<&str, &str> = init_data_list
196 .iter()
197 .filter(|e| e.kind == "inline")
198 .map(|e| (e.id.as_str(), e.data.as_str()))
199 .collect();
200
201 let tracks = wire
202 .tracks
203 .into_iter()
204 .map(|mut track| {
205 if track.init_data.is_none() {
208 if let Some(id) = track.init_ref.take() {
209 track.init_data = inline.get(id.as_str()).map(|data| data.to_string());
210 }
211 }
212 track.init_ref = None;
213 track
214 })
215 .collect();
216
217 Ok(Catalog { tracks })
218 }
219}
220
221#[serde_with::skip_serializing_none]
223#[derive(Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225struct Wire {
226 version: WireVersion,
227 #[serde(default)]
228 tracks: Vec<Track>,
229 init_data_list: Option<Vec<InitData>>,
230}
231
232struct WireVersion;
236
237impl Serialize for WireVersion {
238 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
239 serializer.serialize_str(CURRENT_VERSION)
240 }
241}
242
243impl<'de> Deserialize<'de> for WireVersion {
244 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 struct VersionVisitor;
246
247 impl serde::de::Visitor<'_> for VersionVisitor {
248 type Value = WireVersion;
249
250 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
251 f.write_str("the JSON number 1 (draft-00) or a \"draft-XX\" version string")
252 }
253
254 fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<WireVersion, E> {
258 match v {
259 1 => Ok(WireVersion),
260 other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))),
261 }
262 }
263
264 fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<WireVersion, E> {
265 if v == 1 {
266 Ok(WireVersion)
267 } else {
268 Err(E::custom(format!("unsupported MSF catalog version: {v}")))
269 }
270 }
271
272 fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<WireVersion, E> {
273 if v == 1.0 {
274 Ok(WireVersion)
275 } else {
276 Err(E::custom(format!("unsupported MSF catalog version: {v}")))
277 }
278 }
279
280 fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<WireVersion, E> {
281 Ok(WireVersion)
283 }
284 }
285
286 deserializer.deserialize_any(VersionVisitor)
287 }
288}
289
290#[derive(Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293struct InitData {
294 id: String,
296 #[serde(rename = "type")]
298 kind: String,
299 data: String,
301}
302
303impl Track {
304 pub fn new(name: impl Into<String>, packaging: Packaging) -> Self {
311 Self {
312 name: name.into(),
313 packaging,
314 is_live: false,
315 role: None,
316 codec: None,
317 width: None,
318 height: None,
319 framerate: None,
320 samplerate: None,
321 channel_config: None,
322 bitrate: None,
323 init_data: None,
324 init_ref: None,
325 render_group: None,
326 alt_group: None,
327 max_grp_sap_starting_type: None,
328 max_obj_sap_starting_type: None,
329 jitter: None,
330 }
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum Packaging {
337 Loc,
339 Cmaf,
341 Legacy,
343 MediaTimeline,
345 EventTimeline,
347 Unknown(String),
349}
350
351impl fmt::Display for Packaging {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 match self {
354 Packaging::Loc => write!(f, "loc"),
355 Packaging::Cmaf => write!(f, "cmaf"),
356 Packaging::Legacy => write!(f, "legacy"),
357 Packaging::MediaTimeline => write!(f, "mediatimeline"),
358 Packaging::EventTimeline => write!(f, "eventtimeline"),
359 Packaging::Unknown(s) => write!(f, "{s}"),
360 }
361 }
362}
363
364impl FromStr for Packaging {
365 type Err = std::convert::Infallible;
366
367 fn from_str(s: &str) -> Result<Self, Self::Err> {
368 Ok(match s {
369 "loc" => Packaging::Loc,
370 "cmaf" => Packaging::Cmaf,
371 "legacy" => Packaging::Legacy,
372 "mediatimeline" => Packaging::MediaTimeline,
373 "eventtimeline" => Packaging::EventTimeline,
374 other => Packaging::Unknown(other.to_string()),
375 })
376 }
377}
378
379impl Serialize for Packaging {
380 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
381 serializer.serialize_str(&self.to_string())
382 }
383}
384
385impl<'de> Deserialize<'de> for Packaging {
386 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
387 let s = String::deserialize(deserializer)?;
388 Ok(Packaging::from_str(&s).unwrap())
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
395pub enum Role {
396 Video,
398 Audio,
400 AudioDescription,
402 Caption,
404 Subtitle,
406 SignLanguage,
408 Unknown(String),
410}
411
412impl fmt::Display for Role {
413 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414 match self {
415 Role::Video => write!(f, "video"),
416 Role::Audio => write!(f, "audio"),
417 Role::AudioDescription => write!(f, "audiodescription"),
418 Role::Caption => write!(f, "caption"),
419 Role::Subtitle => write!(f, "subtitle"),
420 Role::SignLanguage => write!(f, "signlanguage"),
421 Role::Unknown(s) => write!(f, "{s}"),
422 }
423 }
424}
425
426impl FromStr for Role {
427 type Err = std::convert::Infallible;
428
429 fn from_str(s: &str) -> Result<Self, Self::Err> {
430 Ok(match s {
431 "video" => Role::Video,
432 "audio" => Role::Audio,
433 "audiodescription" => Role::AudioDescription,
434 "caption" => Role::Caption,
435 "subtitle" => Role::Subtitle,
436 "signlanguage" => Role::SignLanguage,
437 other => Role::Unknown(other.to_string()),
438 })
439 }
440}
441
442impl Serialize for Role {
443 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
444 serializer.serialize_str(&self.to_string())
445 }
446}
447
448impl<'de> Deserialize<'de> for Role {
449 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
450 let s = String::deserialize(deserializer)?;
451 Ok(Role::from_str(&s).unwrap())
453 }
454}
455
456#[cfg(test)]
457mod test {
458 use super::*;
459
460 fn video_track() -> Track {
461 Track {
462 name: "video0".to_string(),
463 packaging: Packaging::Legacy,
464 is_live: true,
465 role: Some(Role::Video),
466 codec: Some("avc3.64001f".to_string()),
467 width: Some(1280),
468 height: Some(720),
469 framerate: Some(30.0),
470 samplerate: None,
471 channel_config: None,
472 bitrate: Some(6_000_000),
473 init_data: None,
474 init_ref: None,
475 render_group: Some(1),
476 alt_group: None,
477 max_grp_sap_starting_type: None,
478 max_obj_sap_starting_type: None,
479 jitter: None,
480 }
481 }
482
483 fn audio_track() -> Track {
484 Track {
485 name: "audio0".to_string(),
486 packaging: Packaging::Legacy,
487 is_live: true,
488 role: Some(Role::Audio),
489 codec: Some("opus".to_string()),
490 width: None,
491 height: None,
492 framerate: None,
493 samplerate: Some(48_000),
494 channel_config: Some("2".to_string()),
495 bitrate: Some(128_000),
496 init_data: None,
497 init_ref: None,
498 render_group: Some(1),
499 alt_group: None,
500 max_grp_sap_starting_type: None,
501 max_obj_sap_starting_type: None,
502 jitter: None,
503 }
504 }
505
506 fn track_with_sap_and_jitter() -> Track {
507 Track {
508 name: "video0".to_string(),
509 packaging: Packaging::Cmaf,
510 is_live: true,
511 role: Some(Role::Video),
512 codec: Some("avc1.640028".to_string()),
513 width: Some(1920),
514 height: Some(1080),
515 framerate: Some(30.0),
516 samplerate: None,
517 channel_config: None,
518 bitrate: Some(5_000_000),
519 init_data: None,
520 init_ref: None,
521 render_group: Some(1),
522 alt_group: None,
523 max_grp_sap_starting_type: Some(1),
524 max_obj_sap_starting_type: Some(2),
525 jitter: Some(Duration::from_millis(15)),
526 }
527 }
528
529 #[test]
530 fn serialize_video_track() {
531 let catalog = Catalog {
532 tracks: vec![video_track()],
533 };
534
535 let json = catalog.to_string().unwrap();
536 let parsed = Catalog::from_str(&json).unwrap();
537 assert_eq!(catalog, parsed);
538
539 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
541 let track = &value["tracks"][0];
542 assert!(track.get("samplerate").is_none());
543 assert!(track.get("channelConfig").is_none());
544
545 assert!(track.get("maxGrpSapStartingType").is_none());
547 assert!(track.get("maxObjSapStartingType").is_none());
548 assert!(track.get("jitter").is_none());
549 }
550
551 #[test]
552 fn serialize_audio_track() {
553 let catalog = Catalog {
554 tracks: vec![audio_track()],
555 };
556
557 let json = catalog.to_string().unwrap();
558 let parsed = Catalog::from_str(&json).unwrap();
559 assert_eq!(catalog, parsed);
560
561 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
563 let track = &value["tracks"][0];
564 assert!(track.get("width").is_none());
565 assert!(track.get("height").is_none());
566 assert!(track.get("framerate").is_none());
567 }
568
569 #[test]
570 fn packaging_roundtrip() {
571 for (s, expected) in [
572 ("loc", Packaging::Loc),
573 ("cmaf", Packaging::Cmaf),
574 ("legacy", Packaging::Legacy),
575 ("mediatimeline", Packaging::MediaTimeline),
576 ("eventtimeline", Packaging::EventTimeline),
577 ("custom", Packaging::Unknown("custom".to_string())),
578 ] {
579 let packaging: Packaging = s.parse().unwrap();
580 assert_eq!(packaging, expected);
581 assert_eq!(packaging.to_string(), s);
582 }
583 }
584
585 #[test]
586 fn role_roundtrip() {
587 for (s, expected) in [
588 ("video", Role::Video),
589 ("audio", Role::Audio),
590 ("audiodescription", Role::AudioDescription),
591 ("caption", Role::Caption),
592 ("subtitle", Role::Subtitle),
593 ("signlanguage", Role::SignLanguage),
594 ("custom", Role::Unknown("custom".to_string())),
595 ] {
596 let role: Role = s.parse().unwrap();
597 assert_eq!(role, expected);
598 assert_eq!(role.to_string(), s);
599 }
600 }
601
602 #[test]
603 fn roundtrip_empty() {
604 let catalog = Catalog { tracks: vec![] };
605 let json = catalog.to_string().unwrap();
606 let parsed = Catalog::from_str(&json).unwrap();
607 assert_eq!(catalog, parsed);
608 }
609
610 #[test]
611 fn cmaf_packaging() {
612 let mut track = track_with_sap_and_jitter();
613 track.name = "hd".to_string();
614 track.alt_group = Some(1);
615 track.max_grp_sap_starting_type = None;
616 track.max_obj_sap_starting_type = None;
617 track.jitter = None;
618 track.init_data = Some("AQID".to_string());
619
620 let catalog = Catalog { tracks: vec![track] };
621
622 let json = catalog.to_string().unwrap();
623 assert!(json.contains("\"packaging\":\"cmaf\""));
624 let parsed = Catalog::from_str(&json).unwrap();
625 assert_eq!(catalog, parsed);
626 assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
627 }
628
629 #[test]
630 fn serialize_sap_fields() {
631 let catalog = Catalog {
632 tracks: vec![track_with_sap_and_jitter()],
633 };
634
635 let json = catalog.to_string().unwrap();
636
637 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
640 let track = &value["tracks"][0];
641 assert_eq!(track.get("maxGrpSapStartingType"), Some(&serde_json::json!(1)));
642 assert_eq!(track.get("maxObjSapStartingType"), Some(&serde_json::json!(2)));
643 assert_eq!(track.get("jitter").and_then(serde_json::Value::as_f64), Some(15.0));
644
645 assert!(track.get("max_grp_sap_starting_type").is_none());
647 assert!(track.get("max_obj_sap_starting_type").is_none());
648 }
649
650 #[test]
651 fn deserialize_without_sap_fields() {
652 let json = r#"{
655 "version": 1,
656 "tracks": [{
657 "name": "video0",
658 "packaging": "cmaf",
659 "isLive": true,
660 "role": "video",
661 "codec": "avc1.640028",
662 "width": 1920,
663 "height": 1080,
664 "framerate": 30.0,
665 "bitrate": 5000000,
666 "renderGroup": 1
667 }]
668 }"#;
669
670 let catalog = Catalog::from_str(json).unwrap();
671 let track = &catalog.tracks[0];
672 assert_eq!(track.max_grp_sap_starting_type, None);
673 assert_eq!(track.max_obj_sap_starting_type, None);
674 assert_eq!(track.jitter, None);
675 }
676
677 #[test]
678 fn sap_and_jitter_roundtrip() {
679 let original = Catalog {
680 tracks: vec![track_with_sap_and_jitter()],
681 };
682
683 let json = original.to_string().unwrap();
684 let parsed = Catalog::from_str(&json).unwrap();
685 assert_eq!(original, parsed);
686 assert_eq!(parsed.tracks[0].max_grp_sap_starting_type, Some(1));
687 assert_eq!(parsed.tracks[0].max_obj_sap_starting_type, Some(2));
688 assert_eq!(parsed.tracks[0].jitter, Some(Duration::from_millis(15)));
689 }
690
691 #[test]
692 fn fractional_jitter_roundtrips() {
693 let json = r#"{
694 "version": "draft-01",
695 "tracks": [{
696 "name": "video0",
697 "packaging": "cmaf",
698 "isLive": true,
699 "role": "video",
700 "codec": "avc1.640028",
701 "jitter": 15.0
702 }]
703 }"#;
704
705 let catalog = Catalog::from_str(json).expect("fractional jitter must decode");
706 assert_eq!(catalog.tracks[0].jitter, Some(Duration::from_millis(15)));
707
708 let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap();
709 assert_eq!(value["tracks"][0]["jitter"].as_f64(), Some(15.0));
710 }
711
712 #[test]
713 fn serialize_emits_draft01_version() {
714 let json = Catalog::default().to_string().unwrap();
716 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
717 assert_eq!(value["version"], serde_json::json!("draft-01"));
718 }
719
720 #[test]
721 fn draft00_numeric_version_decodes_and_normalizes() {
722 let catalog = Catalog::from_str(r#"{"version":1,"tracks":[]}"#).unwrap();
725 assert!(catalog.tracks.is_empty());
726
727 let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap();
728 assert_eq!(value["version"], serde_json::json!("draft-01"));
729 }
730
731 #[test]
732 fn draft01_string_version_decodes() {
733 let catalog = Catalog::from_str(r#"{"version":"draft-01","tracks":[]}"#).unwrap();
734 assert!(catalog.tracks.is_empty());
735 }
736
737 #[test]
738 fn unknown_version_string_is_accepted() {
739 assert!(Catalog::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok());
742 }
743
744 #[test]
745 fn unsupported_numeric_version_errors() {
746 assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err());
748 }
749
750 #[test]
751 fn float_numeric_version_is_accepted() {
752 assert!(Catalog::from_str(r#"{"version":1.0,"tracks":[]}"#).is_ok());
755 assert!(Catalog::from_str(r#"{"version":2.0,"tracks":[]}"#).is_err());
756 }
757
758 #[test]
759 fn unresolved_init_ref_leaves_init_data_none() {
760 let json = r#"{
764 "version": "draft-01",
765 "initDataList": [
766 { "id": "v0", "type": "url", "data": "https://example.com/init" }
767 ],
768 "tracks": [
769 { "name": "a", "packaging": "cmaf", "isLive": true, "role": "video",
770 "codec": "avc1.640028", "initRef": "missing" },
771 { "name": "b", "packaging": "cmaf", "isLive": true, "role": "video",
772 "codec": "avc1.640028", "initRef": "v0" }
773 ]
774 }"#;
775
776 let catalog = Catalog::from_str(json).unwrap();
777 assert_eq!(catalog.tracks[0].init_data, None);
778 assert_eq!(catalog.tracks[1].init_data, None);
779 }
780
781 #[test]
782 fn draft01_init_ref_resolves_to_inline() {
783 let json = r#"{
786 "version": "draft-01",
787 "initDataList": [
788 { "id": "v0", "type": "inline", "data": "AQID" }
789 ],
790 "tracks": [
791 { "name": "video0", "packaging": "cmaf", "isLive": true, "role": "video",
792 "codec": "avc1.640028", "initRef": "v0" }
793 ]
794 }"#;
795
796 let catalog = Catalog::from_str(json).unwrap();
797 assert_eq!(catalog.tracks[0].init_data.as_deref(), Some("AQID"));
798 }
799
800 #[test]
801 fn serialize_hoists_and_dedups_init_data() {
802 let mut a = video_track();
806 a.name = "a".to_string();
807 a.init_data = Some("AQID".to_string());
808 let mut b = video_track();
809 b.name = "b".to_string();
810 b.init_data = Some("AQID".to_string());
811
812 let catalog = Catalog { tracks: vec![a, b] };
813 let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap();
814
815 let list = value["initDataList"].as_array().expect("initDataList present");
816 assert_eq!(list.len(), 1, "identical payloads should dedup to one entry");
817 assert_eq!(list[0]["data"], serde_json::json!("AQID"));
818 assert_eq!(list[0]["type"], serde_json::json!("inline"));
819
820 let id = list[0]["id"].as_str().unwrap();
821 for t in value["tracks"].as_array().unwrap() {
822 assert_eq!(t["initRef"], serde_json::json!(id));
823 assert!(t.get("initData").is_none(), "no inline initData on the wire");
824 }
825
826 let parsed = Catalog::from_str(&catalog.to_string().unwrap()).unwrap();
828 assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID"));
829 assert_eq!(parsed.tracks[1].init_data.as_deref(), Some("AQID"));
830 }
831
832 #[test]
833 fn draft00_example_av_decodes() {
834 let json = r#"{
838 "version": 1,
839 "generatedAt": 1746104606044,
840 "tracks": [
841 {
842 "name": "1080p-video",
843 "namespace": "conference.example.com/conference123/alice",
844 "packaging": "loc",
845 "isLive": true,
846 "targetLatency": 2000,
847 "role": "video",
848 "renderGroup": 1,
849 "codec": "av01.0.08M.10.0.110.09",
850 "width": 1920,
851 "height": 1080,
852 "framerate": 30,
853 "bitrate": 1500000
854 },
855 {
856 "name": "audio",
857 "namespace": "conference.example.com/conference123/alice",
858 "packaging": "loc",
859 "isLive": true,
860 "targetLatency": 2000,
861 "role": "audio",
862 "codec": "opus",
863 "samplerate": 48000,
864 "channelConfig": "2",
865 "bitrate": 32000
866 }
867 ]
868 }"#;
869
870 let catalog = Catalog::from_str(json).expect("draft-00 AV catalog must decode");
871 assert_eq!(catalog.tracks.len(), 2);
872 assert_eq!(catalog.tracks[0].framerate, Some(30.0));
873 assert_eq!(catalog.tracks[1].channel_config.as_deref(), Some("2"));
874 }
875
876 #[test]
877 fn draft00_example_timeline_tracks_decode() {
878 let json = r#"{
881 "version": 1,
882 "generatedAt": 1746104606044,
883 "tracks": [
884 {
885 "name": "history",
886 "namespace": "conference.example.com/conference123/alice",
887 "packaging": "mediatimeline",
888 "mimetype": "application/json",
889 "depends": ["1080p-video", "audio"]
890 },
891 {
892 "name": "1080p-video",
893 "namespace": "conference.example.com/conference123/alice",
894 "packaging": "loc",
895 "isLive": true,
896 "role": "video",
897 "codec": "av01.0.08M.10.0.110.09",
898 "width": 1920,
899 "height": 1080,
900 "framerate": 30,
901 "bitrate": 1500000
902 }
903 ]
904 }"#;
905
906 let catalog = Catalog::from_str(json).expect("draft-00 timeline catalog must decode");
907 assert_eq!(catalog.tracks.len(), 2);
908 assert!(!catalog.tracks[0].is_live);
910 assert_eq!(catalog.tracks[0].packaging, Packaging::MediaTimeline);
911 }
912
913 #[test]
914 fn draft00_example_complete_decodes() {
915 let json = r#"{
917 "version": 1,
918 "generatedAt": 1746104606044,
919 "isComplete": true,
920 "tracks": []
921 }"#;
922 let catalog = Catalog::from_str(json).expect("draft-00 completion catalog must decode");
923 assert!(catalog.tracks.is_empty());
924 }
925}