From 48719201ffc2721e2ef47c574ce2a050c949e00c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:37:03 +0000 Subject: [PATCH 1/4] feat(moq-msf): support draft-01 string version with draft-00 fallback draft-ietf-moq-msf-01 changed the catalog `version` field from a JSON number (`1`) to a `"draft-XX"` string. Introduce a `Version` enum that serializes the newest draft (`"draft-01"`) by default while still decoding draft-00's numeric `1`, so older publishers stay compatible. `Catalog::version` changes type from `u32` to `Version`. moq-mux now emits `"draft-01"` and its MSF consumer accepts both encodings. The JS `@moq/msf` schema accepts the number or any `"draft-XX"` string and exports a `VERSION` constant. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R2ruH25xAPfNhydKJ9Q67n --- doc/concept/standard/msf.md | 7 +- js/msf/src/catalog.ts | 18 ++- rs/moq-msf/CHANGELOG.md | 4 + rs/moq-msf/README.md | 3 +- rs/moq-msf/src/lib.rs | 160 +++++++++++++++++++++++-- rs/moq-mux/CHANGELOG.md | 4 + rs/moq-mux/src/catalog/msf/consumer.rs | 40 +++---- rs/moq-mux/src/catalog/producer.rs | 9 +- 8 files changed, 207 insertions(+), 38 deletions(-) diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index ceefbda89..cb85f4fb2 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -9,7 +9,10 @@ HLS/DASH playlists suck. WebRTC SDP is even worse. MSF is a replacement for both, utilizing MoQ live streams. -[MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) is a catalog format for MoQ. +[MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) is a catalog format for MoQ. It's similar to the [hang catalog](../layer/hang) and we'll probably merge them in the future. -[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.html) for the latest details. +We track draft-01, which changed the catalog `version` from a number to a `"draft-XX"` string. +The older numeric form from draft-00 still decodes for backwards compatibility. + +[See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) for the latest details. diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 1fb9c14cb..f7685a86a 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -45,9 +45,23 @@ export const TrackSchema = z.object({ /** A single track in an MSF catalog, including its codec and media properties. */ export type Track = z.infer; -/** Zod schema for the top-level MSF catalog (version 1). */ +/** + * Zod schema for the catalog version. + * + * draft-00 put the JSON number `1` here; draft-01 switched to a `"draft-XX"` + * string. Both are accepted when decoding for backwards compatibility. + */ +export const VersionSchema = z.union([z.literal(1), z.string()]); + +/** The catalog version: the legacy number `1` (draft-00) or a `"draft-XX"` string. */ +export type Version = z.infer; + +/** The newest MSF draft version string this package emits. */ +export const VERSION = "draft-01"; + +/** Zod schema for the top-level MSF catalog. */ export const CatalogSchema = z.object({ - version: z.literal(1), + version: VersionSchema, tracks: z.array(TrackSchema), }); diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 4c18e88ca..ac858396b 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Track draft-ietf-moq-msf-01: the catalog `version` field is now a `"draft-XX"` string instead of a number. The new `Version` enum still decodes draft-00's numeric `1` for backwards compatibility, and `Catalog::version` changed type from `u32` to `Version`. + ## [0.2.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.1.3...moq-msf-v0.2.0) - 2026-05-23 ### Added diff --git a/rs/moq-msf/README.md b/rs/moq-msf/README.md index f796ae802..b5c2620f7 100644 --- a/rs/moq-msf/README.md +++ b/rs/moq-msf/README.md @@ -10,8 +10,9 @@ Catalog types for the MOQT Streaming Format (MSF). -This crate implements the catalog format defined in [draft-ietf-moq-msf-00](https://www.ietf.org/archive/id/draft-ietf-moq-msf-00.txt), +This crate implements the catalog format defined in [draft-ietf-moq-msf-01](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.txt), with additional support for CMAF packaging from [draft-ietf-moq-cmsf-00](https://www.ietf.org/archive/id/draft-ietf-moq-cmsf-00.txt). +draft-00 catalogs (which used a numeric `version`) still decode, so older publishers remain compatible. Used by [moq-mux](https://github.com/moq-dev/moq/tree/main/rs/moq-mux) for muxing/demuxing media. For the higher-level [hang](https://github.com/moq-dev/moq/tree/main/rs/hang) catalog format used elsewhere in this repo, see that crate. diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index a298a3846..3f0106370 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -1,11 +1,14 @@ //! MSF (MOQT Streaming Format) catalog types. //! //! This crate provides types for the MSF catalog format as defined in -//! draft-ietf-moq-msf-00, with additional support for CMAF packaging +//! draft-ietf-moq-msf-01, with additional support for CMAF packaging //! from draft-ietf-moq-cmsf-00. //! +//! draft-01 changed the catalog `version` from a JSON number to a `"draft-XX"` +//! string. [`Version`] parses both forms, so draft-00 catalogs still decode. +//! //! References: -//! - +//! - //! - use std::fmt; @@ -23,13 +26,103 @@ pub const DEFAULT_NAME: &str = "catalog"; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Catalog { - /// MSF version. Always 1 for this draft. - pub version: u32, + /// MSF catalog version. Defaults to [`Version::CURRENT`]. + pub version: Version, /// Array of track descriptions. pub tracks: Vec, } +impl Default for Catalog { + fn default() -> Self { + Self { + version: Version::CURRENT, + tracks: Vec::new(), + } + } +} + +/// MSF catalog version. +/// +/// draft-00 put the JSON number `1` in the `version` field. draft-01 switched to +/// a JSON string of the form `"draft-XX"`. Both forms are accepted when parsing +/// for backwards compatibility; new catalogs serialize as the newest draft this +/// crate emits ([`Version::CURRENT`]). +/// +/// The variant preserves the wire encoding it was parsed from, so a draft-00 +/// catalog re-serializes as the number `1` and a draft-01 catalog as +/// `"draft-01"`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Version { + /// draft-ietf-moq-msf-00, encoded on the wire as the JSON number `1`. + Draft00, + /// draft-ietf-moq-msf-01, encoded on the wire as the JSON string `"draft-01"`. + Draft01, + /// A version string this crate doesn't recognize, e.g. a future `"draft-02"`. + /// Preserved verbatim so it round-trips. + Unknown(String), +} + +impl Version { + /// The newest MSF version this crate emits by default. + pub const CURRENT: Version = Version::Draft01; +} + +impl Default for Version { + fn default() -> Self { + Version::CURRENT + } +} + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result { + match self { + // draft-00 encoded the version as a bare JSON number. + Version::Draft00 => serializer.serialize_u32(1), + Version::Draft01 => serializer.serialize_str("draft-01"), + Version::Unknown(s) => serializer.serialize_str(s), + } + } +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize>(deserializer: D) -> Result { + struct VersionVisitor; + + impl serde::de::Visitor<'_> for VersionVisitor { + type Value = Version; + + 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(self, v: u64) -> Result { + match v { + 1 => Ok(Version::Draft00), + other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))), + } + } + + fn visit_i64(self, v: i64) -> Result { + match u64::try_from(v) { + Ok(v) => self.visit_u64(v), + Err(_) => Err(E::custom(format!("unsupported MSF catalog version: {v}"))), + } + } + + fn visit_str(self, v: &str) -> Result { + Ok(match v { + "draft-01" => Version::Draft01, + other => Version::Unknown(other.to_string()), + }) + } + } + + deserializer.deserialize_any(VersionVisitor) + } +} + /// A single track in the MSF catalog. /// /// Marked `#[non_exhaustive]` because the CMSF/MSF drafts continue to grow @@ -277,7 +370,7 @@ mod test { #[test] fn serialize_video_track() { let catalog = Catalog { - version: 1, + version: Version::default(), tracks: vec![Track { name: "video0".to_string(), packaging: Packaging::Legacy, @@ -318,7 +411,7 @@ mod test { #[test] fn serialize_audio_track() { let catalog = Catalog { - version: 1, + version: Version::default(), tracks: vec![Track { name: "audio0".to_string(), packaging: Packaging::Legacy, @@ -388,7 +481,7 @@ mod test { #[test] fn roundtrip_empty() { let catalog = Catalog { - version: 1, + version: Version::default(), tracks: vec![], }; let json = catalog.to_string().unwrap(); @@ -399,7 +492,7 @@ mod test { #[test] fn cmaf_packaging() { let catalog = Catalog { - version: 1, + version: Version::default(), tracks: vec![Track { name: "hd".to_string(), packaging: Packaging::Cmaf, @@ -452,7 +545,7 @@ mod test { #[test] fn serialize_sap_fields() { let catalog = Catalog { - version: 1, + version: Version::default(), tracks: vec![track_with_sap_and_jitter()], }; @@ -501,7 +594,7 @@ mod test { #[test] fn sap_and_jitter_roundtrip() { let original = Catalog { - version: 1, + version: Version::default(), tracks: vec![track_with_sap_and_jitter()], }; @@ -512,4 +605,51 @@ mod test { 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 default_catalog_emits_draft01_string() { + // New catalogs carry the draft-01 string version on the wire. + 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 legacy_numeric_version_parses() { + // draft-00 catalogs put the JSON number 1 in `version`. They must still + // decode, and re-serialize back to the same numeric form. + let json = r#"{"version":1,"tracks":[]}"#; + let catalog = Catalog::from_str(json).unwrap(); + assert_eq!(catalog.version, Version::Draft00); + + let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); + assert_eq!(value["version"], serde_json::json!(1)); + } + + #[test] + fn draft01_string_version_parses() { + let json = r#"{"version":"draft-01","tracks":[]}"#; + let catalog = Catalog::from_str(json).unwrap(); + assert_eq!(catalog.version, Version::Draft01); + + 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 unknown_version_string_roundtrips() { + // A future draft we don't recognize is preserved verbatim rather than rejected. + let json = r#"{"version":"draft-99","tracks":[]}"#; + let catalog = Catalog::from_str(json).unwrap(); + assert_eq!(catalog.version, Version::Unknown("draft-99".to_string())); + + let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); + assert_eq!(value["version"], serde_json::json!("draft-99")); + } + + #[test] + fn unsupported_numeric_version_errors() { + // Numbers other than 1 never had a defined meaning, so reject them. + assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err()); + } } diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index ee6c52733..25364512c 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Emit MSF catalogs at draft-ietf-moq-msf-01: the `version` field is now the string `"draft-01"` instead of the number `1`. The MSF consumer still accepts both forms. + ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.5.5...moq-mux-v0.5.6) - 2026-06-17 ### Added diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 448fcc86b..9bdf5c3bf 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -393,7 +393,7 @@ mod test { let expected_init = base64::engine::general_purpose::STANDARD.decode(init_b64).unwrap(); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; @@ -413,7 +413,7 @@ mod test { #[test] fn loc_audio_yields_legacy_container() { let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![audio_track("audio0", moq_msf::Packaging::Loc)], }; @@ -442,7 +442,7 @@ mod test { audio.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![video, audio], }; @@ -460,7 +460,7 @@ mod test { // must stay None so downstream code reads the bytes from one place only. let init_b64 = "AAAYZ2Z0eXA="; let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; let catalog = from_msf(&msf).unwrap(); @@ -472,7 +472,7 @@ mod test { let mut track = video_track("video0", moq_msf::Packaging::Legacy, Some("!!!not-base64!!!")); track.codec = Some("avc1.42c01e".to_string()); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; let err = from_msf(&msf).expect_err("malformed base64 should error"); @@ -488,7 +488,7 @@ mod test { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("weirdcodec".to_string()); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -500,7 +500,7 @@ mod test { #[test] fn cmaf_without_init_data_is_error() { let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, None)], }; @@ -512,7 +512,7 @@ mod test { #[test] fn empty_catalog_is_empty_hang_catalog() { let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![], }; @@ -526,7 +526,7 @@ mod test { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.role = None; let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -540,7 +540,7 @@ mod test { let mut track = audio_track("caption0", moq_msf::Packaging::Legacy); track.role = Some(moq_msf::Role::Caption); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -558,7 +558,7 @@ mod test { track.channel_config = None; track.init_data = None; let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -585,7 +585,7 @@ mod test { track.channel_config = None; track.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -612,7 +612,7 @@ mod test { track.channel_config = None; track.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -640,7 +640,7 @@ mod test { track.channel_config = None; // missing, derive from init_data track.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -656,7 +656,7 @@ mod test { let bad = video_track("timeline0", moq_msf::Packaging::MediaTimeline, None); let good = video_track("video0", moq_msf::Packaging::Legacy, None); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![bad, good], }; @@ -678,7 +678,7 @@ mod test { bad.codec = None; let good = audio_track("audio0", moq_msf::Packaging::Loc); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![bad, good], }; @@ -691,7 +691,7 @@ mod test { fn unknown_packaging_variant_is_skipped() { let track = video_track("video0", moq_msf::Packaging::Unknown("custom".to_string()), None); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -704,7 +704,7 @@ mod test { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = None; let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -721,7 +721,7 @@ mod test { let mut track = audio_track("audio0", moq_msf::Packaging::Legacy); track.codec = None; let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; @@ -739,7 +739,7 @@ mod test { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("avc1.0".to_string()); let msf = moq_msf::Catalog { - version: 1, + version: moq_msf::Version::default(), tracks: vec![track], }; diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 06c73c3b0..564803c4d 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -262,7 +262,10 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { tracks.push(track); } - moq_msf::Catalog { version: 1, tracks } + moq_msf::Catalog { + version: moq_msf::Version::CURRENT, + tracks, + } } #[cfg(test)] @@ -312,7 +315,7 @@ mod test { let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); + assert_eq!(msf.version, moq_msf::Version::CURRENT); assert_eq!(msf.tracks.len(), 2); let video = &msf.tracks[0]; @@ -378,7 +381,7 @@ mod test { fn convert_empty() { let catalog = hang::Catalog::default(); let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); + assert_eq!(msf.version, moq_msf::Version::CURRENT); assert!(msf.tracks.is_empty()); } From 40e9d75c1904614e70c72d0e63eb700ffe16352c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 17:31:58 +0000 Subject: [PATCH 2/4] fix(moq-msf): tolerate draft-00 tracks that omit isLive Verifying backwards compatibility against the draft-ietf-moq-msf-00 examples surfaced a real gap: the spec marks `isLive` required but its own mediatimeline/eventtimeline track examples omit it. With `isLive` as a required field, serde and zod rejected the entire catalog. Default `is_live` to false when absent (Rust `#[serde(default)]`, JS `z.optional`) so draft-00 catalogs decode. Add tests built from the draft-00 example JSON (AV, timeline tracks, completion) in both languages. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R2ruH25xAPfNhydKJ9Q67n --- js/msf/src/catalog.test.ts | 76 ++++++++++++++++++++++++++++ js/msf/src/catalog.ts | 4 +- js/msf/tsconfig.json | 3 +- rs/moq-msf/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 js/msf/src/catalog.test.ts diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts new file mode 100644 index 000000000..7fc0bbd69 --- /dev/null +++ b/js/msf/src/catalog.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { decode } from "./catalog.ts"; + +function encodeJson(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +test("decodes a draft-00 catalog with a numeric version", () => { + // Example 1 from draft-ietf-moq-msf-00, trimmed. Numeric version plus unmodeled + // fields (namespace, targetLatency, generatedAt) which must be ignored. + const catalog = decode( + encodeJson({ + version: 1, + generatedAt: 1746104606044, + tracks: [ + { + name: "1080p-video", + namespace: "conference.example.com/conference123/alice", + packaging: "loc", + isLive: true, + targetLatency: 2000, + role: "video", + codec: "av01.0.08M.10.0.110.09", + width: 1920, + height: 1080, + framerate: 30, + bitrate: 1500000, + }, + ], + }), + ); + + expect(catalog.version).toBe(1); + expect(catalog.tracks).toHaveLength(1); + expect(catalog.tracks[0].codec).toBe("av01.0.08M.10.0.110.09"); +}); + +test("decodes a draft-00 catalog whose timeline tracks omit isLive", () => { + // Example 8 from draft-ietf-moq-msf-00: mediatimeline tracks omit isLive/role/codec. + const catalog = decode( + encodeJson({ + version: 1, + tracks: [ + { + name: "history", + packaging: "mediatimeline", + mimetype: "application/json", + depends: ["1080p-video"], + }, + { + name: "1080p-video", + packaging: "loc", + isLive: true, + role: "video", + codec: "av01.0.08M.10.0.110.09", + }, + ], + }), + ); + + expect(catalog.tracks).toHaveLength(2); + expect(catalog.tracks[0].isLive).toBeUndefined(); + expect(catalog.tracks[0].packaging).toBe("mediatimeline"); +}); + +test("decodes a draft-01 catalog with a string version", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + tracks: [{ name: "audio", packaging: "loc", isLive: true, role: "audio", codec: "opus" }], + }), + ); + + expect(catalog.version).toBe("draft-01"); + expect(catalog.tracks[0].role).toBe("audio"); +}); diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index f7685a86a..34e86283e 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -23,7 +23,9 @@ export type Role = z.infer; export const TrackSchema = z.object({ name: z.string(), packaging: PackagingSchema, - isLive: z.boolean(), + // draft-00 marks isLive required but omits it on mediatimeline/eventtimeline + // tracks, so accept its absence rather than reject the whole catalog. + isLive: z.optional(z.boolean()), role: z.optional(RoleSchema), codec: z.optional(z.string()), width: z.optional(z.number()), diff --git a/js/msf/tsconfig.json b/js/msf/tsconfig.json index 0f506334d..bb55d7c43 100644 --- a/js/msf/tsconfig.json +++ b/js/msf/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["bun"] }, "include": ["src"] } diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 3f0106370..27a9d1922 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -143,6 +143,11 @@ pub struct Track { pub packaging: Packaging, /// Whether new objects will be appended. + /// + /// draft-00 marks this required, but its own examples omit it on + /// `mediatimeline`/`eventtimeline` tracks, so we default to `false` when + /// absent rather than reject the whole catalog. + #[serde(default)] pub is_live: bool, /// Content role. @@ -652,4 +657,99 @@ mod test { // Numbers other than 1 never had a defined meaning, so reject them. assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err()); } + + #[test] + fn draft00_example_av_decodes() { + // Example 1 from draft-ietf-moq-msf-00: time-aligned audio/video. Exercises the + // numeric version, integer framerate into an f64 field, and unmodeled fields + // (namespace, targetLatency, generatedAt) which must be ignored, not rejected. + 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.version, Version::Draft00); + 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() { + // Example 8 from draft-ietf-moq-msf-00: mediatimeline/eventtimeline tracks omit + // isLive/role/codec entirely. The whole catalog must still 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); + // The timeline track had no isLive; it must default rather than fail the parse. + assert!(!catalog.tracks[0].is_live); + assert_eq!(catalog.tracks[0].packaging, Packaging::MediaTimeline); + } + + #[test] + fn draft00_example_complete_decodes() { + // Example 9: terminating a live broadcast (isComplete, empty tracks). + 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()); + } } From 2f20b959ec43edb4deb4abc5dbf743ce9f78752a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 20:41:14 +0000 Subject: [PATCH 3/4] refactor(moq-msf): hide wire version + init-data behind a snapshot API draft-01 changes more than the version field: init data moved out of the track (inline `initData`) into a root `initDataList` referenced per-track by `initRef`, and `version` became a `"draft-XX"` string. Rather than leak any of that, make `Catalog` a version-agnostic snapshot (`{ tracks }`) and push the wire details into (de)serialization. - Drop the public `version` field and `Version` enum. Parsing accepts draft-00 (numeric version, inline initData) and draft-01 (string version, initDataList + initRef); serializing always emits draft-01. - Init data is always presented inline via `Track::init_data`: initRef is resolved against initDataList on parse, and on serialize identical payloads are hoisted into a deduplicated initDataList with initRef pointers. Callers never see the version or the indirection. - moq-mux producer/consumer are unchanged except dropping the version field; they keep using inline `init_data` and the abstraction handles the wire form. - Mirror the same abstraction in `@moq/msf` (encode/decode hide version, initDataList, initRef). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R2ruH25xAPfNhydKJ9Q67n --- doc/concept/standard/msf.md | 6 +- js/msf/src/catalog.test.ts | 47 ++- js/msf/src/catalog.ts | 99 +++-- rs/moq-msf/CHANGELOG.md | 2 +- rs/moq-msf/README.md | 3 +- rs/moq-msf/src/lib.rs | 531 +++++++++++++++---------- rs/moq-mux/CHANGELOG.md | 2 +- rs/moq-mux/src/catalog/msf/consumer.rs | 72 +--- rs/moq-mux/src/catalog/producer.rs | 7 +- 9 files changed, 454 insertions(+), 315 deletions(-) diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index cb85f4fb2..d1c84724d 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -12,7 +12,9 @@ MSF is a replacement for both, utilizing MoQ live streams. [MSF](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) is a catalog format for MoQ. It's similar to the [hang catalog](../layer/hang) and we'll probably merge them in the future. -We track draft-01, which changed the catalog `version` from a number to a `"draft-XX"` string. -The older numeric form from draft-00 still decodes for backwards compatibility. +We track draft-01, which changed the catalog `version` from a number to a `"draft-XX"` string and +moved init data out of the track into a root `initDataList` referenced by `initRef`. +Our implementation hides this on the wire: the catalog API is a version-agnostic snapshot, draft-00 +catalogs still decode, and init data is always presented inline regardless of how it was carried. [See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) for the latest details. diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index 7fc0bbd69..d64148d31 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -1,10 +1,14 @@ import { expect, test } from "bun:test"; -import { decode } from "./catalog.ts"; +import { decode, encode } from "./catalog.ts"; function encodeJson(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } +function decodeJson(raw: Uint8Array): Record { + return JSON.parse(new TextDecoder().decode(raw)); +} + test("decodes a draft-00 catalog with a numeric version", () => { // Example 1 from draft-ietf-moq-msf-00, trimmed. Numeric version plus unmodeled // fields (namespace, targetLatency, generatedAt) which must be ignored. @@ -30,7 +34,6 @@ test("decodes a draft-00 catalog with a numeric version", () => { }), ); - expect(catalog.version).toBe(1); expect(catalog.tracks).toHaveLength(1); expect(catalog.tracks[0].codec).toBe("av01.0.08M.10.0.110.09"); }); @@ -71,6 +74,44 @@ test("decodes a draft-01 catalog with a string version", () => { }), ); - expect(catalog.version).toBe("draft-01"); expect(catalog.tracks[0].role).toBe("audio"); }); + +test("resolves draft-01 initRef into inline initData", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + initDataList: [{ id: "v0", type: "inline", data: "AQID" }], + tracks: [ + { name: "video0", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initRef: "v0" }, + ], + }), + ); + + expect(catalog.tracks[0].initData).toBe("AQID"); +}); + +test("encode hoists and dedups init data, then round-trips", () => { + const catalog = { + tracks: [ + { name: "a", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initData: "AQID" }, + { name: "b", packaging: "cmaf", isLive: true, role: "video", codec: "avc1.640028", initData: "AQID" }, + ], + }; + + const wire = decodeJson(encode(catalog)); + const list = wire.initDataList as { id: string; type: string; data: string }[]; + expect(list).toHaveLength(1); + expect(list[0].data).toBe("AQID"); + expect(wire.version).toBe("draft-01"); + + const wireTracks = wire.tracks as { initRef?: string; initData?: string }[]; + for (const t of wireTracks) { + expect(t.initRef).toBe(list[0].id); + expect(t.initData).toBeUndefined(); + } + + const parsed = decode(encode(catalog)); + expect(parsed.tracks[0].initData).toBe("AQID"); + expect(parsed.tracks[1].initData).toBe("AQID"); +}); diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 34e86283e..5647a0c7c 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -19,8 +19,9 @@ export const RoleSchema = z.union([ /** The semantic role a track plays in the presentation (e.g. "video", "audio", "caption"). */ export type Role = z.infer; -/** Zod schema describing a single track entry in an MSF catalog. */ -export const TrackSchema = z.object({ +// Shared track fields. This is the version-agnostic shape callers see: init data +// is exposed inline via `initData`, regardless of how it was carried on the wire. +const trackShape = { name: z.string(), packaging: PackagingSchema, // draft-00 marks isLive required but omits it on mediatimeline/eventtimeline @@ -34,6 +35,7 @@ export const TrackSchema = z.object({ samplerate: z.optional(z.number()), channelConfig: z.optional(z.string()), bitrate: z.optional(z.number()), + /** Resolved base64 initialization data (draft-01's initRef indirection is resolved away). */ initData: z.optional(z.string()), renderGroup: z.optional(z.number()), altGroup: z.optional(z.number()), @@ -42,47 +44,92 @@ export const TrackSchema = z.object({ // The player's buffer must be at least this large to avoid underruns. // Mirrors the `jitter` field in the hang catalog. jitter: z.optional(z.number()), -}); +}; + +/** Zod schema describing a single track entry in an MSF catalog. */ +export const TrackSchema = z.object(trackShape); /** A single track in an MSF catalog, including its codec and media properties. */ export type Track = z.infer; -/** - * Zod schema for the catalog version. - * - * draft-00 put the JSON number `1` here; draft-01 switched to a `"draft-XX"` - * string. Both are accepted when decoding for backwards compatibility. - */ -export const VersionSchema = z.union([z.literal(1), z.string()]); +/** Zod schema for the top-level MSF catalog: a version-agnostic snapshot of tracks. */ +export const CatalogSchema = z.object({ + tracks: z.array(TrackSchema), +}); -/** The catalog version: the legacy number `1` (draft-00) or a `"draft-XX"` string. */ -export type Version = z.infer; +/** The MSF catalog: a snapshot of the available tracks. */ +export type Catalog = z.infer; -/** The newest MSF draft version string this package emits. */ +/** The newest MSF draft version string this package emits on the wire. */ export const VERSION = "draft-01"; -/** Zod schema for the top-level MSF catalog. */ -export const CatalogSchema = z.object({ - version: VersionSchema, - tracks: z.array(TrackSchema), +// --- Wire representation (internal) ----------------------------------------- +// +// The wire format hides two things from callers: the catalog `version` (number +// in draft-00, "draft-XX" string in draft-01) and init data, which draft-01 +// moved out of the track into a root `initDataList` referenced by `initRef`. + +const InitDataSchema = z.object({ + id: z.string(), + type: z.string(), + data: z.string(), }); -/** The MSF catalog: a versioned list of available tracks. */ -export type Catalog = z.infer; +const WireTrackSchema = z.object({ + ...trackShape, + initRef: z.optional(z.string()), +}); -/** Serialize a catalog to its JSON wire representation. */ +const WireCatalogSchema = z.object({ + // draft-00 used the number 1; draft-01 uses a "draft-XX" string. Accept both. + version: z.union([z.literal(1), z.string()]), + tracks: z.optional(z.array(WireTrackSchema)), + initDataList: z.optional(z.array(InitDataSchema)), +}); + +/** Serialize a catalog to its JSON wire representation (draft-01). */ export function encode(catalog: Catalog): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(JSON.stringify(catalog)); + // Hoist inline init payloads into a shared, deduplicated initDataList and + // reference each from its track via initRef (the draft-01 wire shape). + const initDataList: z.infer[] = []; + const ids = new Map(); + + const tracks = catalog.tracks.map((track) => { + const { initData, ...rest } = track; + if (initData === undefined) return rest; + + let id = ids.get(initData); + if (id === undefined) { + id = `init${initDataList.length}`; + initDataList.push({ id, type: "inline", data: initData }); + ids.set(initData, id); + } + return { ...rest, initRef: id }; + }); + + const wire: Record = { version: VERSION, tracks }; + if (initDataList.length > 0) wire.initDataList = initDataList; + + return new TextEncoder().encode(JSON.stringify(wire)); } /** Parse and validate a catalog from its JSON wire representation. Throws if invalid. */ export function decode(raw: Uint8Array): Catalog { - const decoder = new TextDecoder(); - const str = decoder.decode(raw); + const str = new TextDecoder().decode(raw); try { - const json = JSON.parse(str); - return CatalogSchema.parse(json); + const wire = WireCatalogSchema.parse(JSON.parse(str)); + const list = wire.initDataList ?? []; + + const tracks = (wire.tracks ?? []).map(({ initRef, ...track }) => { + // Resolve draft-01 initRef into inline initData so callers never see + // the indirection. Inline initData (draft-00) is left untouched. + if (track.initData === undefined && initRef !== undefined) { + track.initData = list.find((e) => e.id === initRef && e.type === "inline")?.data; + } + return track; + }); + + return { tracks }; } catch (error) { console.warn("invalid MSF catalog", str); throw error; diff --git a/rs/moq-msf/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index ac858396b..935098c05 100644 --- a/rs/moq-msf/CHANGELOG.md +++ b/rs/moq-msf/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Track draft-ietf-moq-msf-01: the catalog `version` field is now a `"draft-XX"` string instead of a number. The new `Version` enum still decodes draft-00's numeric `1` for backwards compatibility, and `Catalog::version` changed type from `u32` to `Version`. +- Track draft-ietf-moq-msf-01, with the wire format hidden behind the API. `Catalog` is now a version-agnostic snapshot (`{ tracks }`); the `version` field and the `Version` enum are gone. Parsing accepts draft-00 (numeric `version`, inline `initData`) and draft-01 (string `version`, root `initDataList` + per-track `initRef`); serializing always emits draft-01. Init data is resolved to inline `Track::init_data` on parse and hoisted into a deduplicated `initDataList` on serialize, so callers never touch the version or the init-data indirection. ## [0.2.0](https://github.com/moq-dev/moq/compare/moq-msf-v0.1.3...moq-msf-v0.2.0) - 2026-05-23 diff --git a/rs/moq-msf/README.md b/rs/moq-msf/README.md index b5c2620f7..dbb7fb71f 100644 --- a/rs/moq-msf/README.md +++ b/rs/moq-msf/README.md @@ -12,7 +12,8 @@ Catalog types for the MOQT Streaming Format (MSF). This crate implements the catalog format defined in [draft-ietf-moq-msf-01](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.txt), with additional support for CMAF packaging from [draft-ietf-moq-cmsf-00](https://www.ietf.org/archive/id/draft-ietf-moq-cmsf-00.txt). -draft-00 catalogs (which used a numeric `version`) still decode, so older publishers remain compatible. + +`Catalog` is a version-agnostic snapshot of tracks: the wire details (the catalog `version` and draft-01's `initDataList`/`initRef` indirection for init data) are handled during (de)serialization. Parsing accepts both draft-00 and draft-01 catalogs and serializing always emits the newest draft, so older publishers remain compatible and callers never touch the version on the wire. Used by [moq-mux](https://github.com/moq-dev/moq/tree/main/rs/moq-mux) for muxing/demuxing media. For the higher-level [hang](https://github.com/moq-dev/moq/tree/main/rs/hang) catalog format used elsewhere in this repo, see that crate. diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 27a9d1922..333ddaa59 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -4,8 +4,13 @@ //! draft-ietf-moq-msf-01, with additional support for CMAF packaging //! from draft-ietf-moq-cmsf-00. //! -//! draft-01 changed the catalog `version` from a JSON number to a `"draft-XX"` -//! string. [`Version`] parses both forms, so draft-00 catalogs still decode. +//! [`Catalog`] is a version-agnostic snapshot of tracks. The wire details are +//! hidden behind (de)serialization: parsing accepts both draft-00 (numeric +//! `version`, inline `initData`) and draft-01 (string `version`, with init data +//! held in a root `initDataList` and referenced per-track by `initRef`). +//! Serializing always emits the newest draft, and init data is resolved to +//! inline [`Track::init_data`] either way, so callers never touch the version +//! or the init-data indirection. //! //! References: //! - @@ -21,108 +26,19 @@ use serde_with::DurationMilliSeconds; /// The default track name for the MSF catalog. pub const DEFAULT_NAME: &str = "catalog"; -/// Root MSF catalog object. -#[serde_with::skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] +/// A snapshot of an MSF catalog: the tracks currently in a broadcast. +/// +/// This is a version-agnostic view. The on-wire details (the catalog `version` +/// field, and draft-01's `initDataList`/`initRef` indirection for initialization +/// data) are handled during (de)serialization, so callers only ever see +/// resolved tracks with inline [`Track::init_data`]. Parsing accepts both +/// draft-00 and draft-01 catalogs; serializing always emits the newest draft. +#[derive(Debug, Clone, PartialEq, Default)] pub struct Catalog { - /// MSF catalog version. Defaults to [`Version::CURRENT`]. - pub version: Version, - - /// Array of track descriptions. + /// The tracks in this catalog snapshot. pub tracks: Vec, } -impl Default for Catalog { - fn default() -> Self { - Self { - version: Version::CURRENT, - tracks: Vec::new(), - } - } -} - -/// MSF catalog version. -/// -/// draft-00 put the JSON number `1` in the `version` field. draft-01 switched to -/// a JSON string of the form `"draft-XX"`. Both forms are accepted when parsing -/// for backwards compatibility; new catalogs serialize as the newest draft this -/// crate emits ([`Version::CURRENT`]). -/// -/// The variant preserves the wire encoding it was parsed from, so a draft-00 -/// catalog re-serializes as the number `1` and a draft-01 catalog as -/// `"draft-01"`. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Version { - /// draft-ietf-moq-msf-00, encoded on the wire as the JSON number `1`. - Draft00, - /// draft-ietf-moq-msf-01, encoded on the wire as the JSON string `"draft-01"`. - Draft01, - /// A version string this crate doesn't recognize, e.g. a future `"draft-02"`. - /// Preserved verbatim so it round-trips. - Unknown(String), -} - -impl Version { - /// The newest MSF version this crate emits by default. - pub const CURRENT: Version = Version::Draft01; -} - -impl Default for Version { - fn default() -> Self { - Version::CURRENT - } -} - -impl Serialize for Version { - fn serialize(&self, serializer: S) -> Result { - match self { - // draft-00 encoded the version as a bare JSON number. - Version::Draft00 => serializer.serialize_u32(1), - Version::Draft01 => serializer.serialize_str("draft-01"), - Version::Unknown(s) => serializer.serialize_str(s), - } - } -} - -impl<'de> Deserialize<'de> for Version { - fn deserialize>(deserializer: D) -> Result { - struct VersionVisitor; - - impl serde::de::Visitor<'_> for VersionVisitor { - type Value = Version; - - 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(self, v: u64) -> Result { - match v { - 1 => Ok(Version::Draft00), - other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))), - } - } - - fn visit_i64(self, v: i64) -> Result { - match u64::try_from(v) { - Ok(v) => self.visit_u64(v), - Err(_) => Err(E::custom(format!("unsupported MSF catalog version: {v}"))), - } - } - - fn visit_str(self, v: &str) -> Result { - Ok(match v { - "draft-01" => Version::Draft01, - other => Version::Unknown(other.to_string()), - }) - } - } - - deserializer.deserialize_any(VersionVisitor) - } -} - /// A single track in the MSF catalog. /// /// Marked `#[non_exhaustive]` because the CMSF/MSF drafts continue to grow @@ -174,9 +90,18 @@ pub struct Track { /// Bitrate in bits per second. pub bitrate: Option, - /// Base64-encoded initialization data. + /// Resolved base64 initialization data. + /// + /// On the wire this is carried indirectly through draft-01's `initDataList` + + /// `initRef`; [`Catalog`] (de)serialization resolves it so callers always see + /// the inline payload here. draft-00's inline `initData` is also accepted. pub init_data: Option, + /// Wire-only pointer into the catalog's `initDataList` (draft-01). Populated + /// only while (de)serializing; resolved into `init_data` on parse and never + /// surfaced to callers. + init_ref: Option, + /// Render group for synchronized playback. pub render_group: Option, @@ -216,6 +141,146 @@ impl Catalog { } } +/// The newest MSF draft string this crate emits. +const CURRENT_VERSION: &str = "draft-01"; + +impl Serialize for Catalog { + fn serialize(&self, serializer: S) -> Result { + use std::collections::HashMap; + + // Hoist inline init payloads into a shared, deduplicated initDataList and + // point each track at its entry via initRef. That's the draft-01 wire + // shape; identical payloads across tracks collapse to one entry. + let mut init_data_list: Vec = Vec::new(); + let mut ids: HashMap = 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>(deserializer: D) -> Result { + let wire = Wire::deserialize(deserializer)?; + let init_data_list = wire.init_data_list.unwrap_or_default(); + + let tracks = wire + .tracks + .into_iter() + .map(|mut track| { + // Resolve draft-01 initRef into inline init_data so callers never + // see the indirection. Inline init_data (draft-00) is kept as-is. + if track.init_data.is_none() { + if let Some(id) = track.init_ref.take() { + if let Some(entry) = init_data_list.iter().find(|e| e.id == id && e.kind == "inline") { + track.init_data = Some(entry.data.clone()); + } + } + } + track.init_ref = None; + track + }) + .collect(); + + Ok(Catalog { tracks }) + } +} + +/// The on-wire catalog shape, carrying the bits [`Catalog`] hides from callers. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Wire { + version: WireVersion, + #[serde(default)] + tracks: Vec, + init_data_list: Option>, +} + +/// Wire encoding of the catalog version. Deserialization accepts draft-00's +/// number `1` or any draft-01 `"draft-XX"` string; serialization always emits +/// [`CURRENT_VERSION`], so callers never deal with the version on the wire. +struct WireVersion; + +impl Serialize for WireVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(CURRENT_VERSION) + } +} + +impl<'de> Deserialize<'de> for WireVersion { + fn deserialize>(deserializer: D) -> Result { + 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(self, v: u64) -> Result { + match v { + 1 => Ok(WireVersion), + other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))), + } + } + + fn visit_i64(self, v: i64) -> Result { + match u64::try_from(v) { + Ok(v) => self.visit_u64(v), + Err(_) => Err(E::custom(format!("unsupported MSF catalog version: {v}"))), + } + } + + fn visit_str(self, _v: &str) -> Result { + // Any draft string is accepted; we always re-emit the current draft. + Ok(WireVersion) + } + } + + deserializer.deserialize_any(VersionVisitor) + } +} + +/// An entry in the wire `initDataList`, referenced by a track's `initRef`. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InitData { + /// Identifier, unique within the catalog, that a track's `initRef` points at. + id: String, + /// Reference type. draft-01 defines only `"inline"` (base64 payload in `data`). + #[serde(rename = "type")] + kind: String, + /// The init payload, interpreted per `kind`. For `"inline"`, base64. + data: String, +} + impl Track { /// Construct a track with the required identity fields set and every /// optional field cleared. Fields are `pub`, so callers set whatever they @@ -237,6 +302,7 @@ impl Track { channel_config: None, bitrate: None, init_data: None, + init_ref: None, render_group: None, alt_group: None, max_grp_sap_starting_type: None, @@ -372,29 +438,79 @@ impl<'de> Deserialize<'de> for Role { 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 { - version: Version::default(), - tracks: vec![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, - render_group: Some(1), - alt_group: None, - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], + tracks: vec![video_track()], }; let json = catalog.to_string().unwrap(); @@ -416,26 +532,7 @@ mod test { #[test] fn serialize_audio_track() { let catalog = Catalog { - version: Version::default(), - tracks: vec![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, - render_group: Some(1), - alt_group: None, - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], + tracks: vec![audio_track()], }; let json = catalog.to_string().unwrap(); @@ -485,10 +582,7 @@ mod test { #[test] fn roundtrip_empty() { - let catalog = Catalog { - version: Version::default(), - tracks: vec![], - }; + let catalog = Catalog { tracks: vec![] }; let json = catalog.to_string().unwrap(); let parsed = Catalog::from_str(&json).unwrap(); assert_eq!(catalog, parsed); @@ -496,61 +590,26 @@ mod test { #[test] fn cmaf_packaging() { - let catalog = Catalog { - version: Version::default(), - tracks: vec![Track { - name: "hd".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: Some("AQID".to_string()), - render_group: Some(1), - alt_group: Some(1), - max_grp_sap_starting_type: None, - max_obj_sap_starting_type: None, - jitter: None, - }], - }; + 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); - } - - 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, - 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)), - } + assert_eq!(parsed.tracks[0].init_data.as_deref(), Some("AQID")); } #[test] fn serialize_sap_fields() { let catalog = Catalog { - version: Version::default(), tracks: vec![track_with_sap_and_jitter()], }; @@ -599,7 +658,6 @@ mod test { #[test] fn sap_and_jitter_roundtrip() { let original = Catalog { - version: Version::default(), tracks: vec![track_with_sap_and_jitter()], }; @@ -612,44 +670,35 @@ mod test { } #[test] - fn default_catalog_emits_draft01_string() { - // New catalogs carry the draft-01 string version on the wire. + fn serialize_emits_draft01_version() { + // Callers never set a version; we always emit the newest draft string. 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 legacy_numeric_version_parses() { - // draft-00 catalogs put the JSON number 1 in `version`. They must still - // decode, and re-serialize back to the same numeric form. - let json = r#"{"version":1,"tracks":[]}"#; - let catalog = Catalog::from_str(json).unwrap(); - assert_eq!(catalog.version, Version::Draft00); + fn draft00_numeric_version_decodes_and_normalizes() { + // draft-00 put the JSON number 1 in `version`. It must decode, and on + // re-serialize we normalize to the current draft string. + 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!(1)); + assert_eq!(value["version"], serde_json::json!("draft-01")); } #[test] - fn draft01_string_version_parses() { - let json = r#"{"version":"draft-01","tracks":[]}"#; - let catalog = Catalog::from_str(json).unwrap(); - assert_eq!(catalog.version, Version::Draft01); - - let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); - assert_eq!(value["version"], serde_json::json!("draft-01")); + 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_roundtrips() { - // A future draft we don't recognize is preserved verbatim rather than rejected. - let json = r#"{"version":"draft-99","tracks":[]}"#; - let catalog = Catalog::from_str(json).unwrap(); - assert_eq!(catalog.version, Version::Unknown("draft-99".to_string())); - - let value: serde_json::Value = serde_json::from_str(&catalog.to_string().unwrap()).unwrap(); - assert_eq!(value["version"], serde_json::json!("draft-99")); + fn unknown_version_string_is_accepted() { + // A future draft we don't specifically recognize still decodes; we don't + // expose the version, so callers are unaffected. + assert!(Catalog::from_str(r#"{"version":"draft-99","tracks":[]}"#).is_ok()); } #[test] @@ -658,6 +707,57 @@ mod test { assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err()); } + #[test] + fn draft01_init_ref_resolves_to_inline() { + // draft-01 carries init data in a root initDataList; tracks reference it by + // id via initRef. Parsing must resolve that into inline init_data. + 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() { + // Two tracks sharing the same init payload must collapse to a single + // initDataList entry, with both tracks referencing it via initRef and no + // inline initData left on the tracks. + 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"); + } + + // And it round-trips back to inline init_data for both tracks. + 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() { // Example 1 from draft-ietf-moq-msf-00: time-aligned audio/video. Exercises the @@ -697,7 +797,6 @@ mod test { }"#; let catalog = Catalog::from_str(json).expect("draft-00 AV catalog must decode"); - assert_eq!(catalog.version, Version::Draft00); 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")); diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 25364512c..85307385f 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Emit MSF catalogs at draft-ietf-moq-msf-01: the `version` field is now the string `"draft-01"` instead of the number `1`. The MSF consumer still accepts both forms. +- Emit MSF catalogs at draft-ietf-moq-msf-01: `version` is the string `"draft-01"` and init data is carried via the root `initDataList` + per-track `initRef`. The MSF consumer still accepts draft-00 (numeric `version`, inline `initData`). ## [0.5.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.5.5...moq-mux-v0.5.6) - 2026-06-17 diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 9bdf5c3bf..c1adec969 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -393,7 +393,6 @@ mod test { let expected_init = base64::engine::general_purpose::STANDARD.decode(init_b64).unwrap(); let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; @@ -413,7 +412,6 @@ mod test { #[test] fn loc_audio_yields_legacy_container() { let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![audio_track("audio0", moq_msf::Packaging::Loc)], }; @@ -442,7 +440,6 @@ mod test { audio.init_data = Some(init_b64); let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![video, audio], }; @@ -460,7 +457,6 @@ mod test { // must stay None so downstream code reads the bytes from one place only. let init_b64 = "AAAYZ2Z0eXA="; let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, Some(init_b64))], }; let catalog = from_msf(&msf).unwrap(); @@ -471,10 +467,7 @@ mod test { fn legacy_malformed_init_data_is_error() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, Some("!!!not-base64!!!")); track.codec = Some("avc1.42c01e".to_string()); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("malformed base64 should error"); assert!( err.to_string().contains("malformed init_data"), @@ -487,10 +480,7 @@ mod test { fn unknown_codec_yields_unknown_variant() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("weirdcodec".to_string()); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unknown codec is not an error"); let video = catalog.video.renditions.get("video0").expect("video0 rendition"); @@ -500,7 +490,6 @@ mod test { #[test] fn cmaf_without_init_data_is_error() { let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![video_track("video0", moq_msf::Packaging::Cmaf, None)], }; @@ -511,10 +500,7 @@ mod test { #[test] fn empty_catalog_is_empty_hang_catalog() { - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![], - }; + let msf = moq_msf::Catalog { tracks: vec![] }; let catalog = from_msf(&msf).expect("empty catalog should convert"); assert!(catalog.video.renditions.is_empty()); @@ -525,10 +511,7 @@ mod test { fn track_without_role_is_skipped() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.role = None; - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("no-role track should be skipped, not error"); assert!(catalog.video.renditions.is_empty()); @@ -539,10 +522,7 @@ mod test { fn unsupported_role_is_skipped() { let mut track = audio_track("caption0", moq_msf::Packaging::Legacy); track.role = Some(moq_msf::Role::Caption); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unsupported role should be skipped, not error"); assert!(catalog.audio.renditions.is_empty()); @@ -557,10 +537,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = None; - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing fields with no init_data should error"); assert!(err.to_string().contains("no init_data"), "unexpected error: {}", err); @@ -584,10 +561,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("Opus OpusHead should parse"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -611,10 +585,7 @@ mod test { track.samplerate = None; track.channel_config = None; track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("AAC AudioSpecificConfig should parse"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -639,10 +610,7 @@ mod test { track.samplerate = Some(24_000); // explicit, must be preserved track.channel_config = None; // missing, derive from init_data track.init_data = Some(init_b64); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("partial derivation should succeed"); let audio = catalog.audio.renditions.get("audio0").expect("audio0 rendition"); @@ -656,7 +624,6 @@ mod test { let bad = video_track("timeline0", moq_msf::Packaging::MediaTimeline, None); let good = video_track("video0", moq_msf::Packaging::Legacy, None); let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![bad, good], }; @@ -678,7 +645,6 @@ mod test { bad.codec = None; let good = audio_track("audio0", moq_msf::Packaging::Loc); let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), tracks: vec![bad, good], }; @@ -690,10 +656,7 @@ mod test { #[test] fn unknown_packaging_variant_is_skipped() { let track = video_track("video0", moq_msf::Packaging::Unknown("custom".to_string()), None); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let catalog = from_msf(&msf).expect("unknown packaging should be skipped, not error"); assert!(catalog.video.renditions.is_empty()); @@ -703,10 +666,7 @@ mod test { fn missing_video_codec_is_error() { let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = None; - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing video codec must error"); let msg = format!("{err:#}"); @@ -720,10 +680,7 @@ mod test { fn missing_audio_codec_is_error() { let mut track = audio_track("audio0", moq_msf::Packaging::Legacy); track.codec = None; - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("missing audio codec must error"); let msg = format!("{err:#}"); @@ -738,10 +695,7 @@ mod test { // avc1 with a too-short profile string is a malformed structured codec. let mut track = video_track("video0", moq_msf::Packaging::Legacy, None); track.codec = Some("avc1.0".to_string()); - let msf = moq_msf::Catalog { - version: moq_msf::Version::default(), - tracks: vec![track], - }; + let msf = moq_msf::Catalog { tracks: vec![track] }; let err = from_msf(&msf).expect_err("malformed avc1 codec must error"); let msg = format!("{err:#}"); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 564803c4d..b356ef6d0 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -262,10 +262,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { tracks.push(track); } - moq_msf::Catalog { - version: moq_msf::Version::CURRENT, - tracks, - } + moq_msf::Catalog { tracks } } #[cfg(test)] @@ -315,7 +312,6 @@ mod test { let msf = to_msf(&catalog); - assert_eq!(msf.version, moq_msf::Version::CURRENT); assert_eq!(msf.tracks.len(), 2); let video = &msf.tracks[0]; @@ -381,7 +377,6 @@ mod test { fn convert_empty() { let catalog = hang::Catalog::default(); let msf = to_msf(&catalog); - assert_eq!(msf.version, moq_msf::Version::CURRENT); assert!(msf.tracks.is_empty()); } From 698e6de2750832dc16b88d4775a2e89c2cacaf38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 02:57:29 +0000 Subject: [PATCH 4/4] refactor(moq-msf): address review - faster initRef resolution, version parity Review follow-ups on the draft-01 rework: - Resolve initRef via a prebuilt id->payload map instead of a linear scan per track, dropping decode from O(tracks * entries) to O(tracks). Same change on the JS side. - Accept `version: 1.0` (a valid JSON spelling of draft-00's version 1) so Rust doesn't reject a catalog the JS decoder parses. Split the numeric visitor arms accordingly. - Pin the lenient handling of a dangling or non-inline initRef (resolves to no init data, never a parse failure) and the numeric-version rules with tests in both languages. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R2ruH25xAPfNhydKJ9Q67n --- js/msf/src/catalog.test.ts | 21 +++++++++++++ js/msf/src/catalog.ts | 7 +++-- rs/moq-msf/src/lib.rs | 63 ++++++++++++++++++++++++++++++++++---- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index d64148d31..3d6516f9c 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -91,6 +91,27 @@ test("resolves draft-01 initRef into inline initData", () => { expect(catalog.tracks[0].initData).toBe("AQID"); }); +test("leaves initData undefined for a dangling or non-inline initRef", () => { + const catalog = decode( + encodeJson({ + 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" }, + ], + }), + ); + + expect(catalog.tracks[0].initData).toBeUndefined(); + expect(catalog.tracks[1].initData).toBeUndefined(); +}); + +test("rejects an unsupported numeric version", () => { + // Mirrors the Rust side: any number other than 1 is rejected. + expect(() => decode(encodeJson({ version: 2, tracks: [] }))).toThrow(); +}); + test("encode hoists and dedups init data, then round-trips", () => { const catalog = { tracks: [ diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 5647a0c7c..2acec492b 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -118,13 +118,16 @@ export function decode(raw: Uint8Array): Catalog { const str = new TextDecoder().decode(raw); try { const wire = WireCatalogSchema.parse(JSON.parse(str)); - const list = wire.initDataList ?? []; + + // id -> inline payload, built once so resolution is linear in the number + // of tracks rather than tracks x entries. + const inline = new Map((wire.initDataList ?? []).filter((e) => e.type === "inline").map((e) => [e.id, e.data])); const tracks = (wire.tracks ?? []).map(({ initRef, ...track }) => { // Resolve draft-01 initRef into inline initData so callers never see // the indirection. Inline initData (draft-00) is left untouched. if (track.initData === undefined && initRef !== undefined) { - track.initData = list.find((e) => e.id === initRef && e.type === "inline")?.data; + track.initData = inline.get(initRef); } return track; }); diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 333ddaa59..473ab1201 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -186,9 +186,19 @@ impl Serialize for Catalog { impl<'de> Deserialize<'de> for Catalog { fn deserialize>(deserializer: D) -> Result { + use std::collections::HashMap; + let wire = Wire::deserialize(deserializer)?; let init_data_list = wire.init_data_list.unwrap_or_default(); + // id -> inline payload, built once so resolution is linear in the number + // of tracks rather than tracks x entries. + 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() @@ -197,9 +207,7 @@ impl<'de> Deserialize<'de> for Catalog { // see the indirection. Inline init_data (draft-00) is kept as-is. if track.init_data.is_none() { if let Some(id) = track.init_ref.take() { - if let Some(entry) = init_data_list.iter().find(|e| e.id == id && e.kind == "inline") { - track.init_data = Some(entry.data.clone()); - } + track.init_data = inline.get(id.as_str()).map(|data| data.to_string()); } } track.init_ref = None; @@ -244,6 +252,9 @@ impl<'de> Deserialize<'de> for WireVersion { f.write_str("the JSON number 1 (draft-00) or a \"draft-XX\" version string") } + // draft-00's only defined numeric version is 1. Accept it from any JSON + // number type (serde_json picks u64/i64/f64 by shape, and `1.0` is a + // valid spelling), and reject everything else. fn visit_u64(self, v: u64) -> Result { match v { 1 => Ok(WireVersion), @@ -252,9 +263,18 @@ impl<'de> Deserialize<'de> for WireVersion { } fn visit_i64(self, v: i64) -> Result { - match u64::try_from(v) { - Ok(v) => self.visit_u64(v), - Err(_) => Err(E::custom(format!("unsupported MSF catalog version: {v}"))), + if v == 1 { + Ok(WireVersion) + } else { + Err(E::custom(format!("unsupported MSF catalog version: {v}"))) + } + } + + fn visit_f64(self, v: f64) -> Result { + if v == 1.0 { + Ok(WireVersion) + } else { + Err(E::custom(format!("unsupported MSF catalog version: {v}"))) } } @@ -707,6 +727,37 @@ mod test { assert!(Catalog::from_str(r#"{"version":2,"tracks":[]}"#).is_err()); } + #[test] + fn float_numeric_version_is_accepted() { + // `1.0` is a valid JSON spelling of the draft-00 version; accept it so we + // don't reject a catalog the JS decoder would happily parse. + 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() { + // A dangling initRef (no matching entry, or a non-inline type) resolves to + // no init data rather than failing the whole catalog. Downstream decides + // whether a track without init data is usable. + 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() { // draft-01 carries init data in a root initDataList; tracks reference it by