diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index ceefbda89..d1c84724d 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -9,7 +9,12 @@ 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 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 new file mode 100644 index 000000000..3d6516f9c --- /dev/null +++ b/js/msf/src/catalog.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +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. + 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.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.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("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: [ + { 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 1fb9c14cb..2acec492b 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -19,11 +19,14 @@ 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, - 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()), @@ -32,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()), @@ -40,33 +44,95 @@ 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 top-level MSF catalog (version 1). */ +/** Zod schema for the top-level MSF catalog: a version-agnostic snapshot of tracks. */ export const CatalogSchema = z.object({ - version: z.literal(1), tracks: z.array(TrackSchema), }); -/** The MSF catalog: a versioned list of available tracks. */ +/** The MSF catalog: a snapshot of the available tracks. */ export type Catalog = z.infer; -/** Serialize a catalog to its JSON wire representation. */ +/** The newest MSF draft version string this package emits on the wire. */ +export const VERSION = "draft-01"; + +// --- 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(), +}); + +const WireTrackSchema = z.object({ + ...trackShape, + initRef: z.optional(z.string()), +}); + +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)); + + // 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 = inline.get(initRef); + } + return track; + }); + + return { tracks }; } catch (error) { console.warn("invalid MSF catalog", str); throw error; 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/CHANGELOG.md b/rs/moq-msf/CHANGELOG.md index 4c18e88ca..935098c05 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, 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 ### Added diff --git a/rs/moq-msf/README.md b/rs/moq-msf/README.md index f796ae802..dbb7fb71f 100644 --- a/rs/moq-msf/README.md +++ b/rs/moq-msf/README.md @@ -10,9 +10,11 @@ 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). +`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. See the [API documentation](https://docs.rs/moq-msf/) for details. diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index a298a3846..473ab1201 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -1,11 +1,19 @@ //! 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. //! +//! [`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: -//! - +//! - //! - use std::fmt; @@ -18,15 +26,16 @@ 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 version. Always 1 for this draft. - pub version: u32, - - /// Array of track descriptions. + /// The tracks in this catalog snapshot. pub tracks: Vec, } @@ -50,6 +59,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. @@ -76,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, @@ -118,6 +141,166 @@ 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 { + 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() + .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() { + track.init_data = inline.get(id.as_str()).map(|data| data.to_string()); + } + } + 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") + } + + // 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), + other => Err(E::custom(format!("unsupported MSF catalog version: {other}"))), + } + } + + fn visit_i64(self, v: i64) -> Result { + 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}"))) + } + } + + 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 @@ -139,6 +322,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, @@ -274,29 +458,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: 1, - 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(); @@ -318,26 +552,7 @@ mod test { #[test] fn serialize_audio_track() { let catalog = Catalog { - version: 1, - 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(); @@ -387,10 +602,7 @@ mod test { #[test] fn roundtrip_empty() { - let catalog = Catalog { - version: 1, - tracks: vec![], - }; + let catalog = Catalog { tracks: vec![] }; let json = catalog.to_string().unwrap(); let parsed = Catalog::from_str(&json).unwrap(); assert_eq!(catalog, parsed); @@ -398,61 +610,26 @@ mod test { #[test] fn cmaf_packaging() { - let catalog = Catalog { - version: 1, - 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: 1, tracks: vec![track_with_sap_and_jitter()], }; @@ -501,7 +678,6 @@ mod test { #[test] fn sap_and_jitter_roundtrip() { let original = Catalog { - version: 1, tracks: vec![track_with_sap_and_jitter()], }; @@ -512,4 +688,218 @@ 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 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 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!("draft-01")); + } + + #[test] + fn draft01_string_version_decodes() { + let catalog = Catalog::from_str(r#"{"version":"draft-01","tracks":[]}"#).unwrap(); + assert!(catalog.tracks.is_empty()); + } + + #[test] + fn unknown_version_string_is_accepted() { + // 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] + 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()); + } + + #[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 + // 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 + // 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.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()); + } } diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index ee6c52733..85307385f 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: `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 ### Added diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 448fcc86b..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: 1, 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: 1, 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: 1, 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: 1, 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: 1, - 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: 1, - 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: 1, 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: 1, - 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: 1, - 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: 1, - 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: 1, - 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: 1, - 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: 1, - 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: 1, - 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: 1, 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: 1, 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: 1, - 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: 1, - 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: 1, - 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: 1, - 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 06c73c3b0..b356ef6d0 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -262,7 +262,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { tracks.push(track); } - moq_msf::Catalog { version: 1, tracks } + moq_msf::Catalog { tracks } } #[cfg(test)] @@ -312,7 +312,6 @@ mod test { let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); assert_eq!(msf.tracks.len(), 2); let video = &msf.tracks[0]; @@ -378,7 +377,6 @@ mod test { fn convert_empty() { let catalog = hang::Catalog::default(); let msf = to_msf(&catalog); - assert_eq!(msf.version, 1); assert!(msf.tracks.is_empty()); }