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
16 changes: 16 additions & 0 deletions doc/concept/hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ A few things the catalog can express beyond decoder config:
- **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a timeline. Publish independent sources as sibling broadcasts under one prefix, such as `robot/front.hang` and `robot/rear.hang`; each broadcast's audio and video entries are renditions of that source. In one Rust publisher, pass the same `moq_mux::Clock` to each `catalog::Config::with_clock`; JS publishers on one page already share the page clock. Consumers discover the siblings by prefix and align their timestamps through the advertised wall mapping; `@moq/watch` plays one broadcast at a time.
- **Extensions.** The root is a loose object. Applications add their own sections (`scte35`, for example) next to the ones hang defines, optionally naming a track that carries the data. Every library exposes a way to write your section without clobbering the built-in ones, and readers ignore what they don't know.

## Media reports

A catalog may name a publisher snapshot track with `stats: { track }` and
solicit viewer feedback with `echo: { path }`. Applications can use hang's
shared Rust and JS snapshot schemas to exchange per-rendition delivery and
playout measurements, alongside transport measurements. The echo path is
relative to the catalog broadcast; an application assigns one `.echo` name
per viewer and soliciting catalog, and controls the token rights under that
prefix. Echo broadcasts carry `feedback.json` without a media catalog, so
readers can filter them from playable content.

These are schema and discovery primitives. The publishers and players do not
automatically produce reports. MSF catalogs omit both sections. See
[media stats](/concept/stats#media-reports) for the roles and the
[hang draft](/draft/moq-hang#media-stats) for their contract.

## Text

Captions and subtitles are their own tracks in a `text` section, not part of
Expand Down
24 changes: 24 additions & 0 deletions doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ read them in any language. [`moq-stats`](https://docs.rs/moq-stats) is the Rust
producer and consumer, and the relay's [`[stats]`](/bin/relay/config#stats)
section turns it on.

## Media reports

Media reports use hang's catalog discovery, separately from a relay's
traffic broadcasts below. `stats: { track }` names a publisher's snapshot;
`echo: { path }` solicits a viewer's `feedback.json` snapshot on an
application-named `.echo` broadcast. Both snapshots map the catalog's
rendition IDs to their counters and carry optional transport measurements.
The publisher reports only renditions it writes; a viewer reports a
referenced rendition under its ID in the catalog that listed it.

The shared schemas are `hang::{stats, echo}::Snapshot` and
`Hang.Stats.SnapshotSchema` / `Hang.Echo.SnapshotSchema` in `@moq/hang`.
Container-specific sections can sit beside `transport` and `renditions`.
Counters are cumulative; readers derive rates over an interval and treat a
replacement or decrease as a reset. Bitrate, latency, RTT, estimated rate,
and sample age are gauges. Loss is cumulative lost bytes and packets,
with unavailable transport measurements absent. Missing rendition counters
mean zero, and JavaScript preserves omission. A known-zero gauge or transport
measurement stays explicit, so it remains distinct from unavailable.

These types do not enable automatic reporting in a publisher or player.
See the [hang draft](/draft/moq-hang#media-stats) for track framing,
field units, and the feedback convention.

## Broadcasts

Each node publishes under a prefix, `.stats` by default, which
Expand Down
79 changes: 78 additions & 1 deletion drafts/draft-lcurley-moq-hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ type Catalog = {
"video": VideoSchema | undefined,
"archive": ArchiveSchema | undefined,
"clock": ClockSchema | undefined,
"stats": { "track": string } | undefined,
"echo": { "path": string } | undefined,
"text": TextSchema | undefined,
"json": JsonTracks | undefined,
"binary": BinaryTracks | undefined,
Expand All @@ -142,7 +144,7 @@ For example, a chat entry should name a chat track, not carry individual chat me
This way catalog updates are rare and a client MAY choose to not subscribe.
The `json` and `binary` sections ({{data}}) are how a track like that is listed.

This specification defines audio, video, and text media tracks, plus an optional `archive` entry ({{archive-catalog}}) naming the timeline track ({{timeline}}) that indexes each track, an optional root `clock` entry ({{clock}}) mapping content time to wall time, and the application data tracks in {{data}}.
This specification defines audio, video, and text media tracks, plus an optional `archive` entry ({{archive-catalog}}) naming the timeline track ({{timeline}}) that indexes each track, an optional root `clock` entry ({{clock}}) mapping content time to wall time, the application data tracks in {{data}}, and optional publisher statistics and solicited viewer feedback ({{media-stats}}).

## Clock {#clock}
The catalog's root `clock` field is the broadcast's one continuous clock:
Expand Down Expand Up @@ -602,6 +604,80 @@ If absent, `enabled` defaults to true; a publisher SHOULD only write it when fal
Earlier versions defined a video `stalled` field instead.
A publisher MUST NOT write `stalled`, and a consumer MUST ignore it.

# Media Statistics and Viewer Feedback {#media-stats}

The optional catalog `stats` section names the publisher's snapshot track in the catalog's own broadcast: `{ "track": "stats.json" }`.
A publisher snapshot covers only renditions that publisher writes, omitting references to another broadcast.

The optional catalog `echo` section solicits feedback: `{ "path": "viewers" }`.
The path is relative to the broadcast serving the catalog, with the same URL-style resolution and root containment as {{field-broadcast}}.
For example, from `room/live`, `viewers` resolves to `room/viewers`, and `live/viewers` resolves to `room/live/viewers`.
A consumer MUST reject a catalog whose echo path resolves above its connection root.
After reading the soliciting catalog, a viewer MAY announce one broadcast under that prefix at an application-assigned name plus `.echo`, for example `room/viewers/alice.echo`.
Each such broadcast belongs to one soliciting catalog and carries the fixed `feedback.json` track; it has no catalog of its own.
A reader recognizes feedback by the `.echo` suffix on the broadcast's final path segment, so it does not list feedback as playable media.
The application controls names and publish/subscribe authorization; a report is not authenticated beyond those rights.
Without `echo`, no feedback is solicited.

An application may already carry its own `stats` or `echo` root key, so a reader MUST treat a `stats` value without a string `track`, or an `echo` value without a string `path`, as absent rather than refuse the catalog.

Both tracks carry JSON snapshots, optionally with the `.z` sibling encoding of JSON snapshots.
The uncompressed track carries a complete snapshot in a group's single UTF-8 JSON frame.
The compressed sibling starts each group with a full snapshot and continues with RFC 7396 merge patches in the DEFLATE stream defined in {{compression}}.
A producer SHOULD update about once per second while observed; a report need not leave the process when unsubscribed.
A reader takes the newest complete snapshot and treats the reporting broadcast's replacement or decreasing cumulative counters as a reset.

~~~
type PublisherSnapshot = {
transport?: Transport,
renditions?: { [renditionId: string]: PublisherRendition },
// ... any container or application sections ...
}
type ViewerSnapshot = {
transport?: Transport,
renditions?: { [renditionId: string]: ViewerRendition },
// ... any container or application sections ...
}
type PublisherRendition = {
frames?: number, bytes?: number, keyframes?: number, skipped?: number,
bitrate?: number,
}
type ViewerRendition = {
frames?: number, bytes?: number, decoded?: number, late?: number,
errors?: number, stalls?: number, stalled?: number, underruns?: number,
arrival?: { timestamp: number, wall: number }, latency?: number,
}
type Transport = {
rtt?: number, rate?: number, lostBytes?: number, lostPackets?: number, age?: number,
}
~~~

Every number is a nonnegative integer no larger than 2^53 - 1; malformed or out-of-range known fields MUST be refused.
Unknown fields MUST be ignored; application and container sections MAY be flattened beside `transport` and `renditions`.
Absent rendition counters mean zero, and zero counters MAY be omitted; JavaScript preserves omission instead of synthesizing fields.
Unavailable gauges and transport measurements MUST remain absent, and a known zero MUST remain explicit.
A reader treats JSON `null` in an optional `stats` or `echo` catalog section, gauge, transport measurement, or `arrival` as absent; publishers omit unavailable fields.
An `arrival` MUST carry both `timestamp` and `wall`; a reader MUST refuse one missing either.
Rendition IDs are the unique keys from the soliciting catalog, independent of media kind and wire track name.
A viewer reports referenced renditions under the IDs in the catalog it read, even when their media comes from another broadcast.
An empty rendition entry is valid and represents zero cumulative counters; its ID MUST NOT be removed merely because its counters are zero.

`frames` and `bytes` count cumulative media payload frames and bytes sent or received, according to the report's role.
Publisher `keyframes` counts sent keyframes; `skipped` counts frames skipped before sending.
Viewer `decoded`, `late`, and `errors` count decoded frames, frames too late for playout, and decode errors.
`stalls` counts playout stalls, `stalled` is cumulative stalled duration, and `underruns` counts audio buffer underruns.
`bitrate` is the latest target encoder bitrate, `latency` the latest playout latency, and `arrival` pairs the newest received media timestamp with its arrival wall time.
Durations and media timestamps are milliseconds; `arrival.wall` is milliseconds since the MoQ epoch (2020-01-01T00:00:00Z).
Sub-millisecond durations and timestamps are floored when encoded.
Rates are bits per second.

`transport` describes the reporting connection: latest round-trip time `rtt`, estimated bitrate `rate`, cumulative loss counters `lostBytes` and `lostPackets`, and sample `age` at reporting time.
All transport measurements are optional; a browser may know only its PROBE round-trip time.
Loss is a pair of cumulative counters, never a gauge or fraction: a reader derives loss over the same interval as rendition rates and handles resets.
Gauges MUST NOT be summed across reports.

The MSF catalog conversion carries neither `stats` nor `echo`; their transport through MSF `publishTracks` is not defined here.

# Container {#container}
Audio, video, and text tracks use a container to encapsulate the media payload.
A rendition declares its container via the `container` field of its catalog entry ({{common}}):
Expand Down Expand Up @@ -1136,6 +1212,7 @@ This document has no IANA actions.
{:numbered="false"}

- Derived HLS and DASH exports hand disabled references to enabled renditions at the first record numbered past the advertised ones, keeping the record `sequence` as the HLS media sequence and starting a DASH period.
- Added optional `stats` and `echo` catalog sections, publisher/viewer snapshots keyed by rendition ID, and the `.echo` feedback broadcast convention.

- Fixed a track's identity for its name ({{identity}}): codec, description, container, display aspect, sample rate, and channel count never change, while every other field, including size, bitrate, and framerate, updates in place. A change of identity takes a new track name, which a rendition may name with `track` while keeping its ID, or a new Epoch.
- Specified MSF text metadata preservation and refusal of unrepresentable text packaging during gateway conversion.
Expand Down
26 changes: 26 additions & 0 deletions js/hang/src/catalog/consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,29 @@ test("watch subscribes and yields typed catalog updates", async () => {
producer.finish();
broadcast.close();
});

test("echo feedback prefix refuses root escape with the same URL resolution as renditions", () => {
for (const [base, reference, expected] of [
["room/live", "viewers", "room/viewers"],
["room/live", "live/viewers", "room/live/viewers"],
["room/live", "../viewers", "viewers"],
["room/live", "../../viewers", undefined],
["live", "../viewers", undefined],
] as const) {
const root = { echo: { path: Moq.Path.normalizeRelative(reference) } };
const path = Moq.Path.from(base);
if (expected === undefined) expect(() => checkResolvable(root, path)).toThrow(EscapingBroadcast);
else {
expect(checkResolvable(root, path)).toEqual(root);
expect(Moq.Path.tryResolve(path, root.echo.path)).toBe(Moq.Path.from(expected));
}
}
});

test("watch refuses an echo prefix escaping the requested broadcast root", async () => {
await expect(
watchRequested({ echo: { path: Moq.Path.normalizeRelative("../../viewers") } }),
).rejects.toBeInstanceOf(EscapingBroadcast);
const root = { echo: { path: Moq.Path.normalizeRelative("viewers") } };
expect(await watchRequested(root)).toMatchObject({ value: root });
});
11 changes: 11 additions & 0 deletions js/hang/src/catalog/echo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as z from "@zod/mini";
import { RelativeBroadcastSchema } from "./path";

/** The relative prefix under which viewers may publish feedback. */
export const EchoSchema = z.object({
// The prefix relative to the broadcast serving this catalog.
path: RelativeBroadcastSchema,
});

/** The relative prefix soliciting viewer feedback. */
export type Echo = z.infer<typeof EchoSchema>;
4 changes: 3 additions & 1 deletion js/hang/src/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* JSON catalog schema and types describing a broadcast's tracks: audio/video decoder configs for
* WebCodecs, container format, and rendition layout, the `archive` entry and root `clock`,
* plus the `json` and `binary` sections listing application data tracks that aren't media.
* plus the `json` and `binary` data sections and optional `stats` and `echo` discovery.
*
* @module
*/
Expand All @@ -25,6 +25,7 @@ export * from "./binary";
export * from "./clock";
export * from "./compression";
export * from "./container";
export * from "./echo";
export * from "./format";
export * from "./hex";
export * from "./integers";
Expand All @@ -33,6 +34,7 @@ export * from "./mode";
export * from "./path";
export * from "./priority";
export * from "./root";
export * from "./stats";
export * from "./text";
export * from "./track";
export type { VideoConfig } from "./video";
Expand Down
21 changes: 21 additions & 0 deletions js/hang/src/catalog/root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,24 @@ test("an ID used by both video and audio is refused", () => {
const parsed = RootSchema.parse({ video: { renditions: { main: avc } }, audio: { renditions: { main: opus } } });
expect(() => checkRenditions(parsed)).toThrow(DuplicateRendition);
});

test("shared stats and echo discovery round trips without changing old catalogs", async () => {
const fixture = await Bun.file(
new URL("../../../../rs/hang/fixtures/catalog-stats-echo.json", import.meta.url),
).json();
expect(RootSchema.parse(fixture)).toEqual(fixture);
expect(RootSchema.parse({})).toEqual({});
});

test("a foreign stats or echo value decodes as absent instead of failing the catalog", () => {
for (const foreign of [{}, 5, "on", { track: 5 }, { path: ["a"] }, null]) {
for (const key of ["stats", "echo"]) {
const parsed = RootSchema.parse({ video: { renditions: {} }, [key]: foreign });
expect(parsed.stats).toBeUndefined();
expect(parsed.echo).toBeUndefined();
}
}
const parsed = RootSchema.parse({ stats: { track: "stats.json" }, echo: { path: "viewers" } });
expect(parsed.stats).toEqual({ track: "stats.json" });
expect(parsed.echo?.path).toBe("viewers" as RelativeBroadcast);
});
12 changes: 10 additions & 2 deletions js/hang/src/catalog/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { ArchiveSchema } from "./archive";
import { AudioSchema } from "./audio";
import { BinarySchema } from "./binary";
import { ClockSchema } from "./clock";
import { EchoSchema } from "./echo";
import { TRACK } from "./format";
import { JsonSchema } from "./json";
import type { RelativeBroadcast } from "./path";
import { PRIORITY } from "./priority";
import { section } from "./section";
import { named, section } from "./section";
import { StatsSchema } from "./stats";
import { TextSchema } from "./text";
import { VideoSchema } from "./video";

Expand All @@ -20,7 +22,8 @@ import { VideoSchema } from "./video";
* The media sections are `video`, `audio`, and `text`, alongside the broadcast's `archive`
* (the segment index and any durable recording) and its `clock` (the one wall-clock mapping);
* `json` and `binary` list application data tracks that aren't media. A section is omitted
* when it holds no tracks.
* when it holds no tracks. `stats` names the publisher's media snapshot track, and `echo`
* solicits viewer feedback under a relative broadcast prefix.
*
* This is a *loose* object: unknown root sections pass through validation untouched, so an
* application can add its own sections (e.g. `scte35`) without modifying hang. A base consumer
Expand All @@ -35,6 +38,10 @@ export const RootSchema = z.looseObject({
// The broadcast's one continuous clock, if the publisher exposes one. Independent of
// `archive`: a live-only publisher exposes its mapping without creating a segment index.
clock: z.optional(ClockSchema),
// Decoded leniently like `text` below: a value without a string `track` or `path` is someone
// else's and decodes as absent. See `named`.
stats: named(StatsSchema, "track"),
echo: named(EchoSchema, "path"),
// `text`, `json`, and `binary` are generic enough keys that an application could have been
// carrying its own before these sections were reserved, so a value that isn't a section decodes
// as absent rather than failing the whole catalog. A value that IS a section but carries a
Expand Down Expand Up @@ -114,6 +121,7 @@ export function checkResolvable(root: Root, base: Moq.Path.Valid): Root {
if (broadcast && Path.tryResolve(base, broadcast) === undefined) throw new EscapingBroadcast(broadcast);
}
}
if (root.echo && Path.tryResolve(base, root.echo.path) === undefined) throw new EscapingBroadcast(root.echo.path);
return root;
}

Expand Down
24 changes: 24 additions & 0 deletions js/hang/src/catalog/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,27 @@ export function section<T extends z.ZodMiniType>(schema: T, map: string) {
]),
);
}

/**
* Like {@link section}, for a section identified by one string field (`stats.track`, `echo.path`)
* rather than a map. Mirrors the Rust `deserialize_named`.
*
* @param schema - the section's own schema, applied when the field is a string.
* @param key - the section's required string field.
*/
export function named<T extends z.ZodMiniType>(schema: T, key: string) {
return z.optional(
z.union([
schema,
z.pipe(
z.custom<unknown>(
(value) =>
typeof value !== "object" ||
value === null ||
typeof (value as Record<string, unknown>)[key] !== "string",
),
z.transform(() => undefined),
),
]),
);
}
10 changes: 10 additions & 0 deletions js/hang/src/catalog/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as z from "@zod/mini";

/** The publisher's optional media snapshot track in this broadcast. */
export const StatsSchema = z.object({
// The publisher's snapshot track name.
track: z.string(),
});

/** The publisher's media snapshot track in this broadcast. */
export type Stats = z.infer<typeof StatsSchema>;
Loading
Loading