Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions doc/concept/standard/msf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
138 changes: 138 additions & 0 deletions js/msf/src/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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");
});
94 changes: 80 additions & 14 deletions js/msf/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof RoleSchema>;

/** 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()),
Expand All @@ -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()),
Expand All @@ -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<typeof TrackSchema>;

/** 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<typeof CatalogSchema>;

/** 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<typeof InitDataSchema>[] = [];
const ids = new Map<string, string>();

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<string, unknown> = { 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;
Expand Down
3 changes: 2 additions & 1 deletion js/msf/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "./src"
"rootDir": "./src",
"types": ["bun"]
},
"include": ["src"]
}
4 changes: 4 additions & 0 deletions rs/moq-msf/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion rs/moq-msf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading