From c0b05d87a7f512432d4dabb8087fc3602af964c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 16:06:08 -0700 Subject: [PATCH 01/13] chore: claim JS catalog identity quest Co-Authored-By: GPT-6 From 5f847cfead8703e2e141aec513ec8a5dda29ceb7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 16:44:26 -0700 Subject: [PATCH 02/13] fix(publish): restart epochs when served media identity changes Co-Authored-By: GPT-6 --- doc/lib/js/publish.md | 3 +- js/publish/bench/catalog.ts | 17 +- js/publish/src/audio/capture.test.ts | 11 +- js/publish/src/audio/encoder.test.ts | 104 ++++++++++--- js/publish/src/audio/encoder.ts | 136 +++++++++++----- js/publish/src/broadcast.test.ts | 25 +++ js/publish/src/broadcast.ts | 22 ++- js/publish/src/catalog.test.ts | 145 +++++++++++++++++- js/publish/src/catalog.ts | 116 ++++++++++++-- js/publish/src/internal.ts | 6 + js/publish/src/video/capture.test.ts | 6 +- js/publish/src/video/encoder.test.ts | 129 ++++++++++++++-- js/publish/src/video/encoder.ts | 45 +++--- .../m0/broadcast-epoch/catalog-references.md | 1 - quest/m1/README.md | 1 - quest/m1/catalog-identity-js.md | 51 ------ quest/m1/catalog-identity-rs.md | 6 +- 17 files changed, 651 insertions(+), 173 deletions(-) delete mode 100644 quest/m1/catalog-identity-js.md diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 7057e1a0a39..57a63eaa8ca 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -67,7 +67,8 @@ timeline. The catalog's `clock` maps it to wall time. Stamp your own tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application can serve its own tracks alongside the media. It is recreated when the origin, name, -`enabled`, or `maxAge` changes, so acquire it from an effect and create each +`enabled`, or `maxAge` changes, or a served media identity changes (codec, audio +format, or growth past the video coded-size ceiling). Acquire it from an effect and create each track once per `net`. A track name always means the same content, so a track re-created on the same `net` throws on an append that would reuse its sequences: diff --git a/js/publish/bench/catalog.ts b/js/publish/bench/catalog.ts index 8dffeccabd0..32c62c0b714 100644 --- a/js/publish/bench/catalog.ts +++ b/js/publish/bench/catalog.ts @@ -82,7 +82,18 @@ async function measure(tracks: number, viewers: number) { if (publishes !== 2 || updates !== 2 * tracks * viewers) { throw new Error(`estimate churn regression: ${publishes} publishes, ${updates} subscribe updates`); } - console.log(`${tracks},${viewers},${publishes},${updates},${1000 * tracks * viewers},${elapsed.toFixed(1)}`); + const identityStart = realNow(); + catalog.mutate((root) => { + if (!root.video) throw new Error("video missing"); + for (const config of Object.values(root.video.renditions)) config.codec = "vp8"; + }); + for (const viewer of subscribers) { + if ((await viewer.catalog.latest()) !== undefined) throw new Error("old catalog received a new identity"); + } + const identity = realNow() - identityStart; + console.log( + `${tracks},${viewers},${publishes},${updates},${1000 * tracks * viewers},${elapsed.toFixed(1)},${identity.toFixed(3)}`, + ); scope.close(); output.close(); for (const viewer of subscribers) { @@ -93,7 +104,9 @@ async function measure(tracks: number, viewers: number) { } try { - console.log("tracks,viewers,catalog_publishes,subscribe_updates,unlimited_subscribe_updates,elapsed_ms"); + console.log( + "tracks,viewers,catalog_publishes,subscribe_updates,unlimited_subscribe_updates,elapsed_ms,identity_ms", + ); for (const tracks of [1, 4, 16]) for (const viewers of [1, 4, 16]) await measure(tracks, viewers); } finally { performance.now = realNow; diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index f711b4caf2e..43474122db2 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -504,6 +504,10 @@ function installFailableAudioEncoder() { this.state = "configured"; } encode(): void {} + async flush(): Promise { + // The throwaway description probe is not a live capture encoder. + instances.splice(instances.indexOf(this), 1); + } close(): void { this.state = "closed"; } @@ -559,7 +563,11 @@ test("a failed encoder stays failed until the capture source changes", async () close: () => {}, }; const encoder = new Encoder("audio", { - broadcast: { audio: () => rendition, baseline: new Baseline() } as never, + broadcast: { + audio: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture, enabled, }); @@ -572,6 +580,7 @@ test("a failed encoder stays failed until the capture source changes", async () try { await render(); + if (!encoder.out.catalog.peek()) await encoder.out.catalog.changed(); expect(codecs.instances).toHaveLength(1); const failure = new Error("audio encoder failed"); codecs.instances[0].fail(failure); diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 70ddf1c7dcb..c6cc67bb16f 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, mock, spyOn, test } from "bun:test"; -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { Time } from "@moq/net"; @@ -70,6 +70,8 @@ describe("toEncoderConfig", () => { // input timestamps never reaches the output. class LaggingAudioEncoder { static instances: LaggingAudioEncoder[] = []; + static probes: AudioEncoderConfig[] = []; + static description: ((config: AudioEncoderConfig) => Uint8Array) | undefined; static readonly LAG = 2; // Called on configure; the encoder publishes its pipeline synchronously right after. @@ -80,6 +82,8 @@ class LaggingAudioEncoder { #held: { timestamp: number; duration: number }[] = []; #base: number | undefined; #encoded = 0; + #probe = false; + #config?: AudioEncoderConfig; #error: AudioEncoderInit["error"]; constructor(init: AudioEncoderInit) { @@ -95,7 +99,10 @@ class LaggingAudioEncoder { configure(config: AudioEncoderConfig): void { this.state = "configured"; - LaggingAudioEncoder.onConfigure?.(config); + this.#config = config; + queueMicrotask(() => { + if (!this.#probe) LaggingAudioEncoder.onConfigure?.(config); + }); } encode(data: AudioData): void { @@ -104,18 +111,36 @@ class LaggingAudioEncoder { this.#held.push({ timestamp: this.#base + this.#encoded, duration }); this.#encoded += duration; while (this.#held.length > LaggingAudioEncoder.LAG) { - const { timestamp, duration } = this.#held.shift() as { timestamp: number; duration: number }; - const chunk = { - type: "key", - timestamp, - duration, - byteLength: 1, - copyTo: (dest: Uint8Array) => dest.set([1]), - }; - this.#output(chunk as unknown as EncodedAudioChunk); + this.#emit(this.#held.shift() as { timestamp: number; duration: number }); } } + #emit({ timestamp, duration }: { timestamp: number; duration: number }): void { + const chunk = { type: "key", timestamp, duration, byteLength: 1, copyTo: (dest: Uint8Array) => dest.set([1]) }; + const config = this.#config; + const description = config && LaggingAudioEncoder.description?.(config); + const metadata = + config && description + ? { + decoderConfig: { + codec: config.codec, + sampleRate: config.sampleRate, + numberOfChannels: config.numberOfChannels, + description, + }, + } + : undefined; + this.#output(chunk as unknown as EncodedAudioChunk, metadata); + } + + async flush(): Promise { + this.#probe = true; + if (this.#config) LaggingAudioEncoder.probes.push(structuredClone(this.#config)); + const index = LaggingAudioEncoder.instances.indexOf(this); + if (index !== -1) LaggingAudioEncoder.instances.splice(index, 1); + for (const chunk of this.#held.splice(0)) this.#emit(chunk); + } + reset(): void { this.state = "unconfigured"; this.#held = []; @@ -145,6 +170,8 @@ class FakeAudioData { function installFakeWebCodecs() { LaggingAudioEncoder.instances = []; + LaggingAudioEncoder.probes = []; + LaggingAudioEncoder.description = undefined; const names = ["AudioEncoder", "AudioDecoder", "AudioData"] as const; const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); const fakes = [LaggingAudioEncoder, class {}, FakeAudioData]; @@ -264,7 +291,7 @@ async function setup(baseline = new Baseline(), codec?: Codec, groupDuration?: T const enabled = new Signal(true); const encoder = new Encoder("audio", { - broadcast: { audio: () => rendition, baseline } as never, + broadcast: { audio: () => rendition, baseline, net: new Signal(undefined) } as never, capture: capture as never, enabled, codec, @@ -573,6 +600,7 @@ test("a rendition trailing the broadcast's earliest advertises delay", async () // Regression: codec settings that can't resolve left the encoder unsettled, so `` never // announced and withheld every other rendition with it. test("settles when the codec settings can't resolve", async () => { + using _webcodecs = installFakeWebCodecs(); const error = spyOn(console, "error").mockImplementation(() => {}); const capture = { in: { source: new Signal(undefined) }, @@ -595,7 +623,9 @@ test("settles when the codec settings can't resolve", async () => { expect(error).toHaveBeenCalled(); // A valid duration resolves, and the config keeps it settled. + const ready = encoder.out.catalog.changed(); encoder.codec.set({ mime: "opus", frameDuration: Time.Milli(20) }); + await ready; await settle(); expect(encoder.out.catalog.peek()).toBeDefined(); expect(encoder.settled.peek()).toBe(true); @@ -607,6 +637,7 @@ test("settles when the codec settings can't resolve", async () => { // A fade the gain can't ramp over is refused like any other bad setting, rather than ramping wrong. test("refuses the rendition while the fade is invalid", async () => { + using _webcodecs = installFakeWebCodecs(); const error = spyOn(console, "error").mockImplementation(() => {}); const capture = { in: { source: new Signal(undefined) }, @@ -625,8 +656,9 @@ test("refuses the rendition while the fade is invalid", async () => { expect(encoder.settled.peek()).toBe(true); expect(error).toHaveBeenCalled(); + const ready = encoder.out.catalog.changed(); encoder.fade.set(Time.Milli(0)); - await settle(); + await ready; expect(encoder.out.catalog.peek()).toBeDefined(); encoder.fade.set(Time.Milli(Number.NaN)); @@ -764,19 +796,28 @@ test("audio failure stays local through demand changes and capture format re-arm await reconfiguring; await settle(); expect(encoder.out.error.peek()).toBeUndefined(); - const config = (await catalog.latest())?.value.audio?.renditions.audio; - if (!config?.track) throw new Error("expected recovered track name"); - expect(config.track).not.toBe("audio"); + const next = broadcast.net.peek(); + if (!next || next === net) throw new Error("expected a new audio identity epoch"); + expect(net.closed.peek()).toBeNull(); + expect(next.route?.epoch).not.toBe(epoch); + for (let old = await catalog.latest(); old; old = await catalog.latest()) { + expect(old.value.audio?.renditions.audio?.numberOfChannels).toBe(Catalog.u53(1)); + } + const nextCatalog = new Json.Snapshot.Consumer({ + track: next.track(Broadcast.CATALOG_TRACK).subscribe(), + }); + const config = (await nextCatalog.latest())?.value.audio?.renditions.audio; + if (!config) throw new Error("expected the recovered rendition"); + expect(config.numberOfChannels).toBe(Catalog.u53(2)); expect(config.enabled).not.toBe(false); - recovered = net.track(config.track).subscribe(); + recovered = next.track(config.track ?? "audio").subscribe(); await settle(); await push(3, 2); expect(recovered.latest()).toBe(0); expect(encoder.out.active.peek()).toBe(true); expect(recovered.closed.peek()).toBeUndefined(); - expect(broadcast.net.peek()).toBe(net); - expect(net.route?.epoch).toBe(epoch); - expect(siblingTrack.appendGroup().sequence).toBe(2); + expect(broadcast.net.peek()).toBe(next); + expect(siblingTrack.closed.peek()).toBeNull(); } finally { subscriber?.close(); siblingSubscriber?.close(); @@ -865,3 +906,26 @@ test.each(["failure first", "config first"])( } }, ); + +test("Opus description is probed with the exact live config before advertising", async () => { + using _codecs = installFakeWebCodecs(); + LaggingAudioEncoder.description = (config) => new TextEncoder().encode(JSON.stringify(config)); + using env = await setup(new Baseline(), { + mime: "opus", + frameDuration: Time.Milli(2.5), + complexity: 3, + packetlossperc: 7, + useinbandfec: true, + bitrate: 64000, + }); + expect(env.encoder.out.catalog.peek()?.description).toBe(Buffer.from(JSON.stringify(env.config)).toString("hex")); + expect(LaggingAudioEncoder.probes).toEqual([env.config]); + expect(LaggingAudioEncoder.instances).toHaveLength(1); + expect(env.written).toHaveLength(0); + const configuring = configured(); + env.capture.in.source.set({ track: {}, kind: "voice" } as never); + const voice = await configuring; + expect(voice.opus).toMatchObject({ application: "voip", signal: "voice" }); + expect(LaggingAudioEncoder.probes.at(-1)).toEqual(voice); + expect(env.encoder.out.catalog.peek()?.description).toBe(Buffer.from(JSON.stringify(voice)).toString("hex")); +}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 7ba0515edd2..b88f03d30c5 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -15,7 +15,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { errors } from "../internal"; +import { type CatalogState, catalogs, errors } from "../internal"; import { type Baseline, Estimator } from "../jitter"; import type { AudioFrame, Capture, Format } from "./capture"; import { Gain } from "./gain"; @@ -169,10 +169,8 @@ export class Encoder { return this.in.capture.peek(); } - // The encode settings known before encoding starts. Opus adds its decoder description from the - // first encoder output without feeding that catalog-only update back into the encoder. - #config = new Signal(undefined); - #decoderDescription = new Signal<{ config: Catalog.AudioConfig; description: Catalog.Hex } | undefined>(undefined); + // The exact encoder settings and probed decoder description advertised before demand arrives. + #config = new Signal<(Resolved & { encoder: AudioEncoderConfig }) | undefined>(undefined); readonly #out: EncoderOutput = { error: new Signal(undefined), @@ -227,9 +225,8 @@ export class Encoder { #estimator = new Estimator(); // The estimator's jitter and delay, republished whenever either rises. #estimate = new Signal({}); - // What this rendition advertised on each broadcast, keyed by the broadcast or, with none, the - // encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one - // continues its bitrate maximum and last config, while another broadcast starts its own. + // The network epoch owns its bitrate maximum and last config; local previews use the encoder. + #catalog = new Signal(undefined); #advertised = new WeakMap(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens @@ -279,8 +276,8 @@ export class Encoder { try { run.call(this, effect); } catch (err) { - this.#failures.update((n) => n + 1); - effect.cleanup(() => this.#failures.update((n) => n - 1)); + if (run === this.#runConfig) this.#config.set(undefined); + this.#fail(effect); throw err; } }); @@ -380,6 +377,7 @@ export class Encoder { const rendition = broadcast.audio(this.name); errors.set(rendition, failure); + catalogs.set(rendition, this.#catalog); effect.cleanup(() => rendition.close()); // Publish the resolved config; undefined (no capture) drops it from the catalog. @@ -394,7 +392,8 @@ export class Encoder { const capture = effect.get(this.in.capture); const format = capture ? effect.get(capture.out.format) : undefined; const fatal = effect.get(this.#out.error); - if (!enabled || !format || fatal) return; + const resolved = effect.get(this.#config); + if (!enabled || !format || fatal || !resolved) return; this.#encode(broadcast.baseline, format, effect); }); @@ -469,21 +468,91 @@ export class Encoder { const capture = effect.get(this.in.capture); const captured = capture ? effect.get(capture.out.format) : undefined; if (!effect.get(this.in.enabled) || !captured) { - effect.set(this.#config, undefined); + this.#config.set(undefined); return; } - effect.set(this.#config, resolve(captured, effect.get(this.codec))); + const resolved = resolve(captured, effect.get(this.codec)); + const source = capture ? effect.get(capture.in.source) : undefined; + const kind = source ? sourceKind(source) : "auto"; + const encoderConfig = toEncoderConfig(resolved, kind, this.#opusOptions(effect)); + if (resolved.catalog.codec !== "opus") { + this.#config.set({ ...resolved, encoder: encoderConfig }); + return; + } + + const superseded = effect.abort; + effect.spawn(async () => { + try { + await Util.Libav.polyfill(); + if (superseded.aborted) return; + let description: Catalog.Hex | undefined; + let failure: Error | undefined; + const encoder = new AudioEncoder({ + output: (_frame, metadata) => { + const source = metadata?.decoderConfig?.description; + if (!source) return; + const bytes = ArrayBuffer.isView(source) + ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) + : new Uint8Array(source); + description = Util.Hex.fromBytes(bytes); + }, + error: (error) => { + failure = error; + }, + }); + effect.cleanup(() => { + if (encoder.state !== "closed") encoder.close(); + }); + encoder.configure(encoderConfig); + const samples = Math.round( + (encoderConfig.sampleRate * (resolved.frameDuration ?? Time.Micro(20_000))) / 1_000_000, + ); + const frame = new AudioData({ + format: "f32-planar", + sampleRate: encoderConfig.sampleRate, + numberOfChannels: encoderConfig.numberOfChannels, + numberOfFrames: samples, + timestamp: 0, + data: new Float32Array(samples * encoderConfig.numberOfChannels), + }); + try { + encoder.encode(frame); + } finally { + frame.close(); + } + await effect.race(encoder.flush()); + if (superseded.aborted) return; + if (failure) throw failure; + encoder.close(); + this.#config.set({ + ...resolved, + catalog: { ...resolved.catalog, ...(description === undefined ? {} : { description }) }, + encoder: encoderConfig, + }); + } catch (error) { + if (superseded.aborted) return; + this.#config.set(undefined); + this.#fail(effect); + throw error; + } + }); + } + + #fail(effect: Effect): void { + this.#failures.update((n) => n + 1); + effect.cleanup(() => this.#failures.update((n) => n - 1)); } - // Publish the config immediately so a consumer can request the demand-gated track. Once encoding - // starts, republish Opus with the exact decoder description reported for that encoder config. A + // Publish the probed config before a consumer can request the demand-gated track. A // disabled rendition keeps its last config, since muting also releases the capture, and stays // disabled after re-enabling until the reopened capture reports its format. #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const key = effect.get(this.in.broadcast) ?? this; + const broadcast = effect.get(this.in.broadcast); + const epoch = broadcast ? effect.get(broadcast.net) : undefined; + const key = epoch ?? this; let track = this.#advertised.get(key); if (!track) { track = { bitrate: 0 }; @@ -493,19 +562,21 @@ export class Encoder { const config = enabled ? effect.get(this.#config)?.catalog : undefined; if (!config) { const last = this.#paused ? track.last : undefined; - effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); + const catalog = last && { ...last, ...estimate, enabled: false }; + effect.set(this.#out.catalog, catalog); + effect.set(this.#catalog, { epoch, config: catalog }); return; } this.#paused = false; - const decoder = effect.get(this.#decoderDescription); - const catalog = decoder?.config === config ? { ...config, description: decoder.description } : config; + const catalog = config; // The catalog bitrate is the most this track has been configured for, so lowering the knob // retunes the encoder without lowering what the catalog advertises. track.bitrate = Math.max(track.bitrate, catalog.bitrate ?? 0); const bitrate = track.bitrate > 0 ? Catalog.u53(track.bitrate) : undefined; track.last = { ...catalog, bitrate, ...estimate }; effect.set(this.#out.catalog, track.last); + effect.set(this.#catalog, { epoch, config: track.last }); } // Collect the encode-only Opus knobs that are set, reading the codec through the effect so the @@ -538,20 +609,18 @@ export class Encoder { // Encode captured audio frames into whichever track producer is live. The broadcast owns the // track's lifetime, so this never closes it; a fatal encoder error aborts only the live track. #encode(baseline: Baseline, format: Format, effect: Effect): void { + // The parent owns the config subscription before the async codec scope is opened. + const resolved = this.#config.peek(); + if (!resolved) return; effect.spawn(async () => { // We're using an async polyfill temporarily for Safari support. await Util.Libav.polyfill(); effect.run((effect: Effect) => { const aborted = effect.abort; - const resolved = effect.get(this.#config); - if (!resolved) return; const config = resolved.catalog; - const capture = effect.get(this.in.capture); - const source = capture ? effect.get(capture.in.source) : undefined; - const kind: Kind = source ? sourceKind(source) : "auto"; - const encoderConfig = toEncoderConfig(resolved, kind, this.#opusOptions(effect)); + const encoderConfig = resolved.encoder; // WebCodecs rejects input whose rate doesn't match the encoder config outright, so // anything arriving at a rate the codec can't carry (a 44.1kHz file as Opus) has to @@ -569,13 +638,11 @@ export class Encoder { const framer = createFramer(resolved, config.sampleRate); const encoder = new AudioEncoder({ - output: (frame, metadata) => { + output: (frame) => { if (frame.type !== "key") { throw new Error("only key frames are supported"); } - this.#setDecoderDescription(config, metadata?.decoderConfig?.description); - this.#out.stats.update((stats) => ({ frames: stats.frames + 1, bytes: stats.bytes + frame.byteLength, @@ -691,19 +758,6 @@ export class Encoder { this.#live?.track.close(cause); } - #setDecoderDescription(config: Catalog.AudioConfig, source: AllowSharedBufferSource | undefined): void { - if (config.codec !== "opus" || !source) return; - - const bytes = ArrayBuffer.isView(source) - ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength) - : new Uint8Array(source); - const description = Util.Hex.fromBytes(bytes); - const current = this.#decoderDescription.peek(); - if (current?.config === config && current.description === description) return; - - this.#decoderDescription.set({ config, description }); - } - close() { this.#signals.close(); } diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index a8af4db5bd2..90d6680e6dc 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -398,3 +398,28 @@ test("registering a sibling after an encoder failure keeps the network broadcast broadcast.close(); } }); + +test("a served decoder identity change creates a new broadcast epoch", async () => { + const origin = new Origin.Producer(); + const broadcast = new Broadcast({ origin, name: Path.from("identity.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + const before = broadcast.net.peek(); + if (!before) throw new Error("expected an initial broadcast"); + const oldCatalog = new Json.Snapshot.Consumer({ + track: before.track(Broadcast.CATALOG_TRACK).subscribe(), + }); + expect((await oldCatalog.latest())?.value.video?.renditions.video.codec).toBe(videoConfig.codec); + video.config.set({ ...videoConfig, codec: "vp09.00.10.08" }); + await settle(12); + const after = broadcast.net.peek(); + expect(after).toBeDefined(); + expect(after).not.toBe(before); + expect(before.closed.peek()).toBeNull(); + expect(await oldCatalog.latest()).toBeUndefined(); + if (!after) throw new Error("expected a replacement broadcast"); + const next = new Json.Snapshot.Consumer({ track: after.track(Broadcast.CATALOG_TRACK).subscribe() }); + expect((await next.latest())?.value.video?.renditions.video.codec).toBe("vp09.00.10.08"); + broadcast.close(); +}); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 8dbe8a58fbf..082bf24baf1 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -3,7 +3,7 @@ import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { CatalogProducer } from "./catalog"; -import { errors } from "./internal"; +import { catalogs, errors } from "./internal"; import { Baseline } from "./jitter"; import { type Kind, Rendition } from "./rendition"; @@ -67,7 +67,7 @@ export class Broadcast { // root sections (e.g. `scte35`) by locking it too. readonly catalog = new CatalogProducer(); - // The underlying network broadcast, recreated when the name, enabled state, or maxAge changes, + // The underlying network broadcast, recreated when its name, enabled state, maxAge, or a served media identity changes, // and is `undefined` in between. It lives in the origin rather than any session, so it spans // reconnects. Exposed so an application can serve its own tracks alongside the built-in // catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section. @@ -95,6 +95,8 @@ export class Broadcast { readonly #names = new Signal>({}); #signals = new Effect(); + #restart = new Signal(0); + #catalogReady = new Signal(undefined); constructor(props?: Inputs) { this.in = { @@ -175,13 +177,20 @@ export class Broadcast { const enabled = effect.get(this.in.enabled); const renditions = effect.get(this.#renditions); const names = effect.get(this.#names); + const epoch = effect.get(this.net); const video: Record = {}; const audio: Record = {}; const text: Record = {}; for (const rendition of Object.values(renditions)) { - const source = enabled ? effect.get(rendition.config) : undefined; + const encoder = catalogs.get(rendition); + const state = encoder && effect.get(encoder); + if (enabled && encoder && (!state || state.epoch !== epoch)) { + this.#catalogReady.set(undefined); + return; + } + const source = enabled ? (encoder ? state?.config : effect.get(rendition.config)) : undefined; if (source === undefined) continue; const track = names[rendition.name]; const config = track ? { ...(source as object), track } : source; @@ -223,9 +232,11 @@ export class Broadcast { delete catalog.text; } }); + this.#catalogReady.set(epoch); } #run(effect: Effect) { + effect.get(this.#restart); const values = effect.getAll([this.in.enabled, this.in.origin]); if (!values) return; const [_enabled, origin] = values; @@ -267,7 +278,7 @@ export class Broadcast { let serving = false; effect.run((inner) => { - if (!inner.get(this.in.announce)) { + if (!inner.get(this.in.announce) || inner.get(this.#catalogReady) !== broadcast) { broadcast.unannounce(); return; } @@ -292,6 +303,9 @@ export class Broadcast { }); effect.cleanup(() => track.close()); this.catalog.serve(track, effect, { compression }); + effect.run((health) => { + if (health.get(track.closed) !== undefined) this.#restart.update((value) => value + 1); + }); } } diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 073531a1b30..e13607afcd9 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -328,7 +328,7 @@ describe("estimate publication window", () => { if (!catalog.video) throw new Error("video missing"); catalog.video.renditions.v.jitter = Catalog.u53(jitter); }); - return { producer, effect, track: subscriber, consumer, estimate }; + return { producer, effect, served: track, track: subscriber, consumer, estimate }; } test("estimate rises publish at the leading edge and coalesce to the latest trailing value", async () => { @@ -385,6 +385,17 @@ describe("estimate publication window", () => { expect(track.latest()).toBe(removed); }); + test("ending the last served track cancels a trailing estimate", async () => { + const { served, consumer, estimate } = fixture(); + await consumer.latest(); + estimate(1); + await consumer.latest(); + estimate(2); + served.close(); + expect(() => advance(1000)).not.toThrow(); + expect(timers).toHaveLength(0); + }); + test("closing the last output cancels its trailing timer", async () => { const { effect, consumer, estimate } = fixture(); await consumer.latest(); @@ -434,3 +445,135 @@ describe("estimate publication window", () => { expect((await consumer.latest())?.value.video?.renditions.v.jitter).toBe(Catalog.u53(3)); }); }); + +// A served catalog's old track must never carry a different decoder identity. +test("a served identity edit ends the old catalog before advertising the replacement", async () => { + const catalog = new CatalogProducer(); + catalog.mutate((value) => { + value.video = { renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" } } } }; + }); + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); + try { + expect((await consumer.latest())?.value.video?.renditions.video.codec).toBe("avc1.640028"); + catalog.mutate((value) => { + if (value.video) value.video.renditions.video.codec = "avc1.640033"; + }); + expect(track.closed.peek()).toBeNull(); + expect(await consumer.latest()).toBeUndefined(); + const next = new Track.Producer("catalog.json"); + catalog.serve(next, effect); + const replacement = new Json.Snapshot.Consumer({ track: next.subscribe() }); + expect((await replacement.latest())?.value.video?.renditions.video.codec).toBe("avc1.640033"); + } finally { + effect.close(); + } +}); + +test("a removed rendition name keeps its served identity", async () => { + const catalog = new CatalogProducer(); + const initial: Catalog.VideoConfig = { codec: "avc1.640028", container: { kind: "legacy" } }; + catalog.mutate((value) => { + value.video = { renditions: { video: initial } }; + }); + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + try { + catalog.mutate((value) => { + delete value.video; + }); + catalog.mutate((value) => { + value.video = { renditions: { video: initial } }; + }); + expect(track.closed.peek()).toBeUndefined(); + catalog.mutate((value) => { + delete value.video; + }); + catalog.mutate((value) => { + value.video = { renditions: { video: { ...initial, codec: "vp09.00.10.08" } } }; + }); + expect(track.closed.peek()).toBeNull(); + } finally { + effect.close(); + } +}); + +for (const field of ["bitrate", "framerate"] as const) { + test(`served ${field} rises in place and refuses a decrease`, () => { + const catalog = new CatalogProducer(); + catalog.mutate((value) => { + value.video = { + renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" }, [field]: 100 } }, + }; + }); + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + try { + catalog.mutate((value) => { + if (value.video) value.video.renditions.video[field] = Catalog.u53(200); + }); + expect(track.closed.peek()).toBeUndefined(); + expect(() => + catalog.mutate((value) => { + if (value.video) value.video.renditions.video[field] = Catalog.u53(100); + }), + ).toThrow(`${field} cannot decrease`); + expect(track.closed.peek()).toBeUndefined(); + } finally { + effect.close(); + } + }); +} + +test("an unknown ceiling fills once before any later change ends both catalog encodings", () => { + const catalog = new CatalogProducer(); + catalog.mutate((value) => { + value.video = { renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" } } } }; + }); + const effect = new Effect(); + const plain = new Track.Producer("catalog.json"); + const compressed = new Track.Producer("catalog.json.z"); + catalog.serve(plain, effect); + catalog.serve(compressed, effect, { compression: true }); + try { + catalog.mutate((value) => { + if (value.video) value.video.renditions.video.codedWidth = Catalog.u53(1280); + }); + expect(plain.closed.peek()).toBeUndefined(); + expect(compressed.closed.peek()).toBeUndefined(); + catalog.mutate((value) => { + if (value.video) value.video.renditions.video.codedWidth = Catalog.u53(1920); + }); + expect(plain.closed.peek()).toBeNull(); + expect(compressed.closed.peek()).toBeNull(); + } finally { + effect.close(); + } +}); + +test("the last served track ending releases the next epoch's maxima", () => { + const catalog = new CatalogProducer(); + catalog.mutate((root) => { + root.video = { + renditions: { + video: { codec: "vp8", container: { kind: "legacy" }, framerate: 60, bitrate: Catalog.u53(1000) }, + }, + }; + }); + const scope = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, scope); + track.close(); + expect(() => + catalog.mutate((root) => { + if (!root.video) throw new Error("missing video"); + root.video.renditions.video.framerate = 15; + root.video.renditions.video.bitrate = Catalog.u53(100); + }), + ).not.toThrow(); + scope.close(); +}); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 17bd38e6b57..ae48a688c17 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -21,32 +21,53 @@ import { Effect } from "@moq/signals"; */ export class CatalogProducer { #value: Catalog.Root = pageRoot(); - #outputs = new Set>(); + #outputs = new Map, Moq.Track.Producer>(); + #identities = new Map>(); + #served = false; + #fresh = false; #lastEstimate: number | undefined; #pending: Effect | undefined; /** Edit the catalog; estimate-only changes publish at most once a second with a trailing update. */ mutate(fn: (catalog: Catalog.Root) => void): void { + this.#prune(); const value = structuredClone(this.#value); fn(value); // Every consumer refuses a catalog that fails this, so refuse the edit before it reaches one. Catalog.checkRenditions(value); - for (const field of ["jitter", "delay"] as const) { + const restart = this.#changedIdentity(value); + for (const field of ["jitter", "delay", "bitrate", "framerate"] as const) { const previous = advertised(this.#value, field); for (const [section, next] of Object.entries(advertised(value, field))) { for (const [name, estimate] of Object.entries(next)) { - if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); - const before = previous[section]?.[name]; - if (before !== undefined && (estimate === undefined || estimate < before)) { + if ((field === "jitter" || field === "delay") && estimate === 0) + throw new Error(`omit ${field} rather than advertising 0`); + const before = previous[section]?.[name] ?? this.#identities.get(section)?.get(name)?.[field]; + if ( + !restart && + !this.#fresh && + before !== undefined && + (estimate === undefined || estimate < before) + ) { throw new Error(`${field} cannot decrease for an existing track`); } } } } + if (restart) { + // A catalog track can end, but it can never advertise a new decoder identity. + for (const [output, track] of this.#outputs) { + output.finish(); + track.close(); + } + this.#outputs.clear(); + this.#reset(); + } const estimateOnly = Json.deepEqual(structure(this.#value), structure(value)); const changed = !Json.deepEqual(this.#value, value); this.#value = value; if (!changed || this.#outputs.size === 0) return; + this.#remember(); const now = performance.now(); if (!estimateOnly || this.#lastEstimate === undefined || now >= this.#lastEstimate + 1000) { @@ -58,11 +79,12 @@ export class CatalogProducer { } #publish(estimate: boolean): void { + this.#prune(); this.#pending?.close(); this.#pending = undefined; if (estimate) this.#lastEstimate = performance.now(); const at = Time.Timestamp.now(); - for (const output of this.#outputs) output.update({ value: this.#value, at }); + for (const output of this.#outputs.keys()) output.update({ value: this.#value, at }); } /** @@ -72,24 +94,87 @@ export class CatalogProducer { * served both plaintext and compressed (e.g. `catalog.json` and `catalog.json.z`). */ serve(track: Moq.Track.Producer, effect: Effect, opts?: { compression?: boolean }): void { + this.#prune(); const output = new Json.Snapshot.Producer({ track, compression: opts?.compression ? "deflate" : "none", deltaRatio: 0, }); + this.#served = true; + this.#fresh = false; + this.#remember(); output.update({ value: this.#value, at: Time.Timestamp.now() }); - this.#outputs.add(output); + this.#outputs.set(output, track); effect.cleanup(() => { this.#outputs.delete(output); output.finish(); - if (this.#outputs.size === 0) { - this.#pending?.close(); - this.#pending = undefined; - this.#lastEstimate = undefined; - } + if (this.#outputs.size === 0) this.#reset(); }); } + #changedIdentity(value: Catalog.Root): boolean { + for (const [section, tracks] of Object.entries(renditions(value))) { + for (const [name, next] of Object.entries(tracks ?? {})) { + const before = this.#identities.get(section)?.get(name); + if (!before) continue; + for (const field of ["codec", "description", "container", "sampleRate", "numberOfChannels"] as const) { + if (!Json.deepEqual(before[field], next[field])) return true; + } + for (const field of ["codedWidth", "codedHeight"] as const) { + if (before[field] !== undefined && before[field] !== next[field]) return true; + } + } + } + return false; + } + + #remember(): void { + for (const [section, tracks] of Object.entries(renditions(this.#value))) { + let identities = this.#identities.get(section); + if (!identities) { + identities = new Map(); + this.#identities.set(section, identities); + } + for (const [name, config] of Object.entries(tracks ?? {})) identities.set(name, structuredClone(config)); + } + } + + #prune(): void { + for (const [output, track] of this.#outputs) { + if (track.closed.peek() === undefined) continue; + this.#outputs.delete(output); + output.finish(); + } + if (this.#outputs.size === 0) this.#reset(); + } + + #reset(): void { + if (this.#served) this.#fresh = true; + this.#served = false; + this.#identities.clear(); + this.#pending?.close(); + this.#pending = undefined; + this.#lastEstimate = undefined; + } +} + +// Identity and advertised bounds shared by the media rendition schemas. +type Config = { + codec: string; + description?: string; + container: unknown; + sampleRate?: number; + numberOfChannels?: number; + codedWidth?: number; + codedHeight?: number; + bitrate?: number; + framerate?: number; + jitter?: number; + delay?: number; +}; + +function renditions(value: Catalog.Root): Record | undefined> { + return { audio: value.audio?.renditions, video: value.video?.renditions }; } // Strip only the schema's estimate fields, so config edits and arbitrary extension sections remain @@ -114,10 +199,11 @@ function structure(catalog: Catalog.Root): Catalog.Root { /** Every track's advertised `field`, by section and then track name. */ function advertised( catalog: Catalog.Root, - field: "jitter" | "delay", + field: "jitter" | "delay" | "bitrate" | "framerate", ): Record> { - const pick = (tracks: Record | undefined) => - Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config[field]])); + const pick = ( + tracks: Record | undefined, + ) => Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config[field]])); return { audio: pick(catalog.audio?.renditions), video: pick(catalog.video?.renditions), diff --git a/js/publish/src/internal.ts b/js/publish/src/internal.ts index 0261eb09264..29ec97254a9 100644 --- a/js/publish/src/internal.ts +++ b/js/publish/src/internal.ts @@ -1,4 +1,10 @@ +import type * as Moq from "@moq/net"; import type { Getter } from "@moq/signals"; /** @internal Terminal failures of broadcast-owned rendition tracks, including idle tracks. */ export const errors = new WeakMap>(); + +/** @internal Pair an encoder config with the network epoch whose bounds it describes. */ +export type CatalogState = { epoch: Moq.Broadcast.Producer | undefined; config: unknown }; +/** @internal Encoder catalog states keyed by their registered rendition. */ +export const catalogs = new WeakMap>(); diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index e23ae9301a5..e10bc69cfcb 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -232,7 +232,11 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi const rendition = { config: new Signal(undefined), track: live, close: () => {} }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture, }); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 93147850df4..dc568157fcc 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -106,7 +106,11 @@ test("encoding tracks encoder config in its child effect", async () => { track: new Signal(track), close: () => track.close(), }; - const broadcast = { video: () => rendition, baseline: new Baseline() }; + const broadcast = { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + }; const capture = { in: { source: new Signal(undefined) }, out: { @@ -153,7 +157,11 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, }); @@ -210,7 +218,11 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, bandwidth, }); @@ -291,7 +303,11 @@ test("every published config fits a probe for its own codec", async () => { const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, bandwidth, }); @@ -612,7 +628,11 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const enabled = new Signal(true); const encoder = new Encoder("video", { enabled, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, }); @@ -772,7 +792,11 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const bandwidth = new Moq.Bandwidth.Allocator(estimate); const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, bandwidth, }); @@ -928,7 +952,7 @@ test("cut forces a keyframe, coalescing requests and spacing them at least 500ms }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition } as never, + broadcast: { video: () => rendition, net: new Signal(new Moq.Broadcast.Producer()) } as never, capture: capture as never, }); @@ -1155,7 +1179,11 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, }); const send = async (millis: number) => { @@ -1225,7 +1253,11 @@ test("the catalog bitrate and frame rate only rise", async () => { const bandwidth = new Moq.Bandwidth.Allocator(estimate); const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, baseline: new Baseline() } as never, + broadcast: { + video: () => rendition, + baseline: new Baseline(), + net: new Signal(new Moq.Broadcast.Producer()), + } as never, capture: capture as never, bandwidth, }); @@ -1270,7 +1302,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { track: new Signal(undefined), close: () => {}, }); - const first = { video: rendition, baseline: new Baseline() }; + const first = { video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }; const broadcast = new Signal(first); const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -1292,7 +1324,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 1920, codedHeight: 1072, framerate: 60 }); const bitrate = encoder.out.catalog.peek()?.bitrate ?? 0; - broadcast.set({ video: rendition, baseline: new Baseline() }); + broadcast.set({ video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }); // The previous broadcast's probe never describes the new track, even while its own runs. for (let i = 0; i < 5; i++) await Promise.resolve(); expect(encoder.out.catalog.peek()).toBeUndefined(); @@ -1315,7 +1347,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { enabled.set(false); await settle(); expect(encoder.out.catalog.peek()?.enabled).toBe(false); - broadcast.set({ video: rendition, baseline: new Baseline() }); + broadcast.set({ video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }); await settle(); expect(encoder.out.catalog.peek()).toBeUndefined(); } finally { @@ -1479,3 +1511,76 @@ test("a refused first ceiling recovers at a smaller size", async () => { error.mockRestore(); } }); + +test("ceiling growth replaces the network epoch and resets its maxima", async () => { + using _videoEncoder = installFakeVideoEncoder(); + using _probe = spyOn(FakeVideoEncoder, "isConfigSupported").mockResolvedValue({ supported: true }); + using _now = spyOn(performance, "now").mockReturnValue(0); + const origin = new Moq.Origin.Producer(); + const announcements = origin.announced(); + const broadcast = new Broadcast({ origin, name: Moq.Path.from("ceiling.hang") }); + const capture = { + in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, + out: { display: new Signal({ width: 640, height: 480 }), frames: new Signal(undefined) }, + }; + const encoder = new Encoder("video", { broadcast, capture: capture as never, config: { frameRate: 60 } }); + const subscribers: Moq.Track.Subscriber[] = []; + const catalog = (net: Moq.Broadcast.Producer) => { + const subscriber = net.track(Broadcast.CATALOG_TRACK).subscribe(); + subscribers.push(subscriber); + return new Json.Snapshot.Consumer({ track: subscriber }); + }; + try { + await encoder.out.catalog.changed(); + const first = broadcast.net.peek(); + if (!first) throw new Error("expected the first epoch"); + await announcements.next(); + const before = catalog(first); + let initial = await before.latest(); + while (!initial?.value.video?.renditions.video) initial = await before.latest(); + expect(initial.value.video.renditions.video).toMatchObject({ + codedWidth: 640, + codedHeight: 480, + framerate: 60, + }); + const epoch = first.route?.epoch; + const bitrate = initial.value.video.renditions.video.bitrate; + capture.out.display.set({ width: 320, height: 240 }); + encoder.config.set({ frameRate: 15 }); + let live = encoder.out.resolved.peek(); + while (live?.width !== 320 || live.framerate !== 15) live = await encoder.out.resolved.changed(); + expect(broadcast.net.peek()).toBe(first); + expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 640, codedHeight: 480, framerate: 60, bitrate }); + + const restarting = broadcast.net.changed(); + capture.out.display.set({ width: 1280, height: 720 }); + const next = await restarting; + if (!next || next === first) throw new Error("expected a new epoch"); + let announcement = await announcements.next(); + while (announcement?.kind === "end" || announcement?.route.epoch === epoch) + announcement = await announcements.next(); + const after = catalog(next); + let snapshot = await after.latest(); + while (!snapshot?.value.video?.renditions.video) snapshot = await after.latest(); + expect(snapshot.value.video.renditions.video).toMatchObject({ + codedWidth: 1280, + codedHeight: 720, + framerate: 15, + }); + expect(next.route?.epoch).not.toBe(epoch); + expect(first.closed.peek()).toBeNull(); + for (let old = await before.latest(); old; old = await before.latest()) { + expect(old.value.video?.renditions.video).toMatchObject({ + codedWidth: 640, + codedHeight: 480, + framerate: 60, + }); + } + } finally { + for (const subscriber of subscribers) subscriber.close(); + encoder.close(); + broadcast.close(); + announcements.close(); + origin.close(); + } +}); diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index e3da139daf6..93a6493cb73 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -14,7 +14,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { errors } from "../internal"; +import { type CatalogState, catalogs, errors } from "../internal"; import { type Baseline, Estimator } from "../jitter"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; @@ -111,7 +111,7 @@ type EncoderOutput = { * attached (the demand gate). Rename by constructing a new encoder; the name is not a signal. * * The catalog advertises the largest size encoded so far as the coded-size ceiling, so a capture that - * shrinks resizes in band at a keyframe. Outgrowing the ceiling re-probes and advertises a larger one. + * shrinks resizes in band at a keyframe. Outgrowing the ceiling re-probes and publishes a new broadcast epoch. */ export class Encoder { /** The full track name of this rendition, e.g. `"video/hd"`. */ @@ -147,7 +147,7 @@ export class Encoder { // The coded-size ceiling to probe for the current broadcast: each side's largest of its accepted // ceiling and the current size. A smaller capture resizes in band below it without changing what // the catalog advertises. - #coded = new Signal<{ broadcast?: Broadcast; width: number; height: number } | undefined>(undefined); + #coded = new Signal<{ epoch?: Moq.Broadcast.Producer; width: number; height: number } | undefined>(undefined); // The config the browser accepted and encoded a probe frame with, and the codec string it reported. // Kept through a re-probe, so the rendition stays in the catalog until the new probe replaces it. @@ -192,9 +192,9 @@ export class Encoder { #estimator = new Estimator(); // The estimator's jitter and delay, republished whenever either rises. #estimate = new Signal({}); - // What this rendition advertised on each broadcast, keyed by the broadcast or, for a local preview, - // the encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one - // continues its ceiling, maxima, and last config, while another broadcast starts its own. + // A network epoch owns its ceiling, maxima, and last config. A restart of the same + // Broadcast component gets a fresh entry; local previews use the encoder. + #catalog = new Signal(undefined); #advertised = new WeakMap(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens @@ -258,8 +258,8 @@ export class Encoder { } // What this rendition advertised on `broadcast`'s track. - #track(broadcast: Broadcast | undefined): Advertised { - const key = broadcast ?? this; + #track(epoch: Moq.Broadcast.Producer | undefined): Advertised { + const key = epoch ?? this; let track = this.#advertised.get(key); if (!track) { track = { bitrate: 0, framerate: 0 }; @@ -318,6 +318,7 @@ export class Encoder { const rendition = broadcast.video(this.name); errors.set(rendition, failure); + catalogs.set(rendition, this.#catalog); effect.cleanup(() => rendition.close()); // Publish the resolved catalog config; undefined (until one resolves) leaves it out of the catalog. @@ -533,12 +534,17 @@ export class Encoder { #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const track = this.#track(effect.get(this.in.broadcast)); + const broadcast = effect.get(this.in.broadcast); + const epoch = broadcast ? effect.get(broadcast.net) : undefined; + const track = this.#track(epoch); if (!enabled) this.#paused = true; - const live = enabled ? effect.get(this.#live) : undefined; + const candidate = enabled ? effect.get(this.#live) : undefined; + const live = candidate?.detected.epoch === epoch ? candidate : undefined; if (!live) { const last = this.#paused ? track.last : undefined; - effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); + const catalog = last && { ...last, ...estimate, enabled: false }; + effect.set(this.#out.catalog, catalog); + effect.set(this.#catalog, { epoch, config: catalog }); return; } this.#paused = false; @@ -561,6 +567,7 @@ export class Encoder { track.last = catalog; effect.set(this.#out.catalog, catalog); + effect.set(this.#catalog, { epoch, config: catalog }); } // Probe the hardware for the best codec at the coded-size ceiling. Deliberately depends on as little @@ -580,7 +587,7 @@ export class Encoder { // Captured now: the run's signal is replaced once a rerun starts. const superseded = effect.abort; effect.spawn(async () => { - const track = this.#track(dimensions.broadcast); + const track = this.#track(dimensions.epoch); try { let detected: Detected; try { @@ -609,7 +616,7 @@ export class Encoder { // Only an accepted probe commits a ceiling, so a size the hardware refuses never sticks. const { width, height } = detected.config; track.ceiling = { width, height }; - this.#codec.set({ ...detected, broadcast: dimensions.broadcast }); + this.#codec.set({ ...detected, epoch: dimensions.epoch }); } catch (err) { // A newer probe reports its own outcome. if (superseded.aborted) return; @@ -630,10 +637,11 @@ export class Encoder { // A probe for another broadcast describes another track, so wait for this one's. const broadcast = effect.get(this.in.broadcast); + const epoch = broadcast ? effect.get(broadcast.net) : undefined; const detected = effect.get(this.#codec); const dimensions = effect.get(this.#dimensions); const target = effect.get(this.#target); - if (!detected || detected.broadcast !== broadcast || !dimensions || !target) return; + if (!detected || detected.epoch !== epoch || !dimensions || !target) return; // A capture that outgrew the probe encodes scaled down to fit until the re-probe at the new // ceiling lands, so the live config never exceeds what the catalog advertises. @@ -703,10 +711,11 @@ export class Encoder { if (!dimensions) return; const broadcast = effect.get(this.in.broadcast); + const epoch = broadcast ? effect.get(broadcast.net) : undefined; effect.get(this.#codec); - const ceiling = this.#track(broadcast).ceiling; + const ceiling = this.#track(epoch).ceiling; effect.set(this.#coded, { - broadcast, + epoch, width: Math.max(ceiling?.width ?? 0, dimensions.width), height: Math.max(ceiling?.height ?? 0, dimensions.height), }); @@ -842,8 +851,8 @@ type Detected = { config: VideoEncoderConfig & { framerate: number; bitrate: number }; // The codec string the encoder reported for it, which the catalog advertises. reported: string; - // The broadcast the probe ran for; another one starts its own ceiling. - broadcast?: Broadcast; + // The network epoch the probe ran for; another one starts its own ceiling. + epoch?: Moq.Broadcast.Producer; }; // What a rendition advertised on one broadcast's track. diff --git a/quest/m0/broadcast-epoch/catalog-references.md b/quest/m0/broadcast-epoch/catalog-references.md index 4279774411a..8c6ed71eec9 100644 --- a/quest/m0/broadcast-epoch/catalog-references.md +++ b/quest/m0/broadcast-epoch/catalog-references.md @@ -87,6 +87,5 @@ guarantee and the selected legacy best-effort behavior. - [Source pin](https://github.com/moq-dev/moq/pull/5252) - exporters share their resolved catalog handle - [JS consume identity](https://github.com/moq-dev/moq/pull/5254) - JS resolves the actual serving identity - [Retired requests](https://github.com/moq-dev/moq/pull/5294) - held handles cannot emit unpinned requests after retirement -- [JS identity enforcement](/quest/m1/catalog-identity-js.md) - catalog updates cannot redefine a track's content in `@moq/publish` - [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - the same for the Rust publishers - [IETF epochs](/quest/m1/ietf-epochs.md) - verified catalog identity over negotiated IETF sessions diff --git a/quest/m1/README.md b/quest/m1/README.md index af1e55522f7..27dae4ddbdf 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -104,7 +104,6 @@ above them. - [JSON window timestamps](/quest/m1/json-window-timestamps.md) - Rust and JS window consumers return each event's frame timestamp like the snapshot and stream consumers - [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native FILL_TIMEOUT bounds upstream waiting on drafts 18-22, alongside local content-age enforcement -- [JS identity enforcement](/quest/m1/catalog-identity-js.md) - `@moq/publish` republishes under a new epoch instead of redefining a served track, and probes the Opus description before advertising it - [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher resizes in band below its ceiling or mints a new rendition or epoch - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the catalog entry and format may break in place, since no archives exist - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish diff --git a/quest/m1/catalog-identity-js.md b/quest/m1/catalog-identity-js.md deleted file mode 100644 index b38a5a4abf4..00000000000 --- a/quest/m1/catalog-identity-js.md +++ /dev/null @@ -1,51 +0,0 @@ -# [M] JS identity enforcement - -## Goal - -`@moq/publish` never redefines a track it has served. Once a catalog is -served, a change to a rendition's identity (`codec`, `description`, -`container`, `sampleRate`, `numberOfChannels`) or growth past its -`codedWidth`/`codedHeight` ceiling republishes the broadcast under a new -epoch rather than mutating the track, per the hang draft's -[Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) section. Live -state and rising maxima stay plain catalog updates, and a picture that shrinks -below its fixed ceiling resizes in band with no update at all. - -## Plan - -- ✅ Decided 2026-10-13 (maintainer, #5459): enforce by republishing under a - new epoch. Once a catalog is served, an edit that redefines a served - rendition stops feeding the old catalog track (so it never carries the new - identity) and `Broadcast` restarts under a new epoch, as it already does - for a closed rendition track. Refusing the change, or minting a new - rendition name, were rejected. -- ✅ Decided 2026-10-13 (maintainer, #5459): probe the Opus `description` - before advertising it. Today the audio encoder adds the encoder-reported - description on the first encode, which would restart the broadcast on - every first subscriber. Probe it with a throwaway encoder, as the video - encoder probes its codec string, using the exact encoder config (kind - defaults and Opus knobs included, since they move pre-skip). -- ✅ Decided 2026-10-13 (maintainer, #5459): the video ceiling stays the - per-side maximum of every size encoded, so outgrowing it is the one - resize that republishes. - -Track identities per network broadcast in `CatalogProducer` (keyed by section -and name, kept across a rendition's removal so a returning name is compared -too), recorded from the first serve and cleared when every served track ends. -An absent ceiling may be filled once. Bitrate and framerate decreases are -refused like jitter and delay. The encoders' ceiling and maxima are keyed by -the `Broadcast` component today, so a restart under a new epoch inherits the -old run's limits; key them per epoch with the identities. - -What then republishes: video ceiling growth, a codec knob or re-probe that -changes the codec string, an audio capture that changes rate or channel count, -and an audio codec change. - -Test a served identity change reaching a new epoch with the old catalog track -ending on the old identity, a returning name, the Opus description present -from the first catalog, and a shrink or maxima rise staying in place. - -## Related - -- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - the same contract for the Rust publishers -- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a restart is a new epoch rather than a changed track diff --git a/quest/m1/catalog-identity-rs.md b/quest/m1/catalog-identity-rs.md index 022de8b6170..4d0543f31c8 100644 --- a/quest/m1/catalog-identity-rs.md +++ b/quest/m1/catalog-identity-rs.md @@ -14,15 +14,14 @@ every group of the tracks it lists. - ✅ Decided 2026-10-13 (maintainer, #5459): split from the catalog identity quest. The contract, docs, and the `js/publish` ceiling landed there; - [JS identity enforcement](/quest/m1/catalog-identity-js.md) owns the - browser side. + `@moq/publish` enforces the browser side. - ✅ Decided 2026-10-13 (maintainer, #5459): JS enforces identity by republishing under a new epoch with the Opus description probed up front, and the `js/publish` video ceiling keeps growing per side. Open: whether each Rust violator mints a new rendition name (as FLV, HLS import, and transcode rungs already do) or restarts under a new epoch (as JS -will). Recommended: mint a name where the catalog can keep both, and take a +does). Recommended: mint a name where the catalog can keep both, and take a new epoch only for a whole-broadcast restart. Guard `moq-mux` `Rendition::set`, `replace`, and `stage` @@ -62,6 +61,5 @@ of buffer when no `jitter` is advertised. ## Related -- [JS identity enforcement](/quest/m1/catalog-identity-js.md) - the same contract for `@moq/publish` - [Archive](/quest/m1/archive/README.md) - storage and replay consume the identity contract - [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a restart is a new epoch rather than a changed track From 1454cf451c74d991881ee94908a50fa1f71be1b6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:00:02 -0700 Subject: [PATCH 03/13] fix(publish): mint a new track name when served media identity changes A served rendition whose codec, description, container, audio format, or coded-size ceiling changes keeps its catalog ID and moves to a fresh `track` name in the same epoch, ending the old track on its old identity. This replaces restarting the whole broadcast under a new epoch, per the 2026-10-15 maintainer decision. Names are fixed once the catalog is first served, so pre-announce churn mints nothing. Drops the epoch handoff (per-epoch encoder limits, catalog termination, announce gating) that only the restart needed. Keeps the Opus description probe and the refusal of falling bitrate/framerate, now keyed by track name. Co-Authored-By: Claude Opus 5.5 --- doc/concept/hang.md | 2 +- doc/lib/js/publish.md | 8 +- drafts/draft-lcurley-moq-hang.md | 3 +- js/publish/bench/catalog.ts | 17 +-- js/publish/src/audio/capture.test.ts | 6 +- js/publish/src/audio/encoder.test.ts | 24 ++-- js/publish/src/audio/encoder.ts | 22 ++-- js/publish/src/broadcast.test.ts | 131 +++++++++++++++++--- js/publish/src/broadcast.ts | 90 ++++++++++---- js/publish/src/catalog.test.ts | 176 +++++---------------------- js/publish/src/catalog.ts | 117 +++--------------- js/publish/src/internal.ts | 6 - js/publish/src/rendition.ts | 7 +- js/publish/src/video/capture.test.ts | 6 +- js/publish/src/video/encoder.test.ts | 131 ++++++-------------- js/publish/src/video/encoder.ts | 46 +++---- quest/m1/README.md | 2 +- quest/m1/catalog-identity-rs.md | 20 +-- 18 files changed, 328 insertions(+), 486 deletions(-) diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 570e8ca61f0..efeb734e7cb 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -65,7 +65,7 @@ A few things the catalog can express beyond decoder config: - **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the rendition's ID, so labels need not be unique and renaming one doesn't rename the track. - **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. -- **Track names.** The map key is a rendition's ID and, by default, its track name. A rendition sets `track` only when the two must differ, such as two renditions of same-named tracks in different broadcasts; readers subscribe to `track` when present. An ID can't name both a video and an audio rendition. +- **Track names.** The map key is a rendition's ID and, by default, its track name. A rendition sets `track` only when the two must differ, such as two renditions of same-named tracks in different broadcasts, or a rendition whose media changed and so needs a new name; readers subscribe to `track` when present. An ID can't name both a video and an audio rendition. - **Jitter.** A rendition can say how far its frames fell behind the media clock before the publisher flushed them, in whole milliseconds rounded up. Encoders report the spread of lateness above each rendition's own recent minimum, so a constant encoder delay is not jitter; container imports estimate batch spans without counting ingest delay. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. - **Delay.** A rendition can also say how far its frames reach the transport behind the broadcast's earliest rendition, measured the same way from each rendition's minimum lateness, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. - **Disabled renditions.** An audio or video rendition with `enabled: false` has no frames coming, and a player must not select it. Pausing a rendition in `@moq/publish` keeps it in the catalog this way rather than removing it, so a mute is a one-field update; a disabled video rendition encodes one black keyframe first, so a player that predates the field shows black instead of a frozen picture. The field is written only when false. The older video `stalled` field is no longer written, and readers ignore it. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 57a63eaa8ca..4beb717c518 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -49,7 +49,10 @@ for a permission, device, or settings change instead of prompting again. The video encoder follows its share of the connection's send-rate estimate, so several publishers on one connection do not each target the whole uplink. Codec, resolution, framerate, and bitrate are tunable through -`el.video.config`, and `el.video.cut()` asks for an extra keyframe. For +`el.video.config`, and `el.video.cut()` asks for an extra keyframe. A +capture that shrinks resizes in place, while a codec or audio format change, +or video outgrowing its advertised size, keeps the rendition's catalog ID under +a fresh `track` name that viewers follow through the catalog. For simulcast, drop the element and register several encoders on a `Publish.Broadcast`, as below. @@ -67,8 +70,7 @@ timeline. The catalog's `clock` maps it to wall time. Stamp your own tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application can serve its own tracks alongside the media. It is recreated when the origin, name, -`enabled`, or `maxAge` changes, or a served media identity changes (codec, audio -format, or growth past the video coded-size ceiling). Acquire it from an effect and create each +`enabled`, or `maxAge` changes, so acquire it from an effect and create each track once per `net`. A track name always means the same content, so a track re-created on the same `net` throws on an append that would reuse its sequences: diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 5e952d26862..78d32fa10be 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -174,6 +174,7 @@ Each rendition field falls in one of three classes: Every other rendition field is live state that MAY change in any update within its own rules (`jitter` and `delay` never fall), as MAY the video section's `display`, `rotation`, and `flip`, which describe presentation rather than decoding. A publisher that changes an identity field or exceeds a ceiling MUST publish the media under a new track name, or as a new broadcast under a new Epoch {{moql}}. +A rendition MAY keep its ID and name the new track with `track` ({{field-track}}), so a consumer follows the new name rather than reusing a decoder configured for the ID. A track name removed from the catalog MUST NOT return with a different identity. A consumer MAY configure a decoder once per track from the catalog. @@ -1123,7 +1124,7 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} -- Fixed a track's identity for its name ({{identity}}): codec, description, container, sample rate, and channel count never change; `codedWidth` and `codedHeight` are ceilings below which the picture resizes in band; `bitrate` and `framerate` are maxima that only rise. A change of identity takes a new track name or a new Epoch. +- Fixed a track's identity for its name ({{identity}}): codec, description, container, sample rate, and channel count never change; `codedWidth` and `codedHeight` are ceilings below which the picture resizes in band; `bitrate` and `framerate` are maxima that only rise. 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. - Specified conversion between relative broadcast references and MSF track namespaces without changing track identity, reading a namespace aligned with the catalog path's trailing segments as the catalog itself. diff --git a/js/publish/bench/catalog.ts b/js/publish/bench/catalog.ts index 32c62c0b714..8dffeccabd0 100644 --- a/js/publish/bench/catalog.ts +++ b/js/publish/bench/catalog.ts @@ -82,18 +82,7 @@ async function measure(tracks: number, viewers: number) { if (publishes !== 2 || updates !== 2 * tracks * viewers) { throw new Error(`estimate churn regression: ${publishes} publishes, ${updates} subscribe updates`); } - const identityStart = realNow(); - catalog.mutate((root) => { - if (!root.video) throw new Error("video missing"); - for (const config of Object.values(root.video.renditions)) config.codec = "vp8"; - }); - for (const viewer of subscribers) { - if ((await viewer.catalog.latest()) !== undefined) throw new Error("old catalog received a new identity"); - } - const identity = realNow() - identityStart; - console.log( - `${tracks},${viewers},${publishes},${updates},${1000 * tracks * viewers},${elapsed.toFixed(1)},${identity.toFixed(3)}`, - ); + console.log(`${tracks},${viewers},${publishes},${updates},${1000 * tracks * viewers},${elapsed.toFixed(1)}`); scope.close(); output.close(); for (const viewer of subscribers) { @@ -104,9 +93,7 @@ async function measure(tracks: number, viewers: number) { } try { - console.log( - "tracks,viewers,catalog_publishes,subscribe_updates,unlimited_subscribe_updates,elapsed_ms,identity_ms", - ); + console.log("tracks,viewers,catalog_publishes,subscribe_updates,unlimited_subscribe_updates,elapsed_ms"); for (const tracks of [1, 4, 16]) for (const viewers of [1, 4, 16]) await measure(tracks, viewers); } finally { performance.now = realNow; diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index 43474122db2..9c0aa7e928f 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -563,11 +563,7 @@ test("a failed encoder stays failed until the capture source changes", async () close: () => {}, }; const encoder = new Encoder("audio", { - broadcast: { - audio: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { audio: () => rendition, baseline: new Baseline() } as never, capture, enabled, }); diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index c6cc67bb16f..4adaaa70a98 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -291,7 +291,7 @@ async function setup(baseline = new Baseline(), codec?: Codec, groupDuration?: T const enabled = new Signal(true); const encoder = new Encoder("audio", { - broadcast: { audio: () => rendition, baseline, net: new Signal(undefined) } as never, + broadcast: { audio: () => rendition, baseline } as never, capture: capture as never, enabled, codec, @@ -796,28 +796,20 @@ test("audio failure stays local through demand changes and capture format re-arm await reconfiguring; await settle(); expect(encoder.out.error.peek()).toBeUndefined(); - const next = broadcast.net.peek(); - if (!next || next === net) throw new Error("expected a new audio identity epoch"); - expect(net.closed.peek()).toBeNull(); - expect(next.route?.epoch).not.toBe(epoch); - for (let old = await catalog.latest(); old; old = await catalog.latest()) { - expect(old.value.audio?.renditions.audio?.numberOfChannels).toBe(Catalog.u53(1)); - } - const nextCatalog = new Json.Snapshot.Consumer({ - track: next.track(Broadcast.CATALOG_TRACK).subscribe(), - }); - const config = (await nextCatalog.latest())?.value.audio?.renditions.audio; - if (!config) throw new Error("expected the recovered rendition"); + const config = (await catalog.latest())?.value.audio?.renditions.audio; + if (!config?.track) throw new Error("expected recovered track name"); + expect(config.track).not.toBe("audio"); expect(config.numberOfChannels).toBe(Catalog.u53(2)); expect(config.enabled).not.toBe(false); - recovered = next.track(config.track ?? "audio").subscribe(); + recovered = net.track(config.track).subscribe(); await settle(); await push(3, 2); expect(recovered.latest()).toBe(0); expect(encoder.out.active.peek()).toBe(true); expect(recovered.closed.peek()).toBeUndefined(); - expect(broadcast.net.peek()).toBe(next); - expect(siblingTrack.closed.peek()).toBeNull(); + expect(broadcast.net.peek()).toBe(net); + expect(net.route?.epoch).toBe(epoch); + expect(siblingTrack.appendGroup().sequence).toBe(2); } finally { subscriber?.close(); siblingSubscriber?.close(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index b88f03d30c5..7ee7fc56afc 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -15,7 +15,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { type CatalogState, catalogs, errors } from "../internal"; +import { errors } from "../internal"; import { type Baseline, Estimator } from "../jitter"; import type { AudioFrame, Capture, Format } from "./capture"; import { Gain } from "./gain"; @@ -225,8 +225,9 @@ export class Encoder { #estimator = new Estimator(); // The estimator's jitter and delay, republished whenever either rises. #estimate = new Signal({}); - // The network epoch owns its bitrate maximum and last config; local previews use the encoder. - #catalog = new Signal(undefined); + // What this rendition advertised on each broadcast, keyed by the broadcast or, with none, the + // encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one + // continues its bitrate maximum and last config, while another broadcast starts its own. #advertised = new WeakMap(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens @@ -377,7 +378,6 @@ export class Encoder { const rendition = broadcast.audio(this.name); errors.set(rendition, failure); - catalogs.set(rendition, this.#catalog); effect.cleanup(() => rendition.close()); // Publish the resolved config; undefined (no capture) drops it from the catalog. @@ -550,9 +550,7 @@ export class Encoder { #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const broadcast = effect.get(this.in.broadcast); - const epoch = broadcast ? effect.get(broadcast.net) : undefined; - const key = epoch ?? this; + const key = effect.get(this.in.broadcast) ?? this; let track = this.#advertised.get(key); if (!track) { track = { bitrate: 0 }; @@ -562,21 +560,17 @@ export class Encoder { const config = enabled ? effect.get(this.#config)?.catalog : undefined; if (!config) { const last = this.#paused ? track.last : undefined; - const catalog = last && { ...last, ...estimate, enabled: false }; - effect.set(this.#out.catalog, catalog); - effect.set(this.#catalog, { epoch, config: catalog }); + effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); return; } this.#paused = false; - const catalog = config; // The catalog bitrate is the most this track has been configured for, so lowering the knob // retunes the encoder without lowering what the catalog advertises. - track.bitrate = Math.max(track.bitrate, catalog.bitrate ?? 0); + track.bitrate = Math.max(track.bitrate, config.bitrate ?? 0); const bitrate = track.bitrate > 0 ? Catalog.u53(track.bitrate) : undefined; - track.last = { ...catalog, bitrate, ...estimate }; + track.last = { ...config, bitrate, ...estimate }; effect.set(this.#out.catalog, track.last); - effect.set(this.#catalog, { epoch, config: track.last }); } // Collect the encode-only Opus knobs that are set, reading the codec through the effect so the diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 90d6680e6dc..945bb0bc853 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -399,27 +399,122 @@ test("registering a sibling after an encoder failure keeps the network broadcast } }); -test("a served decoder identity change creates a new broadcast epoch", async () => { - const origin = new Origin.Producer(); - const broadcast = new Broadcast({ origin, name: Path.from("identity.hang") }); +// A name always means the same content, so a served rendition whose decoder identity changes moves to +// a fresh track name under the same catalog ID and epoch, ending the old track on its old identity. +test("a served identity change moves the rendition to a fresh track name", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("identity.hang") }); + const video = broadcast.video("video"); + const sized = { ...videoConfig, codedWidth: Catalog.u53(640), codedHeight: Catalog.u53(480) }; + video.config.set(sized); + await settle(); + + const net = broadcast.net.peek(); + if (!net) throw new Error("expected a network producer once connected"); + const epoch = net.route?.epoch; + const catalog = new Json.Snapshot.Consumer({ + track: net.track(Broadcast.CATALOG_TRACK).subscribe(), + }); + const first = net.track("video").subscribe(); + try { + expect((await catalog.latest())?.value.video?.renditions.video?.track).toBeUndefined(); + await settle(); + const track = video.track.peek(); + if (!track) throw new Error("expected the demanded track"); + track.appendGroup().close(); + + // A rising maximum stays on the served track. + video.config.set({ ...sized, bitrate: Catalog.u53(1_000_000) }); + await settle(); + expect((await catalog.latest())?.value.video?.renditions.video?.track).toBeUndefined(); + expect(video.track.peek()).toBe(track); + + // Outgrowing the coded-size ceiling is a new identity. + video.config.set({ + ...videoConfig, + codedWidth: Catalog.u53(1280), + codedHeight: Catalog.u53(720), + bitrate: Catalog.u53(1_000_000), + }); + await settle(); + const config = (await catalog.latest())?.value.video?.renditions.video; + if (!config?.track) throw new Error("expected a fresh track name"); + expect(config.track).toStartWith("video/"); + expect(config.codedWidth).toBe(Catalog.u53(1280)); + expect(track.closed.peek()).toBeNull(); + expect(video.track.peek()).toBeUndefined(); + expect(broadcast.net.peek()).toBe(net); + expect(net.route?.epoch).toBe(epoch); + + const second = net.track(config.track).subscribe(); + await settle(); + const replacement = video.track.peek(); + expect(replacement?.name).toBe(config.track); + expect(replacement?.appendGroup().sequence).toBe(0); + + // A codec change moves it again rather than redefining the second name. + video.config.set({ ...config, track: undefined, codec: "vp8" }); + await settle(); + const third = (await catalog.latest())?.value.video?.renditions.video?.track; + expect(third).toStartWith("video/"); + expect(third).not.toBe(config.track); + expect(replacement?.closed.peek()).toBeNull(); + second.close(); + } finally { + first.close(); + broadcast.close(); + } +}); + +test("a name returning with a different identity takes a fresh track name", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("returning.hang") }); + let audio = broadcast.audio("audio"); + audio.config.set(audioConfig); + await settle(); + expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toBeUndefined(); + + // The same identity returns to its track. + audio.close(); + await settle(); + audio = broadcast.audio("audio"); + audio.config.set(audioConfig); + await settle(); + expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toBeUndefined(); + + audio.close(); + await settle(); + audio = broadcast.audio("audio"); + audio.config.set({ ...audioConfig, numberOfChannels: Catalog.u53(1) }); + await settle(); + expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toStartWith("audio/"); + + broadcast.close(); +}); + +// Nobody has seen an unserved catalog, so its renditions settle without minting names. +test("identity changes before the first announce keep the catalog ID as the track name", async () => { + const announce = new Signal(false); + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("unserved.hang"), announce }); const video = broadcast.video("video"); video.config.set(videoConfig); await settle(); - const before = broadcast.net.peek(); - if (!before) throw new Error("expected an initial broadcast"); - const oldCatalog = new Json.Snapshot.Consumer({ - track: before.track(Broadcast.CATALOG_TRACK).subscribe(), + video.config.set({ ...videoConfig, codec: "vp8" }); + await settle(); + + announce.set(true); + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected a network producer once connected"); + const catalog = new Json.Snapshot.Consumer({ + track: net.track(Broadcast.CATALOG_TRACK).subscribe(), }); - expect((await oldCatalog.latest())?.value.video?.renditions.video.codec).toBe(videoConfig.codec); - video.config.set({ ...videoConfig, codec: "vp09.00.10.08" }); - await settle(12); - const after = broadcast.net.peek(); - expect(after).toBeDefined(); - expect(after).not.toBe(before); - expect(before.closed.peek()).toBeNull(); - expect(await oldCatalog.latest()).toBeUndefined(); - if (!after) throw new Error("expected a replacement broadcast"); - const next = new Json.Snapshot.Consumer({ track: after.track(Broadcast.CATALOG_TRACK).subscribe() }); - expect((await next.latest())?.value.video?.renditions.video.codec).toBe("vp09.00.10.08"); + const config = (await catalog.latest())?.value.video?.renditions.video; + expect(config?.codec).toBe("vp8"); + expect(config?.track).toBeUndefined(); + + // The first snapshot fixed the name's identity. + video.config.set(videoConfig); + await settle(); + expect((await catalog.latest())?.value.video?.renditions.video?.track).toStartWith("video/"); + broadcast.close(); }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 082bf24baf1..73e3dcf24c8 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -1,9 +1,10 @@ import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; +import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { CatalogProducer } from "./catalog"; -import { catalogs, errors } from "./internal"; +import { errors } from "./internal"; import { Baseline } from "./jitter"; import { type Kind, Rendition } from "./rendition"; @@ -67,7 +68,7 @@ export class Broadcast { // root sections (e.g. `scte35`) by locking it too. readonly catalog = new CatalogProducer(); - // The underlying network broadcast, recreated when its name, enabled state, maxAge, or a served media identity changes, + // The underlying network broadcast, recreated when the name, enabled state, or maxAge changes, // and is `undefined` in between. It lives in the origin rather than any session, so it spans // reconnects. Exposed so an application can serve its own tracks alongside the built-in // catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section. @@ -91,12 +92,20 @@ export class Broadcast { // The kind of each rendition track in the current network broadcast. A new one starts over. #kinds = new Map(); - // Replacement tracks keep their catalog IDs but never reuse a closed track name. + // Each catalog ID's track name in the current network broadcast, when it is not the ID itself. + // A replacement for a closed track, or a rendition whose media identity changed, keeps its + // catalog ID under a fresh track name, so a name always means the same content. readonly #names = new Signal>({}); + // The media identity of each track name the current network broadcast's catalog has served, or + // undefined before it serves one. Kept across a rendition's removal, so a name returning with + // different media takes a fresh name too. + #identities: Map | undefined; + + // The identity of each track name in the latest catalog fold, recorded when the catalog is first served. + #listed = new Map(); + #signals = new Effect(); - #restart = new Signal(0); - #catalogReady = new Signal(undefined); constructor(props?: Inputs) { this.in = { @@ -177,23 +186,26 @@ export class Broadcast { const enabled = effect.get(this.in.enabled); const renditions = effect.get(this.#renditions); const names = effect.get(this.#names); - const epoch = effect.get(this.net); const video: Record = {}; const audio: Record = {}; const text: Record = {}; + const listed = new Map(); + const renamed: Record = {}; for (const rendition of Object.values(renditions)) { - const encoder = catalogs.get(rendition); - const state = encoder && effect.get(encoder); - if (enabled && encoder && (!state || state.epoch !== epoch)) { - this.#catalogReady.set(undefined); - return; - } - const source = enabled ? (encoder ? state?.config : effect.get(rendition.config)) : undefined; + const source = enabled ? effect.get(rendition.config) : undefined; if (source === undefined) continue; - const track = names[rendition.name]; - const config = track ? { ...(source as object), track } : source; + let track = names[rendition.name] ?? rendition.name; + const media = identity(source); + const served = this.#identities?.get(track); + if (served !== undefined && !Json.deepEqual(served, media)) { + track = `${rendition.name}/${Moq.Epoch.mint()}`; + renamed[rendition.name] = track; + } + this.#identities?.set(track, media); + listed.set(track, media); + const config = track !== rendition.name ? { ...(source as object), track } : source; if (rendition.kind === "video") { video[rendition.name] = config as Catalog.VideoConfig; @@ -232,11 +244,13 @@ export class Broadcast { delete catalog.text; } }); - this.#catalogReady.set(epoch); + this.#listed = listed; + + // The network loop ends each renamed rendition's old track and serves the new name. + if (Object.keys(renamed).length > 0) this.#names.update((names) => ({ ...names, ...renamed })); } #run(effect: Effect) { - effect.get(this.#restart); const values = effect.getAll([this.in.enabled, this.in.origin]); if (!values) return; const [_enabled, origin] = values; @@ -269,6 +283,7 @@ export class Broadcast { // again finds its track; the catalog's stay out of reach of a colliding rendition. const tracks = new Map }>(); this.#names.set({}); + this.#identities = undefined; const kinds = new Map(); this.#kinds = kinds; effect.cleanup(() => { @@ -278,7 +293,7 @@ export class Broadcast { let serving = false; effect.run((inner) => { - if (!inner.get(this.in.announce) || inner.get(this.#catalogReady) !== broadcast) { + if (!inner.get(this.in.announce)) { broadcast.unannounce(); return; } @@ -288,6 +303,8 @@ export class Broadcast { // is whatever the catalog holds at announce time. if (!serving) { serving = true; + // From here a name keeps the identity this first snapshot gives it. + this.#identities = new Map(this.#listed); for (const [name, compression] of [ [Broadcast.CATALOG_TRACK, false], [Broadcast.CATALOG_TRACK_COMPRESSED, true], @@ -303,9 +320,6 @@ export class Broadcast { }); effect.cleanup(() => track.close()); this.catalog.serve(track, effect, { compression }); - effect.run((health) => { - if (health.get(track.closed) !== undefined) this.#restart.update((value) => value + 1); - }); } } @@ -316,6 +330,7 @@ export class Broadcast { // so capture and encoding still stop when the final subscriber leaves. effect.run((inner) => { const renditions = inner.get(this.#renditions); + const names = inner.get(this.#names); for (const rendition of Object.values(renditions)) { const signal = this.#tracks.get(rendition.name); @@ -324,10 +339,22 @@ export class Broadcast { let entry = tracks.get(rendition.name); const cause = entry && errors.get(entry.owner)?.peek(); if (entry && cause) entry.track.close(cause); - // An error leaves this rendition down. Only a replacement registration may - // recover, with a fresh track name so its group sequence can start over. - if (!entry || (entry.owner !== rendition && entry.track.closed.peek() !== undefined)) { - const name = entry ? `${rendition.name}/${Moq.Epoch.mint()}` : rendition.name; + const name = names[rendition.name] ?? rendition.name; + if (entry && entry.track.name !== name) { + // The catalog moved this rendition to a new name for new media. End the old track + // cleanly on its last identity, so its subscribers follow the catalog. + entry.track.close(); + entry = undefined; + } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { + // An error leaves this rendition down. Only a replacement registration may recover, + // with a fresh track name so its group sequence can start over. + this.#names.update((names) => ({ + ...names, + [rendition.name]: `${rendition.name}/${Moq.Epoch.mint()}`, + })); + continue; + } + if (!entry) { const track = broadcast.createTrack( name, Container.trackInfo({ maxAge, priority: Catalog.PRIORITY[rendition.kind] }), @@ -335,7 +362,6 @@ export class Broadcast { entry = { track, owner: rendition }; tracks.set(rendition.name, entry); kinds.set(rendition.name, rendition.kind); - if (name !== rendition.name) this.#names.update((names) => ({ ...names, [rendition.name]: name })); } else { entry.owner = rendition; } @@ -358,3 +384,15 @@ export class Broadcast { this.#signals.close(); } } + +// The fields a consumer may configure its decoder from once per track name, plus the coded-size +// ceilings: changing any of them takes a new track name. +type Identity = Pick< + Partial, + "codec" | "description" | "container" | "sampleRate" | "numberOfChannels" | "codedWidth" | "codedHeight" +>; + +function identity(config: unknown): Identity { + const { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight } = config as Identity; + return { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }; +} diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index e13607afcd9..8b092040665 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -239,6 +239,37 @@ for (const field of ["jitter", "delay"] as const) { } } +// Maxima only rise for a track name, but a rendition moved to a fresh track name starts over. +for (const field of ["bitrate", "framerate"] as const) { + test(`catalog refuses a falling ${field} per track name`, () => { + const catalog = new CatalogProducer(); + const media = (value: Catalog.Root) => { + const config = value.video?.renditions.media; + if (!config) throw new Error("expected a retained video rendition"); + return config; + }; + catalog.mutate((value) => { + value.video = { renditions: { media: { codec: "vp8", container: { kind: "legacy" }, [field]: 100 } } }; + }); + catalog.mutate((value) => { + media(value)[field] = Catalog.u53(200); + }); + for (const maximum of [Catalog.u53(100), undefined]) { + expect(() => + catalog.mutate((value) => { + media(value)[field] = maximum; + }), + ).toThrow(`${field} cannot decrease`); + } + catalog.mutate((value) => { + Object.assign(media(value), { track: "media/2", [field]: 50 }); + }); + catalog.mutate((value) => { + expect(media(value)[field]).toBe(Catalog.u53(50)); + }); + }); +} + for (const section of ["json", "binary"] as const) { test(`catalog refuses zero or decreasing ${section} jitter without retaining it`, () => { const catalog = new CatalogProducer(); @@ -328,7 +359,7 @@ describe("estimate publication window", () => { if (!catalog.video) throw new Error("video missing"); catalog.video.renditions.v.jitter = Catalog.u53(jitter); }); - return { producer, effect, served: track, track: subscriber, consumer, estimate }; + return { producer, effect, track: subscriber, consumer, estimate }; } test("estimate rises publish at the leading edge and coalesce to the latest trailing value", async () => { @@ -385,17 +416,6 @@ describe("estimate publication window", () => { expect(track.latest()).toBe(removed); }); - test("ending the last served track cancels a trailing estimate", async () => { - const { served, consumer, estimate } = fixture(); - await consumer.latest(); - estimate(1); - await consumer.latest(); - estimate(2); - served.close(); - expect(() => advance(1000)).not.toThrow(); - expect(timers).toHaveLength(0); - }); - test("closing the last output cancels its trailing timer", async () => { const { effect, consumer, estimate } = fixture(); await consumer.latest(); @@ -445,135 +465,3 @@ describe("estimate publication window", () => { expect((await consumer.latest())?.value.video?.renditions.v.jitter).toBe(Catalog.u53(3)); }); }); - -// A served catalog's old track must never carry a different decoder identity. -test("a served identity edit ends the old catalog before advertising the replacement", async () => { - const catalog = new CatalogProducer(); - catalog.mutate((value) => { - value.video = { renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" } } } }; - }); - const effect = new Effect(); - const track = new Track.Producer("catalog.json"); - catalog.serve(track, effect); - const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); - try { - expect((await consumer.latest())?.value.video?.renditions.video.codec).toBe("avc1.640028"); - catalog.mutate((value) => { - if (value.video) value.video.renditions.video.codec = "avc1.640033"; - }); - expect(track.closed.peek()).toBeNull(); - expect(await consumer.latest()).toBeUndefined(); - const next = new Track.Producer("catalog.json"); - catalog.serve(next, effect); - const replacement = new Json.Snapshot.Consumer({ track: next.subscribe() }); - expect((await replacement.latest())?.value.video?.renditions.video.codec).toBe("avc1.640033"); - } finally { - effect.close(); - } -}); - -test("a removed rendition name keeps its served identity", async () => { - const catalog = new CatalogProducer(); - const initial: Catalog.VideoConfig = { codec: "avc1.640028", container: { kind: "legacy" } }; - catalog.mutate((value) => { - value.video = { renditions: { video: initial } }; - }); - const effect = new Effect(); - const track = new Track.Producer("catalog.json"); - catalog.serve(track, effect); - try { - catalog.mutate((value) => { - delete value.video; - }); - catalog.mutate((value) => { - value.video = { renditions: { video: initial } }; - }); - expect(track.closed.peek()).toBeUndefined(); - catalog.mutate((value) => { - delete value.video; - }); - catalog.mutate((value) => { - value.video = { renditions: { video: { ...initial, codec: "vp09.00.10.08" } } }; - }); - expect(track.closed.peek()).toBeNull(); - } finally { - effect.close(); - } -}); - -for (const field of ["bitrate", "framerate"] as const) { - test(`served ${field} rises in place and refuses a decrease`, () => { - const catalog = new CatalogProducer(); - catalog.mutate((value) => { - value.video = { - renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" }, [field]: 100 } }, - }; - }); - const effect = new Effect(); - const track = new Track.Producer("catalog.json"); - catalog.serve(track, effect); - try { - catalog.mutate((value) => { - if (value.video) value.video.renditions.video[field] = Catalog.u53(200); - }); - expect(track.closed.peek()).toBeUndefined(); - expect(() => - catalog.mutate((value) => { - if (value.video) value.video.renditions.video[field] = Catalog.u53(100); - }), - ).toThrow(`${field} cannot decrease`); - expect(track.closed.peek()).toBeUndefined(); - } finally { - effect.close(); - } - }); -} - -test("an unknown ceiling fills once before any later change ends both catalog encodings", () => { - const catalog = new CatalogProducer(); - catalog.mutate((value) => { - value.video = { renditions: { video: { codec: "avc1.640028", container: { kind: "legacy" } } } }; - }); - const effect = new Effect(); - const plain = new Track.Producer("catalog.json"); - const compressed = new Track.Producer("catalog.json.z"); - catalog.serve(plain, effect); - catalog.serve(compressed, effect, { compression: true }); - try { - catalog.mutate((value) => { - if (value.video) value.video.renditions.video.codedWidth = Catalog.u53(1280); - }); - expect(plain.closed.peek()).toBeUndefined(); - expect(compressed.closed.peek()).toBeUndefined(); - catalog.mutate((value) => { - if (value.video) value.video.renditions.video.codedWidth = Catalog.u53(1920); - }); - expect(plain.closed.peek()).toBeNull(); - expect(compressed.closed.peek()).toBeNull(); - } finally { - effect.close(); - } -}); - -test("the last served track ending releases the next epoch's maxima", () => { - const catalog = new CatalogProducer(); - catalog.mutate((root) => { - root.video = { - renditions: { - video: { codec: "vp8", container: { kind: "legacy" }, framerate: 60, bitrate: Catalog.u53(1000) }, - }, - }; - }); - const scope = new Effect(); - const track = new Track.Producer("catalog.json"); - catalog.serve(track, scope); - track.close(); - expect(() => - catalog.mutate((root) => { - if (!root.video) throw new Error("missing video"); - root.video.renditions.video.framerate = 15; - root.video.renditions.video.bitrate = Catalog.u53(100); - }), - ).not.toThrow(); - scope.close(); -}); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index ae48a688c17..604497adbaf 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -21,53 +21,35 @@ import { Effect } from "@moq/signals"; */ export class CatalogProducer { #value: Catalog.Root = pageRoot(); - #outputs = new Map, Moq.Track.Producer>(); - #identities = new Map>(); - #served = false; - #fresh = false; + #outputs = new Set>(); #lastEstimate: number | undefined; #pending: Effect | undefined; /** Edit the catalog; estimate-only changes publish at most once a second with a trailing update. */ mutate(fn: (catalog: Catalog.Root) => void): void { - this.#prune(); const value = structuredClone(this.#value); fn(value); // Every consumer refuses a catalog that fails this, so refuse the edit before it reaches one. Catalog.checkRenditions(value); - const restart = this.#changedIdentity(value); + // Estimates and maxima only rise for a track name; a renamed rendition starts its new track over. for (const field of ["jitter", "delay", "bitrate", "framerate"] as const) { const previous = advertised(this.#value, field); for (const [section, next] of Object.entries(advertised(value, field))) { for (const [name, estimate] of Object.entries(next)) { - if ((field === "jitter" || field === "delay") && estimate === 0) + if (estimate === 0 && (field === "jitter" || field === "delay")) { throw new Error(`omit ${field} rather than advertising 0`); - const before = previous[section]?.[name] ?? this.#identities.get(section)?.get(name)?.[field]; - if ( - !restart && - !this.#fresh && - before !== undefined && - (estimate === undefined || estimate < before) - ) { + } + const before = previous[section]?.[name]; + if (before !== undefined && (estimate === undefined || estimate < before)) { throw new Error(`${field} cannot decrease for an existing track`); } } } } - if (restart) { - // A catalog track can end, but it can never advertise a new decoder identity. - for (const [output, track] of this.#outputs) { - output.finish(); - track.close(); - } - this.#outputs.clear(); - this.#reset(); - } const estimateOnly = Json.deepEqual(structure(this.#value), structure(value)); const changed = !Json.deepEqual(this.#value, value); this.#value = value; if (!changed || this.#outputs.size === 0) return; - this.#remember(); const now = performance.now(); if (!estimateOnly || this.#lastEstimate === undefined || now >= this.#lastEstimate + 1000) { @@ -79,12 +61,11 @@ export class CatalogProducer { } #publish(estimate: boolean): void { - this.#prune(); this.#pending?.close(); this.#pending = undefined; if (estimate) this.#lastEstimate = performance.now(); const at = Time.Timestamp.now(); - for (const output of this.#outputs.keys()) output.update({ value: this.#value, at }); + for (const output of this.#outputs) output.update({ value: this.#value, at }); } /** @@ -94,89 +75,26 @@ export class CatalogProducer { * served both plaintext and compressed (e.g. `catalog.json` and `catalog.json.z`). */ serve(track: Moq.Track.Producer, effect: Effect, opts?: { compression?: boolean }): void { - this.#prune(); const output = new Json.Snapshot.Producer({ track, compression: opts?.compression ? "deflate" : "none", deltaRatio: 0, }); - this.#served = true; - this.#fresh = false; - this.#remember(); output.update({ value: this.#value, at: Time.Timestamp.now() }); - this.#outputs.set(output, track); + this.#outputs.add(output); effect.cleanup(() => { this.#outputs.delete(output); output.finish(); - if (this.#outputs.size === 0) this.#reset(); - }); - } - #changedIdentity(value: Catalog.Root): boolean { - for (const [section, tracks] of Object.entries(renditions(value))) { - for (const [name, next] of Object.entries(tracks ?? {})) { - const before = this.#identities.get(section)?.get(name); - if (!before) continue; - for (const field of ["codec", "description", "container", "sampleRate", "numberOfChannels"] as const) { - if (!Json.deepEqual(before[field], next[field])) return true; - } - for (const field of ["codedWidth", "codedHeight"] as const) { - if (before[field] !== undefined && before[field] !== next[field]) return true; - } + if (this.#outputs.size === 0) { + this.#pending?.close(); + this.#pending = undefined; + this.#lastEstimate = undefined; } - } - return false; - } - - #remember(): void { - for (const [section, tracks] of Object.entries(renditions(this.#value))) { - let identities = this.#identities.get(section); - if (!identities) { - identities = new Map(); - this.#identities.set(section, identities); - } - for (const [name, config] of Object.entries(tracks ?? {})) identities.set(name, structuredClone(config)); - } - } - - #prune(): void { - for (const [output, track] of this.#outputs) { - if (track.closed.peek() === undefined) continue; - this.#outputs.delete(output); - output.finish(); - } - if (this.#outputs.size === 0) this.#reset(); - } - - #reset(): void { - if (this.#served) this.#fresh = true; - this.#served = false; - this.#identities.clear(); - this.#pending?.close(); - this.#pending = undefined; - this.#lastEstimate = undefined; + }); } } -// Identity and advertised bounds shared by the media rendition schemas. -type Config = { - codec: string; - description?: string; - container: unknown; - sampleRate?: number; - numberOfChannels?: number; - codedWidth?: number; - codedHeight?: number; - bitrate?: number; - framerate?: number; - jitter?: number; - delay?: number; -}; - -function renditions(value: Catalog.Root): Record | undefined> { - return { audio: value.audio?.renditions, video: value.video?.renditions }; -} - // Strip only the schema's estimate fields, so config edits and arbitrary extension sections remain // immediate even when an extension happens to contain a property named jitter or delay. function structure(catalog: Catalog.Root): Catalog.Root { @@ -202,8 +120,13 @@ function advertised( field: "jitter" | "delay" | "bitrate" | "framerate", ): Record> { const pick = ( - tracks: Record | undefined, - ) => Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config[field]])); + tracks: + | Record + | undefined, + ) => + Object.fromEntries( + Object.entries(tracks ?? {}).map(([id, config]) => [Catalog.track(id, config), config[field]]), + ); return { audio: pick(catalog.audio?.renditions), video: pick(catalog.video?.renditions), diff --git a/js/publish/src/internal.ts b/js/publish/src/internal.ts index 29ec97254a9..0261eb09264 100644 --- a/js/publish/src/internal.ts +++ b/js/publish/src/internal.ts @@ -1,10 +1,4 @@ -import type * as Moq from "@moq/net"; import type { Getter } from "@moq/signals"; /** @internal Terminal failures of broadcast-owned rendition tracks, including idle tracks. */ export const errors = new WeakMap>(); - -/** @internal Pair an encoder config with the network epoch whose bounds it describes. */ -export type CatalogState = { epoch: Moq.Broadcast.Producer | undefined; config: unknown }; -/** @internal Encoder catalog states keyed by their registered rendition. */ -export const catalogs = new WeakMap>(); diff --git a/js/publish/src/rendition.ts b/js/publish/src/rendition.ts index d8ffc262ad4..121997e782d 100644 --- a/js/publish/src/rendition.ts +++ b/js/publish/src/rendition.ts @@ -19,13 +19,16 @@ export class Rendition { /** * The catalog entry for this rendition, written by its producer (usually an encoder). - * `undefined` omits it from the catalog (e.g. while disabled). + * `undefined` omits it from the catalog (e.g. while disabled). Changing its codec, description, + * container, sample rate, channel count, or coded size ends the current track and serves the + * rendition under a fresh catalog `track` name, so a name always means the same content. */ readonly config = new Signal(undefined); /** * The live track producer while a subscriber is attached, `undefined` otherwise. - * Producers should encode only while this is set (the demand gate). + * Producers should encode only while this is set (the demand gate), and only media matching + * the current {@link config}: a new identity swaps in a new track. */ readonly track: Getter; diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index e10bc69cfcb..e23ae9301a5 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -232,11 +232,7 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi const rendition = { config: new Signal(undefined), track: live, close: () => {} }; const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture, }); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index dc568157fcc..e5784d44126 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -106,11 +106,7 @@ test("encoding tracks encoder config in its child effect", async () => { track: new Signal(track), close: () => track.close(), }; - const broadcast = { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - }; + const broadcast = { video: () => rendition, baseline: new Baseline() }; const capture = { in: { source: new Signal(undefined) }, out: { @@ -157,11 +153,7 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op }; const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); @@ -218,11 +210,7 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -303,11 +291,7 @@ test("every published config fits a probe for its own codec", async () => { const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -628,11 +612,7 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const enabled = new Signal(true); const encoder = new Encoder("video", { enabled, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); @@ -792,11 +772,7 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const bandwidth = new Moq.Bandwidth.Allocator(estimate); const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -952,7 +928,7 @@ test("cut forces a keyframe, coalescing requests and spacing them at least 500ms }; const encoder = new Encoder("video", { enabled: true, - broadcast: { video: () => rendition, net: new Signal(new Moq.Broadcast.Producer()) } as never, + broadcast: { video: () => rendition } as never, capture: capture as never, }); @@ -1179,11 +1155,7 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( }; const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, }); const send = async (millis: number) => { @@ -1253,11 +1225,7 @@ test("the catalog bitrate and frame rate only rise", async () => { const bandwidth = new Moq.Bandwidth.Allocator(estimate); const encoder = new Encoder("video", { enabled: true, - broadcast: { - video: () => rendition, - baseline: new Baseline(), - net: new Signal(new Moq.Broadcast.Producer()), - } as never, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, capture: capture as never, bandwidth, }); @@ -1302,7 +1270,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { track: new Signal(undefined), close: () => {}, }); - const first = { video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }; + const first = { video: rendition, baseline: new Baseline() }; const broadcast = new Signal(first); const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -1324,7 +1292,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 1920, codedHeight: 1072, framerate: 60 }); const bitrate = encoder.out.catalog.peek()?.bitrate ?? 0; - broadcast.set({ video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }); + broadcast.set({ video: rendition, baseline: new Baseline() }); // The previous broadcast's probe never describes the new track, even while its own runs. for (let i = 0; i < 5; i++) await Promise.resolve(); expect(encoder.out.catalog.peek()).toBeUndefined(); @@ -1347,7 +1315,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { enabled.set(false); await settle(); expect(encoder.out.catalog.peek()?.enabled).toBe(false); - broadcast.set({ video: rendition, baseline: new Baseline(), net: new Signal(new Moq.Broadcast.Producer()) }); + broadcast.set({ video: rendition, baseline: new Baseline() }); await settle(); expect(encoder.out.catalog.peek()).toBeUndefined(); } finally { @@ -1512,75 +1480,48 @@ test("a refused first ceiling recovers at a smaller size", async () => { } }); -test("ceiling growth replaces the network epoch and resets its maxima", async () => { +// Outgrowing the advertised ceiling is a new identity, so the broadcast serves the larger ceiling under +// a fresh track name while the epoch and the maxima carry on. A shrink stays in band on the old name. +test("ceiling growth moves the rendition to a fresh track name", async () => { using _videoEncoder = installFakeVideoEncoder(); using _probe = spyOn(FakeVideoEncoder, "isConfigSupported").mockResolvedValue({ supported: true }); using _now = spyOn(performance, "now").mockReturnValue(0); - const origin = new Moq.Origin.Producer(); - const announcements = origin.announced(); - const broadcast = new Broadcast({ origin, name: Moq.Path.from("ceiling.hang") }); + const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("ceiling.hang") }); const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, out: { display: new Signal({ width: 640, height: 480 }), frames: new Signal(undefined) }, }; const encoder = new Encoder("video", { broadcast, capture: capture as never, config: { frameRate: 60 } }); - const subscribers: Moq.Track.Subscriber[] = []; - const catalog = (net: Moq.Broadcast.Producer) => { - const subscriber = net.track(Broadcast.CATALOG_TRACK).subscribe(); - subscribers.push(subscriber); - return new Json.Snapshot.Consumer({ track: subscriber }); + const net = broadcast.net.peek() ?? (await broadcast.net.changed()); + if (!net) throw new Error("expected a network broadcast"); + const subscriber = net.track(Broadcast.CATALOG_TRACK).subscribe(); + const catalog = new Json.Snapshot.Consumer({ track: subscriber }); + const next = async (done: (config: Catalog.VideoConfig) => boolean) => { + for (;;) { + const config = (await catalog.latest())?.value.video?.renditions.video; + if (config && done(config)) return config; + } }; try { - await encoder.out.catalog.changed(); - const first = broadcast.net.peek(); - if (!first) throw new Error("expected the first epoch"); - await announcements.next(); - const before = catalog(first); - let initial = await before.latest(); - while (!initial?.value.video?.renditions.video) initial = await before.latest(); - expect(initial.value.video.renditions.video).toMatchObject({ - codedWidth: 640, - codedHeight: 480, - framerate: 60, - }); - const epoch = first.route?.epoch; - const bitrate = initial.value.video.renditions.video.bitrate; + const initial = await next(() => true); + expect(initial).toMatchObject({ codedWidth: 640, codedHeight: 480, framerate: 60 }); + expect(initial.track).toBeUndefined(); + capture.out.display.set({ width: 320, height: 240 }); encoder.config.set({ frameRate: 15 }); let live = encoder.out.resolved.peek(); while (live?.width !== 320 || live.framerate !== 15) live = await encoder.out.resolved.changed(); - expect(broadcast.net.peek()).toBe(first); - expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 640, codedHeight: 480, framerate: 60, bitrate }); + expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 640, codedHeight: 480, framerate: 60 }); - const restarting = broadcast.net.changed(); capture.out.display.set({ width: 1280, height: 720 }); - const next = await restarting; - if (!next || next === first) throw new Error("expected a new epoch"); - let announcement = await announcements.next(); - while (announcement?.kind === "end" || announcement?.route.epoch === epoch) - announcement = await announcements.next(); - const after = catalog(next); - let snapshot = await after.latest(); - while (!snapshot?.value.video?.renditions.video) snapshot = await after.latest(); - expect(snapshot.value.video.renditions.video).toMatchObject({ - codedWidth: 1280, - codedHeight: 720, - framerate: 15, - }); - expect(next.route?.epoch).not.toBe(epoch); - expect(first.closed.peek()).toBeNull(); - for (let old = await before.latest(); old; old = await before.latest()) { - expect(old.value.video?.renditions.video).toMatchObject({ - codedWidth: 640, - codedHeight: 480, - framerate: 60, - }); - } + const grown = await next((config) => config.codedWidth === 1280); + expect(grown).toMatchObject({ codedHeight: 720, framerate: 60 }); + expect(grown.track).toStartWith("video/"); + expect(grown.bitrate ?? 0).toBeGreaterThanOrEqual(initial.bitrate ?? 0); + expect(broadcast.net.peek()).toBe(net); } finally { - for (const subscriber of subscribers) subscriber.close(); + subscriber.close(); encoder.close(); broadcast.close(); - announcements.close(); - origin.close(); } }); diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 93a6493cb73..a4de00a4c4b 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -14,7 +14,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { type CatalogState, catalogs, errors } from "../internal"; +import { errors } from "../internal"; import { type Baseline, Estimator } from "../jitter"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; @@ -111,7 +111,8 @@ type EncoderOutput = { * attached (the demand gate). Rename by constructing a new encoder; the name is not a signal. * * The catalog advertises the largest size encoded so far as the coded-size ceiling, so a capture that - * shrinks resizes in band at a keyframe. Outgrowing the ceiling re-probes and publishes a new broadcast epoch. + * shrinks resizes in band at a keyframe. Outgrowing the ceiling re-probes and advertises a larger one, + * which the broadcast serves under a new track name. */ export class Encoder { /** The full track name of this rendition, e.g. `"video/hd"`. */ @@ -147,7 +148,7 @@ export class Encoder { // The coded-size ceiling to probe for the current broadcast: each side's largest of its accepted // ceiling and the current size. A smaller capture resizes in band below it without changing what // the catalog advertises. - #coded = new Signal<{ epoch?: Moq.Broadcast.Producer; width: number; height: number } | undefined>(undefined); + #coded = new Signal<{ broadcast?: Broadcast; width: number; height: number } | undefined>(undefined); // The config the browser accepted and encoded a probe frame with, and the codec string it reported. // Kept through a re-probe, so the rendition stays in the catalog until the new probe replaces it. @@ -192,9 +193,9 @@ export class Encoder { #estimator = new Estimator(); // The estimator's jitter and delay, republished whenever either rises. #estimate = new Signal({}); - // A network epoch owns its ceiling, maxima, and last config. A restart of the same - // Broadcast component gets a fresh entry; local previews use the encoder. - #catalog = new Signal(undefined); + // What this rendition advertised on each broadcast, keyed by the broadcast or, for a local preview, + // the encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one + // continues its ceiling, maxima, and last config, while another broadcast starts its own. #advertised = new WeakMap(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens @@ -258,8 +259,8 @@ export class Encoder { } // What this rendition advertised on `broadcast`'s track. - #track(epoch: Moq.Broadcast.Producer | undefined): Advertised { - const key = epoch ?? this; + #track(broadcast: Broadcast | undefined): Advertised { + const key = broadcast ?? this; let track = this.#advertised.get(key); if (!track) { track = { bitrate: 0, framerate: 0 }; @@ -318,7 +319,6 @@ export class Encoder { const rendition = broadcast.video(this.name); errors.set(rendition, failure); - catalogs.set(rendition, this.#catalog); effect.cleanup(() => rendition.close()); // Publish the resolved catalog config; undefined (until one resolves) leaves it out of the catalog. @@ -534,17 +534,12 @@ export class Encoder { #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const broadcast = effect.get(this.in.broadcast); - const epoch = broadcast ? effect.get(broadcast.net) : undefined; - const track = this.#track(epoch); + const track = this.#track(effect.get(this.in.broadcast)); if (!enabled) this.#paused = true; - const candidate = enabled ? effect.get(this.#live) : undefined; - const live = candidate?.detected.epoch === epoch ? candidate : undefined; + const live = enabled ? effect.get(this.#live) : undefined; if (!live) { const last = this.#paused ? track.last : undefined; - const catalog = last && { ...last, ...estimate, enabled: false }; - effect.set(this.#out.catalog, catalog); - effect.set(this.#catalog, { epoch, config: catalog }); + effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); return; } this.#paused = false; @@ -567,7 +562,6 @@ export class Encoder { track.last = catalog; effect.set(this.#out.catalog, catalog); - effect.set(this.#catalog, { epoch, config: catalog }); } // Probe the hardware for the best codec at the coded-size ceiling. Deliberately depends on as little @@ -587,7 +581,7 @@ export class Encoder { // Captured now: the run's signal is replaced once a rerun starts. const superseded = effect.abort; effect.spawn(async () => { - const track = this.#track(dimensions.epoch); + const track = this.#track(dimensions.broadcast); try { let detected: Detected; try { @@ -616,7 +610,7 @@ export class Encoder { // Only an accepted probe commits a ceiling, so a size the hardware refuses never sticks. const { width, height } = detected.config; track.ceiling = { width, height }; - this.#codec.set({ ...detected, epoch: dimensions.epoch }); + this.#codec.set({ ...detected, broadcast: dimensions.broadcast }); } catch (err) { // A newer probe reports its own outcome. if (superseded.aborted) return; @@ -637,11 +631,10 @@ export class Encoder { // A probe for another broadcast describes another track, so wait for this one's. const broadcast = effect.get(this.in.broadcast); - const epoch = broadcast ? effect.get(broadcast.net) : undefined; const detected = effect.get(this.#codec); const dimensions = effect.get(this.#dimensions); const target = effect.get(this.#target); - if (!detected || detected.epoch !== epoch || !dimensions || !target) return; + if (!detected || detected.broadcast !== broadcast || !dimensions || !target) return; // A capture that outgrew the probe encodes scaled down to fit until the re-probe at the new // ceiling lands, so the live config never exceeds what the catalog advertises. @@ -711,11 +704,10 @@ export class Encoder { if (!dimensions) return; const broadcast = effect.get(this.in.broadcast); - const epoch = broadcast ? effect.get(broadcast.net) : undefined; effect.get(this.#codec); - const ceiling = this.#track(epoch).ceiling; + const ceiling = this.#track(broadcast).ceiling; effect.set(this.#coded, { - epoch, + broadcast, width: Math.max(ceiling?.width ?? 0, dimensions.width), height: Math.max(ceiling?.height ?? 0, dimensions.height), }); @@ -851,8 +843,8 @@ type Detected = { config: VideoEncoderConfig & { framerate: number; bitrate: number }; // The codec string the encoder reported for it, which the catalog advertises. reported: string; - // The network epoch the probe ran for; another one starts its own ceiling. - epoch?: Moq.Broadcast.Producer; + // The broadcast the probe ran for; another one starts its own ceiling. + broadcast?: Broadcast; }; // What a rendition advertised on one broadcast's track. diff --git a/quest/m1/README.md b/quest/m1/README.md index 31f1b2e3c56..3cbf37068a5 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -100,7 +100,7 @@ above them. - [JSON window timestamps](/quest/m1/json-window-timestamps.md) - Rust and JS window consumers return each event's frame timestamp like the snapshot and stream consumers - [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native FILL_TIMEOUT bounds upstream waiting on drafts 18-22, alongside local content-age enforcement -- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher resizes in band below its ceiling or mints a new rendition or epoch +- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher resizes in band below its ceiling or mints a new rendition name - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the catalog entry and format may break in place, since no archives exist - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [IETF epochs](/quest/m1/ietf-epochs.md) - negotiate explicit broadcast identity on drafts 17-22, retaining unchanged paths and epochless clients on updated relays diff --git a/quest/m1/catalog-identity-rs.md b/quest/m1/catalog-identity-rs.md index 4d0543f31c8..cdee5a24d9c 100644 --- a/quest/m1/catalog-identity-rs.md +++ b/quest/m1/catalog-identity-rs.md @@ -5,7 +5,7 @@ No Rust publisher redefines a track it has published. A change to a rendition's identity (`codec`, `description`, `container`, `sample_rate`, `channel_count`) or growth past its `coded_width`/`coded_height` ceiling -publishes a new rendition name or a new broadcast epoch, per the hang draft's +publishes a new rendition name, per the hang draft's [Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) section, and `bitrate`/`framerate` never fall. A replayed archive catalog then describes every group of the tracks it lists. @@ -15,14 +15,15 @@ every group of the tracks it lists. - ✅ Decided 2026-10-13 (maintainer, #5459): split from the catalog identity quest. The contract, docs, and the `js/publish` ceiling landed there; `@moq/publish` enforces the browser side. -- ✅ Decided 2026-10-13 (maintainer, #5459): JS enforces identity by - republishing under a new epoch with the Opus description probed up front, - and the `js/publish` video ceiling keeps growing per side. - -Open: whether each Rust violator mints a new rendition name (as FLV, HLS -import, and transcode rungs already do) or restarts under a new epoch (as JS -does). Recommended: mint a name where the catalog can keep both, and take a -new epoch only for a whole-broadcast restart. +- ✅ Decided 2026-10-13 (maintainer, #5459): the Opus description is probed + up front, and the `js/publish` video ceiling keeps growing per side. +- ✅ Decided 2026-10-15 (maintainer): mint a new rendition name, not a new + epoch; reverses the #5459 decision. Restarting the whole broadcast is an + extreme response to one rendition changing, and a name always means the + same content, so changed content gets a new name. `@moq/publish` keeps the + catalog ID and points its `track` at a fresh name (#5552); each Rust + violator mints a name likewise, as FLV, HLS import, and transcode rungs + already do. A new epoch stays reserved for a whole-broadcast restart. Guard `moq-mux` `Rendition::set`, `replace`, and `stage` (`rs/moq-mux/src/catalog/tracks.rs`): refuse a change to identity or a set @@ -62,4 +63,3 @@ of buffer when no `jitter` is advertised. ## Related - [Archive](/quest/m1/archive/README.md) - storage and replay consume the identity contract -- [Broadcast epochs](/quest/m0/broadcast-epoch/README.md) - a restart is a new epoch rather than a changed track From 77095260dda0d3377c0bc886e43bf45547392c45 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:09:21 -0700 Subject: [PATCH 04/13] fix(publish): snapshot a served track's identity A producer mutating a container it shares rewrote the stored identity, so an init change kept the same track name. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 17 +++++++++++++++++ js/publish/src/broadcast.ts | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 945bb0bc853..9899be8f876 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -465,6 +465,23 @@ test("a served identity change moves the rendition to a fresh track name", async } }); +// The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. +test("mutating a shared container still takes a fresh track name", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); + const video = broadcast.video("video"); + const container = { kind: "cmaf" as const, init: "AAAA" }; + video.config.set({ ...videoConfig, container }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBeUndefined(); + + container.init = "BBBB"; + video.config.set({ ...videoConfig, container, bitrate: Catalog.u53(1_000_000) }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toStartWith("video/"); + + broadcast.close(); +}); + test("a name returning with a different identity takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("returning.hang") }); let audio = broadcast.audio("audio"); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 73e3dcf24c8..0bb8d7d5c2a 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -386,7 +386,8 @@ export class Broadcast { } // The fields a consumer may configure its decoder from once per track name, plus the coded-size -// ceilings: changing any of them takes a new track name. +// ceilings: changing any of them takes a new track name. Cloned, so a producer mutating a config it +// shares (such as its container) can't rewrite what a name was served with. type Identity = Pick< Partial, "codec" | "description" | "container" | "sampleRate" | "numberOfChannels" | "codedWidth" | "codedHeight" @@ -394,5 +395,5 @@ type Identity = Pick< function identity(config: unknown): Identity { const { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight } = config as Identity; - return { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }; + return structuredClone({ codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }); } From 72db6bce6aeb13634a5c0a7ce5e0a5447e272260 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:15:10 -0700 Subject: [PATCH 05/13] fix(publish): key catalog maxima by broadcast and track name Same-named tracks referenced in different broadcasts are different tracks, so removing one compared the other against it. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/catalog.test.ts | 22 ++++++++++++++++++++++ js/publish/src/catalog.ts | 22 +++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 8b092040665..5486feb2536 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -270,6 +270,28 @@ for (const field of ["bitrate", "framerate"] as const) { }); } +// Same-named tracks in different broadcasts are different tracks. +test("catalog keeps maxima apart for same-named tracks in different broadcasts", () => { + const catalog = new CatalogProducer(); + const rendition = (broadcast: string, bitrate: number) => ({ + codec: "opus", + container: { kind: "legacy" as const }, + sampleRate: Catalog.u53(48000), + numberOfChannels: Catalog.u53(2), + broadcast: broadcast as Catalog.RelativeBroadcast, + track: "audio", + bitrate: Catalog.u53(bitrate), + }); + catalog.mutate((value) => { + value.audio = { renditions: { en: rendition("../en", 64), es: rendition("../es", 128) } }; + }); + expect(() => + catalog.mutate((value) => { + delete value.audio?.renditions.es; + }), + ).not.toThrow(); +}); + for (const section of ["json", "binary"] as const) { test(`catalog refuses zero or decreasing ${section} jitter without retaining it`, () => { const catalog = new CatalogProducer(); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 604497adbaf..83b0ed7c3aa 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -114,18 +114,26 @@ function structure(catalog: Catalog.Root): Catalog.Root { return value; } -/** Every track's advertised `field`, by section and then track name. */ +type Advertised = { + broadcast?: string; + track?: string; + jitter?: number; + delay?: number; + bitrate?: number; + framerate?: number; +}; + +/** Every track's advertised `field`, by section and then the track's broadcast and name. */ function advertised( catalog: Catalog.Root, field: "jitter" | "delay" | "bitrate" | "framerate", ): Record> { - const pick = ( - tracks: - | Record - | undefined, - ) => + const pick = (tracks: Record | undefined) => Object.fromEntries( - Object.entries(tracks ?? {}).map(([id, config]) => [Catalog.track(id, config), config[field]]), + Object.entries(tracks ?? {}).map(([id, config]) => [ + JSON.stringify([config.broadcast, Catalog.track(id, config)]), + config[field], + ]), ); return { audio: pick(catalog.audio?.renditions), From 765c802e71454117ea25d70d970091f7830ca98a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:23:25 -0700 Subject: [PATCH 06/13] fix(publish): keep a served track's maxima across its rendition's removal A rendition re-registered on the same track name at a lower bitrate lowered the advertised maximum, though the track had carried more. Broadcast now advertises the most each served name has carried. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 26 ++++++++++++++ js/publish/src/broadcast.ts | 58 +++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 9899be8f876..c15c8163c81 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -465,6 +465,32 @@ test("a served identity change moves the rendition to a fresh track name", async } }); +// A track keeps the most it has carried, even across its rendition's removal. +test("a served track never lowers its maxima", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("maxima.hang") }); + let audio = broadcast.audio("audio"); + audio.config.set({ ...audioConfig, bitrate: Catalog.u53(128_000) }); + await settle(); + + audio.close(); + await settle(); + audio = broadcast.audio("audio"); + audio.config.set({ ...audioConfig, bitrate: Catalog.u53(32_000) }); + await settle(); + let config = (await readCatalog(broadcast))?.audio?.renditions.audio; + expect(config?.bitrate).toBe(Catalog.u53(128_000)); + expect(config?.track).toBeUndefined(); + + audio.config.set({ ...audioConfig, bitrate: Catalog.u53(256_000) }); + await settle(); + audio.config.set({ ...audioConfig, bitrate: Catalog.u53(64_000) }); + await settle(); + config = (await readCatalog(broadcast))?.audio?.renditions.audio; + expect(config?.bitrate).toBe(Catalog.u53(256_000)); + + broadcast.close(); +}); + // The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. test("mutating a shared container still takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 0bb8d7d5c2a..18081a252c2 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -97,13 +97,13 @@ export class Broadcast { // catalog ID under a fresh track name, so a name always means the same content. readonly #names = new Signal>({}); - // The media identity of each track name the current network broadcast's catalog has served, or - // undefined before it serves one. Kept across a rendition's removal, so a name returning with - // different media takes a fresh name too. - #identities: Map | undefined; + // The media identity and maxima of each track name the current network broadcast's catalog has + // served, or undefined before it serves one. Kept across a rendition's removal, so a name returning + // with different media takes a fresh name, and one returning lower keeps advertising its maxima. + #served: Map | undefined; - // The identity of each track name in the latest catalog fold, recorded when the catalog is first served. - #listed = new Map(); + // What each track name in the latest catalog fold advertises, recorded when the catalog is first served. + #listed = new Map(); #signals = new Effect(); @@ -190,22 +190,30 @@ export class Broadcast { const video: Record = {}; const audio: Record = {}; const text: Record = {}; - const listed = new Map(); + const listed = new Map(); const renamed: Record = {}; for (const rendition of Object.values(renditions)) { const source = enabled ? effect.get(rendition.config) : undefined; if (source === undefined) continue; let track = names[rendition.name] ?? rendition.name; - const media = identity(source); - const served = this.#identities?.get(track); - if (served !== undefined && !Json.deepEqual(served, media)) { + const next = served(source); + let previous = this.#served?.get(track); + if (previous && !Json.deepEqual(previous.identity, next.identity)) { track = `${rendition.name}/${Moq.Epoch.mint()}`; renamed[rendition.name] = track; + previous = undefined; } - this.#identities?.set(track, media); - listed.set(track, media); - const config = track !== rendition.name ? { ...(source as object), track } : source; + // The track has carried its served maxima, so a lower config never lowers them. + const bitrate = most(previous?.bitrate, next.bitrate); + const framerate = most(previous?.framerate, next.framerate); + const advertised = { identity: next.identity, bitrate, framerate }; + this.#served?.set(track, advertised); + listed.set(track, advertised); + const config = { ...(source as object) } as Record; + if (track !== rendition.name) config.track = track; + if (bitrate !== next.bitrate) config.bitrate = bitrate; + if (framerate !== next.framerate) config.framerate = framerate; if (rendition.kind === "video") { video[rendition.name] = config as Catalog.VideoConfig; @@ -283,7 +291,7 @@ export class Broadcast { // again finds its track; the catalog's stay out of reach of a colliding rendition. const tracks = new Map }>(); this.#names.set({}); - this.#identities = undefined; + this.#served = undefined; const kinds = new Map(); this.#kinds = kinds; effect.cleanup(() => { @@ -304,7 +312,7 @@ export class Broadcast { if (!serving) { serving = true; // From here a name keeps the identity this first snapshot gives it. - this.#identities = new Map(this.#listed); + this.#served = new Map(this.#listed); for (const [name, compression] of [ [Broadcast.CATALOG_TRACK, false], [Broadcast.CATALOG_TRACK_COMPRESSED, true], @@ -386,14 +394,24 @@ export class Broadcast { } // The fields a consumer may configure its decoder from once per track name, plus the coded-size -// ceilings: changing any of them takes a new track name. Cloned, so a producer mutating a config it -// shares (such as its container) can't rewrite what a name was served with. +// ceilings: changing any of them takes a new track name. type Identity = Pick< Partial, "codec" | "description" | "container" | "sampleRate" | "numberOfChannels" | "codedWidth" | "codedHeight" >; -function identity(config: unknown): Identity { - const { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight } = config as Identity; - return structuredClone({ codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }); +// What a track name was served with: its identity and the maxima it may never lower. +type Served = { identity: Identity; bitrate?: number; framerate?: number }; + +// Cloned, so a producer mutating a config it shares (such as its container) can't rewrite what a +// name was served with. +function served(config: unknown): Served { + const { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight, bitrate, framerate } = + config as Identity & Pick; + const identity = { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }; + return { identity: structuredClone(identity), bitrate, framerate }; +} + +function most(a: number | undefined, b: number | undefined): number | undefined { + return a === undefined ? b : b === undefined ? a : Math.max(a, b); } From 0c694181d869dc5cd82735c114855fb9785ca50e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:29:03 -0700 Subject: [PATCH 07/13] fix(publish): carry maxima across unserved catalog folds Before the first serve, a codec change with a lower bitrate was refused by the catalog, leaving the old codec advertised. Unserved folds now carry the previous fold's maxima. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 8 +++++--- js/publish/src/broadcast.ts | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index c15c8163c81..bb7a80820d8 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -538,9 +538,10 @@ test("identity changes before the first announce keep the catalog ID as the trac const announce = new Signal(false); const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("unserved.hang"), announce }); const video = broadcast.video("video"); - video.config.set(videoConfig); + video.config.set({ ...videoConfig, bitrate: Catalog.u53(1_000_000) }); await settle(); - video.config.set({ ...videoConfig, codec: "vp8" }); + // A lower maximum for the new codec keeps the higher one rather than being refused. + video.config.set({ ...videoConfig, codec: "vp8", bitrate: Catalog.u53(500_000) }); await settle(); announce.set(true); @@ -552,10 +553,11 @@ test("identity changes before the first announce keep the catalog ID as the trac }); const config = (await catalog.latest())?.value.video?.renditions.video; expect(config?.codec).toBe("vp8"); + expect(config?.bitrate).toBe(Catalog.u53(1_000_000)); expect(config?.track).toBeUndefined(); // The first snapshot fixed the name's identity. - video.config.set(videoConfig); + video.config.set({ ...videoConfig, bitrate: Catalog.u53(1_000_000) }); await settle(); expect((await catalog.latest())?.value.video?.renditions.video?.track).toStartWith("video/"); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 18081a252c2..f82afb35def 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -198,8 +198,10 @@ export class Broadcast { if (source === undefined) continue; let track = names[rendition.name] ?? rendition.name; const next = served(source); - let previous = this.#served?.get(track); - if (previous && !Json.deepEqual(previous.identity, next.identity)) { + // Unserved, nothing is renamed, but the catalog still refuses a maximum falling below its + // previous fold, so carry that fold's. + let previous = this.#served ? this.#served.get(track) : this.#listed.get(track); + if (this.#served && previous && !Json.deepEqual(previous.identity, next.identity)) { track = `${rendition.name}/${Moq.Epoch.mint()}`; renamed[rendition.name] = track; previous = undefined; From 5d1e98e3d47a386f64ca617525c95ef63467ba23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:06:45 -0700 Subject: [PATCH 08/13] fix(publish): mint tracks only for decoder identity changes Per the 2026-10-15 maintainer decision, only fields that force a decoder reinitialization (codec, description, container, display aspect, sample rate, channel count) mint a new track. Coded size, bitrate, and framerate update in place in either direction, so the falling-maxima refusal and per-name maxima are gone, and the hang draft, concept doc, and Rust quest drop the ceiling and maxima classes. Minted names count up per catalog ID (/1, /2), skipping every name the broadcast already uses. Co-Authored-By: Claude Opus 5.5 --- doc/concept/hang.md | 17 ++-- doc/lib/js/publish.md | 8 +- drafts/draft-lcurley-moq-hang.md | 14 ++-- js/hang/src/catalog/audio.ts | 2 +- js/hang/src/catalog/video.ts | 4 +- js/publish/src/broadcast.test.ts | 107 ++++++++++++------------- js/publish/src/broadcast.ts | 112 +++++++++++++++++---------- js/publish/src/catalog.test.ts | 56 ++++++-------- js/publish/src/catalog.ts | 19 ++--- js/publish/src/rendition.ts | 7 +- js/publish/src/video/encoder.test.ts | 11 ++- js/publish/src/video/encoder.ts | 2 +- quest/m1/README.md | 2 +- quest/m1/catalog-identity-rs.md | 58 +++++++------- rs/hang/src/catalog/audio/mod.rs | 2 +- rs/hang/src/catalog/video/mod.rs | 4 +- 16 files changed, 218 insertions(+), 207 deletions(-) diff --git a/doc/concept/hang.md b/doc/concept/hang.md index efeb734e7cb..09c38ea9590 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -51,15 +51,14 @@ VP9, AV1, AAC, and Opus are what the tools produce today. Properties shared by every video rendition (display size, rotation, flip) sit at the section root. A track name always means the same content, so a catalog update never -redefines a track. Its codec, description, container, sample rate, and channel -count are fixed. `codedWidth` and `codedHeight` are ceilings: the picture -resizes in band at a keyframe below them, unless a `description` pins it. -`bitrate` and `framerate` are maxima that only rise, like `jitter` and `delay`. -Everything else, such as `enabled` and the section's display size, updates -freely. A -publisher that needs a new identity publishes a new track name, or restarts the -broadcast under a new [epoch](/concept/moq-lite#publisher-epochs). A player can -configure its decoder once per track. +redefines a track. The fields a decoder can't change without reinitializing +are fixed: codec, description, container, display aspect, sample rate, and +channel count. Everything else, such as the coded size, `bitrate`, +`framerate`, and `enabled`, updates in place; the picture resizes in band at a +keyframe unless a `description` pins it. A publisher that needs a new identity +publishes a new track name, or restarts the broadcast under a new +[epoch](/concept/moq-lite#publisher-epochs). A player can configure its +decoder once per track. A few things the catalog can express beyond decoder config: diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 4beb717c518..2a7591748ea 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -49,10 +49,10 @@ for a permission, device, or settings change instead of prompting again. The video encoder follows its share of the connection's send-rate estimate, so several publishers on one connection do not each target the whole uplink. Codec, resolution, framerate, and bitrate are tunable through -`el.video.config`, and `el.video.cut()` asks for an extra keyframe. A -capture that shrinks resizes in place, while a codec or audio format change, -or video outgrowing its advertised size, keeps the rendition's catalog ID under -a fresh `track` name that viewers follow through the catalog. For +`el.video.config`, and `el.video.cut()` asks for an extra keyframe. Size, +bitrate, and framerate change in place, while a codec or audio format change +keeps the rendition's catalog ID under a fresh `track` name that viewers +follow through the catalog. For simulcast, drop the element and register several encoders on a `Publish.Broadcast`, as below. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 78d32fa10be..e55d9d1c5dc 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -166,14 +166,12 @@ A discontinuity marker is a delivery event, not a new epoch, and a system-clock ## Track Identity {#identity} A track name always means the same content, so a catalog update never redefines an audio or video track. -Each rendition field falls in one of three classes: +A track's identity is the fields a consumer cannot change without reinitializing its decoder or demuxer: `codec`, `description`, and `container`, plus `displayAspectWidth` and `displayAspectHeight` for video and `sampleRate` and `numberOfChannels` for audio. +A publisher MUST NOT change them for a track once a catalog lists it. -- Identity: `codec`, `description`, and `container`, plus `sampleRate` and `numberOfChannels` for audio. A publisher MUST NOT change them for a track once a catalog lists it. -- Ceilings: video `codedWidth` and `codedHeight` bound every picture the track carries, and `codec` names a profile and level that covers them. The picture size MAY change at any keyframe within both bounds, which a `description` carrying parameter sets rules out. A publisher MAY fill an absent ceiling once, and MUST NOT change it afterward. -- Maxima: `bitrate` and `framerate` are the most the track has carried or is configured to carry. A publisher MAY raise them and MUST NOT lower them. - -Every other rendition field is live state that MAY change in any update within its own rules (`jitter` and `delay` never fall), as MAY the video section's `display`, `rotation`, and `flip`, which describe presentation rather than decoding. -A publisher that changes an identity field or exceeds a ceiling MUST publish the media under a new track name, or as a new broadcast under a new Epoch {{moql}}. +Every other rendition field is live state that MAY change in any update within its own rules (`jitter` and `delay` never fall), as MAY the video section's `display`, `rotation`, and `flip`. +The picture size MAY change at any keyframe, which a `description` carrying parameter sets rules out, and `codec` names a profile and level that covers every picture the track carries. +A publisher that changes an identity field MUST publish the media under a new track name, or as a new broadcast under a new Epoch {{moql}}. A rendition MAY keep its ID and name the new track with `track` ({{field-track}}), so a consumer follows the new name rather than reusing a decoder configured for the ID. A track name removed from the catalog MUST NOT return with a different identity. A consumer MAY configure a decoder once per track from the catalog. @@ -1124,7 +1122,7 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} -- Fixed a track's identity for its name ({{identity}}): codec, description, container, sample rate, and channel count never change; `codedWidth` and `codedHeight` are ceilings below which the picture resizes in band; `bitrate` and `framerate` are maxima that only rise. A change of identity takes a new track name, which a rendition may name with `track` while keeping its ID, or a new Epoch. +- 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. - Specified conversion between relative broadcast references and MSF track namespaces without changing track identity, reading a namespace aligned with the catalog path's trailing segments as the catalog itself. diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 746618ac405..1d04093571f 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -43,7 +43,7 @@ export const AudioConfigSchema = z.object({ // The number of channels in the audio numberOfChannels: u53Schema, - // The maximum bitrate of the audio in bits per second. It only rises. + // The maximum bitrate of the audio in bits per second. // TODO: Support up to Number.MAX_SAFE_INTEGER bitrate: z.optional(u53Schema), diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index b19b1fd0c27..11fca3d7465 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -44,10 +44,10 @@ export const VideoConfigSchema = z.object({ displayAspectWidth: z.optional(u53Schema), displayAspectHeight: z.optional(u53Schema), - // The maximum frame rate of the video in frames per second. It only rises. + // The maximum frame rate of the video in frames per second. framerate: z.optional(z.number()), - // The maximum bitrate of the video in bits per second. It only rises. + // The maximum bitrate of the video in bits per second. // TODO: Support up to Number.MAX_SAFE_INTEGER bitrate: z.optional(u53Schema), diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index bb7a80820d8..886478d54f4 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -401,10 +401,17 @@ test("registering a sibling after an encoder failure keeps the network broadcast // A name always means the same content, so a served rendition whose decoder identity changes moves to // a fresh track name under the same catalog ID and epoch, ending the old track on its old identity. -test("a served identity change moves the rendition to a fresh track name", async () => { +// Fields a decoder needn't reinitialize for update the served track in place. +test("a served decoder identity change moves the rendition to a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("identity.hang") }); const video = broadcast.video("video"); - const sized = { ...videoConfig, codedWidth: Catalog.u53(640), codedHeight: Catalog.u53(480) }; + const sized = { + ...videoConfig, + codedWidth: Catalog.u53(1280), + codedHeight: Catalog.u53(720), + bitrate: Catalog.u53(1_000_000), + framerate: 30, + }; video.config.set(sized); await settle(); @@ -422,41 +429,46 @@ test("a served identity change moves the rendition to a fresh track name", async if (!track) throw new Error("expected the demanded track"); track.appendGroup().close(); - // A rising maximum stays on the served track. - video.config.set({ ...sized, bitrate: Catalog.u53(1_000_000) }); - await settle(); - expect((await catalog.latest())?.value.video?.renditions.video?.track).toBeUndefined(); - expect(video.track.peek()).toBe(track); - - // Outgrowing the coded-size ceiling is a new identity. - video.config.set({ - ...videoConfig, - codedWidth: Catalog.u53(1280), - codedHeight: Catalog.u53(720), - bitrate: Catalog.u53(1_000_000), - }); + // Bitrate, frame rate, and coded size move either way on the served track. + for (const update of [ + { bitrate: Catalog.u53(250_000), framerate: 15 }, + { codedWidth: Catalog.u53(1920), codedHeight: Catalog.u53(1080), bitrate: Catalog.u53(4_000_000) }, + { codedWidth: Catalog.u53(640), codedHeight: Catalog.u53(360) }, + ]) { + video.config.set({ ...sized, ...update }); + await settle(); + const config = (await catalog.latest())?.value.video?.renditions.video; + expect(config).toMatchObject(update); + expect(config?.track).toBeUndefined(); + expect(video.track.peek()).toBe(track); + } + + // A codec change is a new identity. + video.config.set({ ...sized, codec: "vp8" }); await settle(); const config = (await catalog.latest())?.value.video?.renditions.video; - if (!config?.track) throw new Error("expected a fresh track name"); - expect(config.track).toStartWith("video/"); - expect(config.codedWidth).toBe(Catalog.u53(1280)); + expect(config?.track).toBe("video/1"); + expect(config?.codec).toBe("vp8"); expect(track.closed.peek()).toBeNull(); expect(video.track.peek()).toBeUndefined(); expect(broadcast.net.peek()).toBe(net); expect(net.route?.epoch).toBe(epoch); - const second = net.track(config.track).subscribe(); + const second = net.track("video/1").subscribe(); await settle(); const replacement = video.track.peek(); - expect(replacement?.name).toBe(config.track); + expect(replacement?.name).toBe("video/1"); expect(replacement?.appendGroup().sequence).toBe(0); - // A codec change moves it again rather than redefining the second name. - video.config.set({ ...config, track: undefined, codec: "vp8" }); + // So is a display aspect change, which moves it again rather than redefining the second name. + video.config.set({ + ...sized, + codec: "vp8", + displayAspectWidth: Catalog.u53(4), + displayAspectHeight: Catalog.u53(3), + }); await settle(); - const third = (await catalog.latest())?.value.video?.renditions.video?.track; - expect(third).toStartWith("video/"); - expect(third).not.toBe(config.track); + expect((await catalog.latest())?.value.video?.renditions.video?.track).toBe("video/2"); expect(replacement?.closed.peek()).toBeNull(); second.close(); } finally { @@ -465,28 +477,20 @@ test("a served identity change moves the rendition to a fresh track name", async } }); -// A track keeps the most it has carried, even across its rendition's removal. -test("a served track never lowers its maxima", async () => { - const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("maxima.hang") }); - let audio = broadcast.audio("audio"); - audio.config.set({ ...audioConfig, bitrate: Catalog.u53(128_000) }); - await settle(); - - audio.close(); - await settle(); - audio = broadcast.audio("audio"); - audio.config.set({ ...audioConfig, bitrate: Catalog.u53(32_000) }); +// The counter skips every name this broadcast already uses, including an application's own sections. +test("a minted track name skips names already in the catalog", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("collide.hang") }); + broadcast.audio("video/1").config.set(audioConfig); + broadcast.catalog.mutate((catalog) => { + catalog.json = { tracks: { meta: { mode: "stream", track: "video/2" } } }; + }); + const video = broadcast.video("video"); + video.config.set(videoConfig); await settle(); - let config = (await readCatalog(broadcast))?.audio?.renditions.audio; - expect(config?.bitrate).toBe(Catalog.u53(128_000)); - expect(config?.track).toBeUndefined(); - audio.config.set({ ...audioConfig, bitrate: Catalog.u53(256_000) }); + video.config.set({ ...videoConfig, codec: "vp8" }); await settle(); - audio.config.set({ ...audioConfig, bitrate: Catalog.u53(64_000) }); - await settle(); - config = (await readCatalog(broadcast))?.audio?.renditions.audio; - expect(config?.bitrate).toBe(Catalog.u53(256_000)); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/3"); broadcast.close(); }); @@ -503,7 +507,7 @@ test("mutating a shared container still takes a fresh track name", async () => { container.init = "BBBB"; video.config.set({ ...videoConfig, container, bitrate: Catalog.u53(1_000_000) }); await settle(); - expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toStartWith("video/"); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/1"); broadcast.close(); }); @@ -515,11 +519,11 @@ test("a name returning with a different identity takes a fresh track name", asyn await settle(); expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toBeUndefined(); - // The same identity returns to its track. + // The same identity returns to its track, even at a lower bitrate. audio.close(); await settle(); audio = broadcast.audio("audio"); - audio.config.set(audioConfig); + audio.config.set({ ...audioConfig, bitrate: Catalog.u53(32_000) }); await settle(); expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toBeUndefined(); @@ -528,7 +532,7 @@ test("a name returning with a different identity takes a fresh track name", asyn audio = broadcast.audio("audio"); audio.config.set({ ...audioConfig, numberOfChannels: Catalog.u53(1) }); await settle(); - expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toStartWith("audio/"); + expect((await readCatalog(broadcast))?.audio?.renditions.audio?.track).toBe("audio/1"); broadcast.close(); }); @@ -540,7 +544,6 @@ test("identity changes before the first announce keep the catalog ID as the trac const video = broadcast.video("video"); video.config.set({ ...videoConfig, bitrate: Catalog.u53(1_000_000) }); await settle(); - // A lower maximum for the new codec keeps the higher one rather than being refused. video.config.set({ ...videoConfig, codec: "vp8", bitrate: Catalog.u53(500_000) }); await settle(); @@ -553,13 +556,13 @@ test("identity changes before the first announce keep the catalog ID as the trac }); const config = (await catalog.latest())?.value.video?.renditions.video; expect(config?.codec).toBe("vp8"); - expect(config?.bitrate).toBe(Catalog.u53(1_000_000)); + expect(config?.bitrate).toBe(Catalog.u53(500_000)); expect(config?.track).toBeUndefined(); // The first snapshot fixed the name's identity. - video.config.set({ ...videoConfig, bitrate: Catalog.u53(1_000_000) }); + video.config.set(videoConfig); await settle(); - expect((await catalog.latest())?.value.video?.renditions.video?.track).toStartWith("video/"); + expect((await catalog.latest())?.value.video?.renditions.video?.track).toBe("video/1"); broadcast.close(); }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index f82afb35def..779476d52fc 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -97,13 +97,13 @@ export class Broadcast { // catalog ID under a fresh track name, so a name always means the same content. readonly #names = new Signal>({}); - // The media identity and maxima of each track name the current network broadcast's catalog has - // served, or undefined before it serves one. Kept across a rendition's removal, so a name returning - // with different media takes a fresh name, and one returning lower keeps advertising its maxima. - #served: Map | undefined; + // The decoder identity of each track name the current network broadcast's catalog has served, or + // undefined before it serves one. Kept across a rendition's removal, so a name returning with + // different media takes a fresh name too. + #served: Map | undefined; - // What each track name in the latest catalog fold advertises, recorded when the catalog is first served. - #listed = new Map(); + // The identity of each track name in the latest catalog fold, recorded when the catalog is first served. + #listed = new Map(); #signals = new Effect(); @@ -190,32 +190,23 @@ export class Broadcast { const video: Record = {}; const audio: Record = {}; const text: Record = {}; - const listed = new Map(); + const listed = new Map(); const renamed: Record = {}; for (const rendition of Object.values(renditions)) { const source = enabled ? effect.get(rendition.config) : undefined; if (source === undefined) continue; let track = names[rendition.name] ?? rendition.name; - const next = served(source); - // Unserved, nothing is renamed, but the catalog still refuses a maximum falling below its - // previous fold, so carry that fold's. - let previous = this.#served ? this.#served.get(track) : this.#listed.get(track); - if (this.#served && previous && !Json.deepEqual(previous.identity, next.identity)) { - track = `${rendition.name}/${Moq.Epoch.mint()}`; + // Everything else (bitrate, frame rate, coded size, ...) updates the served track in place. + const media = identity(source); + const served = this.#served?.get(track); + if (served && !Json.deepEqual(served, media)) { + track = this.#mint(rendition.name); renamed[rendition.name] = track; - previous = undefined; } - // The track has carried its served maxima, so a lower config never lowers them. - const bitrate = most(previous?.bitrate, next.bitrate); - const framerate = most(previous?.framerate, next.framerate); - const advertised = { identity: next.identity, bitrate, framerate }; - this.#served?.set(track, advertised); - listed.set(track, advertised); - const config = { ...(source as object) } as Record; - if (track !== rendition.name) config.track = track; - if (bitrate !== next.bitrate) config.bitrate = bitrate; - if (framerate !== next.framerate) config.framerate = framerate; + this.#served?.set(track, media); + listed.set(track, media); + const config = track !== rendition.name ? { ...(source as object), track } : source; if (rendition.kind === "video") { video[rendition.name] = config as Catalog.VideoConfig; @@ -260,6 +251,37 @@ export class Broadcast { if (Object.keys(renamed).length > 0) this.#names.update((names) => ({ ...names, ...renamed })); } + // The next unused `/` track name in the current network broadcast. This publisher owns the + // namespace, so skipping every name it has served, registered, or listed in the catalog (including + // an application's own sections) is enough to never reuse one. + #mint(id: string): string { + const taken = new Set([ + ...(this.#served?.keys() ?? []), + ...this.#listed.keys(), + ...Object.keys(this.#renditions.peek()), + ...Object.values(this.#names.peek()), + ]); + // A no-op edit is the catalog's only read; it publishes nothing. + this.catalog.mutate((catalog) => { + for (const entries of [ + catalog.audio?.renditions, + catalog.video?.renditions, + catalog.text?.renditions, + catalog.json?.tracks, + catalog.binary?.tracks, + ]) { + for (const [key, config] of Object.entries(entries ?? {})) { + taken.add(key); + taken.add(Catalog.track(key, config)); + } + } + }); + for (let n = 1; ; n++) { + const name = `${id}/${n}`; + if (!taken.has(name)) return name; + } + } + #run(effect: Effect) { const values = effect.getAll([this.in.enabled, this.in.origin]); if (!values) return; @@ -358,10 +380,8 @@ export class Broadcast { } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { // An error leaves this rendition down. Only a replacement registration may recover, // with a fresh track name so its group sequence can start over. - this.#names.update((names) => ({ - ...names, - [rendition.name]: `${rendition.name}/${Moq.Epoch.mint()}`, - })); + const fresh = this.#mint(rendition.name); + this.#names.update((names) => ({ ...names, [rendition.name]: fresh })); continue; } if (!entry) { @@ -395,25 +415,31 @@ export class Broadcast { } } -// The fields a consumer may configure its decoder from once per track name, plus the coded-size -// ceilings: changing any of them takes a new track name. +// The fields a consumer can't change without reinitializing its decoder (or demuxer, for +// `container`), so changing any of them takes a new track name. type Identity = Pick< Partial, - "codec" | "description" | "container" | "sampleRate" | "numberOfChannels" | "codedWidth" | "codedHeight" + | "codec" + | "description" + | "container" + | "sampleRate" + | "numberOfChannels" + | "displayAspectWidth" + | "displayAspectHeight" >; -// What a track name was served with: its identity and the maxima it may never lower. -type Served = { identity: Identity; bitrate?: number; framerate?: number }; - // Cloned, so a producer mutating a config it shares (such as its container) can't rewrite what a // name was served with. -function served(config: unknown): Served { - const { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight, bitrate, framerate } = - config as Identity & Pick; - const identity = { codec, description, container, sampleRate, numberOfChannels, codedWidth, codedHeight }; - return { identity: structuredClone(identity), bitrate, framerate }; -} - -function most(a: number | undefined, b: number | undefined): number | undefined { - return a === undefined ? b : b === undefined ? a : Math.max(a, b); +function identity(config: unknown): Identity { + const { codec, description, container, sampleRate, numberOfChannels, displayAspectWidth, displayAspectHeight } = + config as Identity; + return structuredClone({ + codec, + description, + container, + sampleRate, + numberOfChannels, + displayAspectWidth, + displayAspectHeight, + }); } diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 5486feb2536..4a08803857c 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -239,48 +239,17 @@ for (const field of ["jitter", "delay"] as const) { } } -// Maxima only rise for a track name, but a rendition moved to a fresh track name starts over. -for (const field of ["bitrate", "framerate"] as const) { - test(`catalog refuses a falling ${field} per track name`, () => { - const catalog = new CatalogProducer(); - const media = (value: Catalog.Root) => { - const config = value.video?.renditions.media; - if (!config) throw new Error("expected a retained video rendition"); - return config; - }; - catalog.mutate((value) => { - value.video = { renditions: { media: { codec: "vp8", container: { kind: "legacy" }, [field]: 100 } } }; - }); - catalog.mutate((value) => { - media(value)[field] = Catalog.u53(200); - }); - for (const maximum of [Catalog.u53(100), undefined]) { - expect(() => - catalog.mutate((value) => { - media(value)[field] = maximum; - }), - ).toThrow(`${field} cannot decrease`); - } - catalog.mutate((value) => { - Object.assign(media(value), { track: "media/2", [field]: 50 }); - }); - catalog.mutate((value) => { - expect(media(value)[field]).toBe(Catalog.u53(50)); - }); - }); -} - // Same-named tracks in different broadcasts are different tracks. -test("catalog keeps maxima apart for same-named tracks in different broadcasts", () => { +test("catalog keeps estimates apart for same-named tracks in different broadcasts", () => { const catalog = new CatalogProducer(); - const rendition = (broadcast: string, bitrate: number) => ({ + const rendition = (broadcast: string, jitter: number) => ({ codec: "opus", container: { kind: "legacy" as const }, sampleRate: Catalog.u53(48000), numberOfChannels: Catalog.u53(2), broadcast: broadcast as Catalog.RelativeBroadcast, track: "audio", - bitrate: Catalog.u53(bitrate), + jitter: Catalog.u53(jitter), }); catalog.mutate((value) => { value.audio = { renditions: { en: rendition("../en", 64), es: rendition("../es", 128) } }; @@ -487,3 +456,22 @@ describe("estimate publication window", () => { expect((await consumer.latest())?.value.video?.renditions.v.jitter).toBe(Catalog.u53(3)); }); }); + +// A renamed rendition's new track starts its estimates over. +test("catalog lets a renamed track start its estimates over", () => { + const catalog = new CatalogProducer(); + const config = { codec: "vp8", container: { kind: "legacy" as const } }; + catalog.mutate((value) => { + value.video = { renditions: { video: { ...config, jitter: Catalog.u53(100) } } }; + }); + catalog.mutate((value) => { + value.video = { renditions: { video: { ...config, codec: "vp9", track: "video/1", jitter: Catalog.u53(10) } } }; + }); + expect(() => + catalog.mutate((value) => { + value.video = { + renditions: { video: { ...config, codec: "vp9", track: "video/1", jitter: Catalog.u53(5) } }, + }; + }), + ).toThrow("jitter cannot decrease"); +}); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 83b0ed7c3aa..009c57314b1 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -31,14 +31,12 @@ export class CatalogProducer { fn(value); // Every consumer refuses a catalog that fails this, so refuse the edit before it reaches one. Catalog.checkRenditions(value); - // Estimates and maxima only rise for a track name; a renamed rendition starts its new track over. - for (const field of ["jitter", "delay", "bitrate", "framerate"] as const) { + // Estimates only rise for a track; a renamed rendition starts its new track over. + for (const field of ["jitter", "delay"] as const) { const previous = advertised(this.#value, field); for (const [section, next] of Object.entries(advertised(value, field))) { for (const [name, estimate] of Object.entries(next)) { - if (estimate === 0 && (field === "jitter" || field === "delay")) { - throw new Error(`omit ${field} rather than advertising 0`); - } + if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); const before = previous[section]?.[name]; if (before !== undefined && (estimate === undefined || estimate < before)) { throw new Error(`${field} cannot decrease for an existing track`); @@ -114,19 +112,12 @@ function structure(catalog: Catalog.Root): Catalog.Root { return value; } -type Advertised = { - broadcast?: string; - track?: string; - jitter?: number; - delay?: number; - bitrate?: number; - framerate?: number; -}; +type Advertised = { broadcast?: string; track?: string; jitter?: number; delay?: number }; /** Every track's advertised `field`, by section and then the track's broadcast and name. */ function advertised( catalog: Catalog.Root, - field: "jitter" | "delay" | "bitrate" | "framerate", + field: "jitter" | "delay", ): Record> { const pick = (tracks: Record | undefined) => Object.fromEntries( diff --git a/js/publish/src/rendition.ts b/js/publish/src/rendition.ts index 121997e782d..eb5f79e49d1 100644 --- a/js/publish/src/rendition.ts +++ b/js/publish/src/rendition.ts @@ -19,9 +19,10 @@ export class Rendition { /** * The catalog entry for this rendition, written by its producer (usually an encoder). - * `undefined` omits it from the catalog (e.g. while disabled). Changing its codec, description, - * container, sample rate, channel count, or coded size ends the current track and serves the - * rendition under a fresh catalog `track` name, so a name always means the same content. + * `undefined` omits it from the catalog (e.g. while disabled). Changing a field a decoder must + * reinitialize for (codec, description, container, sample rate, channel count, or display aspect) + * ends the current track and serves the rendition under a fresh catalog `track` name, so a name + * always means the same content. Every other field updates the current track in place. */ readonly config = new Signal(undefined); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index e5784d44126..4f9378dc929 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -1480,9 +1480,9 @@ test("a refused first ceiling recovers at a smaller size", async () => { } }); -// Outgrowing the advertised ceiling is a new identity, so the broadcast serves the larger ceiling under -// a fresh track name while the epoch and the maxima carry on. A shrink stays in band on the old name. -test("ceiling growth moves the rendition to a fresh track name", async () => { +// A decoder follows a resize in band, so outgrowing the advertised size re-probes for a codec that +// covers it and updates the served track in place while the codec string holds. A shrink stays in band. +test("ceiling growth updates the served track in place", async () => { using _videoEncoder = installFakeVideoEncoder(); using _probe = spyOn(FakeVideoEncoder, "isConfigSupported").mockResolvedValue({ supported: true }); using _now = spyOn(performance, "now").mockReturnValue(0); @@ -1515,9 +1515,8 @@ test("ceiling growth moves the rendition to a fresh track name", async () => { capture.out.display.set({ width: 1280, height: 720 }); const grown = await next((config) => config.codedWidth === 1280); - expect(grown).toMatchObject({ codedHeight: 720, framerate: 60 }); - expect(grown.track).toStartWith("video/"); - expect(grown.bitrate ?? 0).toBeGreaterThanOrEqual(initial.bitrate ?? 0); + expect(grown).toMatchObject({ codec: initial.codec, codedHeight: 720, framerate: 60 }); + expect(grown.track).toBeUndefined(); expect(broadcast.net.peek()).toBe(net); } finally { subscriber.close(); diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index a4de00a4c4b..70daa7fb84d 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -112,7 +112,7 @@ type EncoderOutput = { * * The catalog advertises the largest size encoded so far as the coded-size ceiling, so a capture that * shrinks resizes in band at a keyframe. Outgrowing the ceiling re-probes and advertises a larger one, - * which the broadcast serves under a new track name. + * in place unless the codec string changes, which the broadcast serves under a new track name. */ export class Encoder { /** The full track name of this rendition, e.g. `"video/hd"`. */ diff --git a/quest/m1/README.md b/quest/m1/README.md index 0234287b9db..b087d03ddc8 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -101,7 +101,7 @@ above them. - [JSON window timestamps](/quest/m1/json-window-timestamps.md) - Rust and JS window consumers return each event's frame timestamp like the snapshot and stream consumers - [JavaScript FETCH](/quest/m1/js-fetch.md) - browser publishers answer IETF FETCH through the JS ranges request surface - [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - native FILL_TIMEOUT bounds upstream waiting on drafts 18-22, alongside local content-age enforcement -- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher resizes in band below its ceiling or mints a new rendition name +- [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - `moq-mux` refuses a redefined rendition, and every Rust publisher mints a new rendition name for a decoder identity change and updates everything else in place - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; the catalog entry and format may break in place, since no archives exist - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [IETF epochs](/quest/m1/ietf-epochs.md) - negotiate explicit broadcast identity on drafts 17-22, retaining unchanged paths and epochless clients on updated relays diff --git a/quest/m1/catalog-identity-rs.md b/quest/m1/catalog-identity-rs.md index cdee5a24d9c..5f5eac72145 100644 --- a/quest/m1/catalog-identity-rs.md +++ b/quest/m1/catalog-identity-rs.md @@ -3,41 +3,51 @@ ## Goal No Rust publisher redefines a track it has published. A change to a -rendition's identity (`codec`, `description`, `container`, `sample_rate`, -`channel_count`) or growth past its `coded_width`/`coded_height` ceiling -publishes a new rendition name, per the hang draft's -[Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) section, and -`bitrate`/`framerate` never fall. A replayed archive catalog then describes -every group of the tracks it lists. +rendition's identity, the fields a consumer cannot change without +reinitializing its decoder or demuxer (`codec`, `description`, `container`, +`display_aspect_width`/`display_aspect_height`, `sample_rate`, +`channel_count`), publishes a new rendition name, per the hang draft's +[Track Identity](/drafts/draft-lcurley-moq-hang.md#identity) section. Every +other field, including coded size, `bitrate`, and `framerate`, updates in +place. A replayed archive catalog then describes every group of the tracks it +lists. ## Plan - ✅ Decided 2026-10-13 (maintainer, #5459): split from the catalog identity - quest. The contract, docs, and the `js/publish` ceiling landed there; - `@moq/publish` enforces the browser side. + quest. The contract and docs landed there; `@moq/publish` enforces the + browser side. - ✅ Decided 2026-10-13 (maintainer, #5459): the Opus description is probed - up front, and the `js/publish` video ceiling keeps growing per side. + up front. - ✅ Decided 2026-10-15 (maintainer): mint a new rendition name, not a new epoch; reverses the #5459 decision. Restarting the whole broadcast is an extreme response to one rendition changing, and a name always means the same content, so changed content gets a new name. `@moq/publish` keeps the - catalog ID and points its `track` at a fresh name (#5552); each Rust - violator mints a name likewise, as FLV, HLS import, and transcode rungs + catalog ID and points its `track` at the next free `/` (#5552); each + Rust violator mints a name likewise, as FLV, HLS import, and transcode rungs already do. A new epoch stays reserved for a whole-broadcast restart. +- ✅ Decided 2026-10-15 (maintainer): only fields that force a decoder + reinitialization mint a new track; everything else updates in place. New + tracks have user impact, so they are minted only when they must be. This + drops the #5459 coded-size ceilings and the rule that `bitrate`/`framerate` + never fall: a decoder follows a resize in band, and `codec` names a level + covering every picture, so outgrowing the size takes a new name only when + the codec string changes. Guard `moq-mux` `Rendition::set`, `replace`, and `stage` -(`rs/moq-mux/src/catalog/tracks.rs`): refuse a change to identity or a set -ceiling, and keep `resolved`'s `bitrate`/`framerate` from falling. Then fix -each violator the 2026-10-13 audit found: +(`rs/moq-mux/src/catalog/tracks.rs`): refuse a change to identity, and let +every other field update. Then fix each violator the 2026-10-13 audit found, +minting a name where the identity changes and updating in place otherwise: - The h264/h265 importers (an inline SPS, or a new avcC/hvcC through `initialize`), av1 (a new sequence header, including the av1C defaults and the minimal placeholder), and vp8/vp9 (keyframe size; VP9's level follows it) rewrite in place, including MPEG-TS and `moq-rtc` ingest through them. - Below the ceiling, resolution changes in band with no catalog update. + A size change alone updates in place; a new description or codec string + mints a name. - A codec hint published up front is republished with the real - profile/level and size by the first keyframe. Publish once from the stream, - or treat the hint as the ceiling. + profile/level by the first keyframe. Publish once from the stream, or mint + when the codec string differs. - `moq-video` reopening onto another camera mode or backend (`source_resize_updates_the_published_rendition` asserts the rewrite). - `moq-c` re-declaring a config under the same name, and `moq-gst` reusing an @@ -45,20 +55,16 @@ each violator the 2026-10-13 audit found: - TS AAC/MP2/AC-3 and FLV legacy audio fix the config from the first header and silently ignore a later change; refuse or mint instead. A PMT that changes a PID's stream type keeps the old importer. -- `moq-audio`/`moq-ffi` `set_bitrate` raising past the advertised bitrate - should raise the catalog. +- `moq-audio`/`moq-ffi` `set_bitrate` should update the catalog's bitrate. FLV, fMP4, MKV, HLS import, and `moq-transcode` rungs already comply; keep -their tests. Update the tests that assert in-place rewrites (`tracks.rs`, -`moq-video`, `moq-gst`, `moq-c`, and the transcode fixture's same-name -resize). +their tests. Update the tests that assert in-place identity rewrites +(`tracks.rs`, `moq-video`, `moq-gst`, `moq-c`); a same-name resize, such as +the transcode fixture's, is now allowed. Later, at lower priority: watch and HLS export consumer coverage for in-band resolution changes, rendition switches, reconnects, and late joiners against -the contract. Watch's `renditionJitter` fallback -(`js/watch/src/video/playhead.ts`) reads `framerate` as the live cadence, but it -is now a maximum, so a stream running below it gets less than a frame interval -of buffer when no `jitter` is advertised. +the contract. ## Related diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 7f11d192ca3..01382bb0ea6 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -127,7 +127,7 @@ pub struct AudioConfig { #[serde(rename = "numberOfChannels")] pub channel_count: u32, - // The maximum bitrate of the audio track in bits per second. It only rises. + // The maximum bitrate of the audio track in bits per second. #[serde(default)] pub bitrate: Option, diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index b503aa8ef3a..581913e3411 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -227,7 +227,7 @@ pub struct VideoConfig { pub display_aspect_height: Option, // TODO color space - /// The maximum bitrate of the video track, if known. It only rises. + /// The maximum bitrate of the video track, if known. #[serde(default)] pub bitrate: Option, @@ -239,7 +239,7 @@ pub struct VideoConfig { )] pub enabled: bool, - /// The maximum frame rate of the video track, if known. It only rises. + /// The maximum frame rate of the video track, if known. #[serde(default)] pub framerate: Option, From 62bd461e8260151896dc4938ede77a1889850128 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:13:31 -0700 Subject: [PATCH 09/13] docs(publish): reserve rendition replacement track names Application tracks on net share the namespace, so names under a rendition's ID are reserved for its replacements rather than tracked through the network broadcast's private state. Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/publish.md | 6 ++++-- js/publish/src/broadcast.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 2a7591748ea..02d2cf111a5 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -71,8 +71,10 @@ timeline. The catalog's `clock` maps it to wall time. Stamp your own tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application can serve its own tracks alongside the media. It is recreated when the origin, name, `enabled`, or `maxAge` changes, so acquire it from an effect and create each -track once per `net`. A track name always means the same content, so a track -re-created on the same `net` throws on an append that would reuse its sequences: +track once per `net`. Names under a rendition's ID, such as `video/1`, are +reserved for that rendition's replacement tracks. A track name always means +the same content, so a track re-created on the same `net` throws on an append +that would reuse its sequences: ```ts import * as Json from "@moq/json"; diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 779476d52fc..50b8a6de93e 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -72,6 +72,7 @@ export class Broadcast { // and is `undefined` in between. It lives in the origin rather than any session, so it spans // reconnects. Exposed so an application can serve its own tracks alongside the built-in // catalog/audio/video, e.g. `net.createTrack("meta.json")` plus a matching `catalog` section. + // Names under a rendition's ID (`video/1`) are reserved for that rendition's replacement tracks. // Reacquire it via an effect, since a rename swaps in a fresh producer. readonly net = new Signal(undefined); @@ -252,8 +253,9 @@ export class Broadcast { } // The next unused `/` track name in the current network broadcast. This publisher owns the - // namespace, so skipping every name it has served, registered, or listed in the catalog (including - // an application's own sections) is enough to never reuse one. + // namespace and reserves `/` for the rendition (see `net`), so skipping every name it has + // served, registered, or listed in the catalog (including an application's own sections) is + // enough to never reuse one. #mint(id: string): string { const taken = new Set([ ...(this.#served?.keys() ?? []), From 3cf0b209f791b7e3af3a4da72df33dc926b6b26a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:21:27 -0700 Subject: [PATCH 10/13] fix(publish): skip a removed rendition's track when minting names A rendition removed before it had a config keeps its network track, so the counter handed its name to a replacement and createTrack threw. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 23 +++++++++++++++++++++++ js/publish/src/broadcast.ts | 2 ++ 2 files changed, 25 insertions(+) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 886478d54f4..d166ebcf2c3 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -495,6 +495,29 @@ test("a minted track name skips names already in the catalog", async () => { broadcast.close(); }); +// A removed rendition keeps its network track, so its name stays taken even if it never had a config. +test("a minted track name skips a removed rendition's track", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("removed.hang") }); + const removed = broadcast.video("video/1"); + await settle(); + removed.close(); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + + video.config.set({ ...videoConfig, codec: "vp8" }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/2"); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected a network producer once connected"); + const subscriber = net.track("video/2").subscribe(); + await settle(); + expect(video.track.peek()?.name).toBe("video/2"); + + subscriber.close(); + broadcast.close(); +}); + // The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. test("mutating a shared container still takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 50b8a6de93e..37270a38622 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -261,6 +261,8 @@ export class Broadcast { ...(this.#served?.keys() ?? []), ...this.#listed.keys(), ...Object.keys(this.#renditions.peek()), + // Every ID that held a track here, even one removed before it had a config. + ...this.#kinds.keys(), ...Object.values(this.#names.peek()), ]); // A no-op edit is the catalog's only read; it publishes nothing. From c56b280b7db56cf3a228a9e8b89503d5f0b39180 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:29:22 -0700 Subject: [PATCH 11/13] fix(publish): refuse a rendition ID that names a replacement track Registering a minted name as a new rendition pointed two renditions at one track, and the network loop stopped at the duplicate. Found by Codex review. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 23 +++++++++++++++++++++++ js/publish/src/broadcast.ts | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index d166ebcf2c3..d62cca1a1f6 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -518,6 +518,29 @@ test("a minted track name skips a removed rendition's track", async () => { broadcast.close(); }); +// A minted name belongs to its rendition's media, so registering it as an ID fails loud. +test("registering a replacement track's name throws", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("owned.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + video.config.set({ ...videoConfig, codec: "vp8" }); + await settle(); + video.config.set({ ...videoConfig, codec: "vp9" }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/2"); + + // Both the current replacement and a retired one stay owned. + expect(() => broadcast.video("video/2")).toThrow("replacement track"); + expect(() => broadcast.video("video/1")).toThrow("replacement track"); + // A sibling still registers and serves. + broadcast.audio("audio").config.set(audioConfig); + await settle(); + expect((await readCatalog(broadcast))?.audio?.renditions.audio?.codec).toBe("opus"); + + broadcast.close(); +}); + // The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. test("mutating a shared container still takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 37270a38622..5c33799e43b 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -155,6 +155,11 @@ export class Broadcast { if (previous !== undefined && previous !== kind) { throw new Error(`rendition ${name} was registered as ${previous}, not ${kind}`); } + // A replacement track holds or held the name, so another rendition's media owns it. + const replaced = this.#served?.has(name) && !this.#kinds.has(name); + if (replaced || Object.values(this.#names.peek()).includes(name)) { + throw new Error(`rendition ${name} names another rendition's replacement track`); + } const track = new Signal(undefined); this.#tracks.set(name, track); From 78d30a996794131793ff99a2f07c4cd36ab63a22 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:41:32 -0700 Subject: [PATCH 12/13] fix(publish): reserve unserved replacement names and keep aliases apart A replacement minted before its rendition had a config was never served, so a later mint could reuse it and a rendition could register under it. Record every minted name for the network broadcast's life. The estimate check keyed entries by track alone, so two catalog IDs aliasing one track collapsed and a falling estimate on one slipped through. Key each entry by section, ID, broadcast, and track. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 32 +++++++++++++++++++++ js/publish/src/broadcast.ts | 14 ++++++---- js/publish/src/catalog.test.ts | 21 ++++++++++++++ js/publish/src/catalog.ts | 48 +++++++++++++++----------------- 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index d62cca1a1f6..33cad4e60db 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -541,6 +541,38 @@ test("registering a replacement track's name throws", async () => { broadcast.close(); }); +// A replacement minted before its rendition ever had a config is never served, but still owns its name. +test("a replacement track name is never reused, even one never served", async () => { + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("unserved.hang") }); + let video = broadcast.video("video"); + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected a network producer once connected"); + + // Fail the rendition on its current track, then register a replacement for it. + const fail = async (name: string) => { + const sub = net.track(name).subscribe(); + await settle(); + video.track.peek()?.close(new Error("encoder failed")); + video.close(); + await settle(); + sub.close(); + video = broadcast.video("video"); + await settle(); + }; + await fail("video"); + await fail("video/1"); + await fail("video/2"); + expect(net.track("video/3").subscribe()).toBeDefined(); + expect(() => broadcast.video("video/1")).toThrow("replacement track"); + + video.config.set(videoConfig); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/3"); + + broadcast.close(); +}); + // The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. test("mutating a shared container still takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 5c33799e43b..8123b2fe488 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -98,6 +98,9 @@ export class Broadcast { // catalog ID under a fresh track name, so a name always means the same content. readonly #names = new Signal>({}); + // Every replacement track name minted in the current network broadcast, even one never served. + #replacements = new Set(); + // The decoder identity of each track name the current network broadcast's catalog has served, or // undefined before it serves one. Kept across a rendition's removal, so a name returning with // different media takes a fresh name too. @@ -156,8 +159,7 @@ export class Broadcast { throw new Error(`rendition ${name} was registered as ${previous}, not ${kind}`); } // A replacement track holds or held the name, so another rendition's media owns it. - const replaced = this.#served?.has(name) && !this.#kinds.has(name); - if (replaced || Object.values(this.#names.peek()).includes(name)) { + if (this.#replacements.has(name)) { throw new Error(`rendition ${name} names another rendition's replacement track`); } @@ -263,12 +265,11 @@ export class Broadcast { // enough to never reuse one. #mint(id: string): string { const taken = new Set([ + ...this.#replacements, ...(this.#served?.keys() ?? []), - ...this.#listed.keys(), ...Object.keys(this.#renditions.peek()), // Every ID that held a track here, even one removed before it had a config. ...this.#kinds.keys(), - ...Object.values(this.#names.peek()), ]); // A no-op edit is the catalog's only read; it publishes nothing. this.catalog.mutate((catalog) => { @@ -287,7 +288,9 @@ export class Broadcast { }); for (let n = 1; ; n++) { const name = `${id}/${n}`; - if (!taken.has(name)) return name; + if (taken.has(name)) continue; + this.#replacements.add(name); + return name; } } @@ -324,6 +327,7 @@ export class Broadcast { // again finds its track; the catalog's stay out of reach of a colliding rendition. const tracks = new Map }>(); this.#names.set({}); + this.#replacements = new Set(); this.#served = undefined; const kinds = new Map(); this.#kinds = kinds; diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 4a08803857c..c802272e69e 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -457,6 +457,27 @@ describe("estimate publication window", () => { }); }); +// Each ID's estimate is checked on its own, even when two IDs alias one track. +test("catalog refuses a falling estimate on one alias of a track", () => { + const catalog = new CatalogProducer(); + const alias = (jitter: number) => ({ + codec: "opus", + container: { kind: "legacy" as const }, + sampleRate: Catalog.u53(48000), + numberOfChannels: Catalog.u53(2), + track: "audio", + jitter: Catalog.u53(jitter), + }); + catalog.mutate((value) => { + value.audio = { renditions: { en: alias(100), es: alias(100) } }; + }); + expect(() => + catalog.mutate((value) => { + value.audio = { renditions: { en: alias(10), es: alias(100) } }; + }), + ).toThrow("jitter cannot decrease"); +}); + // A renamed rendition's new track starts its estimates over. test("catalog lets a renamed track start its estimates over", () => { const catalog = new CatalogProducer(); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 009c57314b1..92b523f06ab 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -33,14 +33,12 @@ export class CatalogProducer { Catalog.checkRenditions(value); // Estimates only rise for a track; a renamed rendition starts its new track over. for (const field of ["jitter", "delay"] as const) { - const previous = advertised(this.#value, field); - for (const [section, next] of Object.entries(advertised(value, field))) { - for (const [name, estimate] of Object.entries(next)) { - if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); - const before = previous[section]?.[name]; - if (before !== undefined && (estimate === undefined || estimate < before)) { - throw new Error(`${field} cannot decrease for an existing track`); - } + const previous = new Map(advertised(this.#value, field)); + for (const [track, estimate] of advertised(value, field)) { + if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); + const before = previous.get(track); + if (before !== undefined && (estimate === undefined || estimate < before)) { + throw new Error(`${field} cannot decrease for an existing track`); } } } @@ -114,25 +112,23 @@ function structure(catalog: Catalog.Root): Catalog.Root { type Advertised = { broadcast?: string; track?: string; jitter?: number; delay?: number }; -/** Every track's advertised `field`, by section and then the track's broadcast and name. */ -function advertised( - catalog: Catalog.Root, - field: "jitter" | "delay", -): Record> { - const pick = (tracks: Record | undefined) => - Object.fromEntries( - Object.entries(tracks ?? {}).map(([id, config]) => [ - JSON.stringify([config.broadcast, Catalog.track(id, config)]), - config[field], - ]), - ); - return { - audio: pick(catalog.audio?.renditions), - video: pick(catalog.video?.renditions), - text: pick(catalog.text?.renditions), - json: pick(catalog.json?.tracks), - binary: pick(catalog.binary?.tracks), +/** Every entry's advertised `field`, keyed by its section, ID, broadcast, and track name. */ +function advertised(catalog: Catalog.Root, field: "jitter" | "delay"): [string, number | undefined][] { + const sections = { + audio: catalog.audio?.renditions, + video: catalog.video?.renditions, + text: catalog.text?.renditions, + json: catalog.json?.tracks, + binary: catalog.binary?.tracks, }; + return Object.entries(sections).flatMap(([section, tracks]) => + Object.entries((tracks ?? {}) as Record).map( + ([id, config]): [string, number | undefined] => [ + JSON.stringify([section, id, config.broadcast, Catalog.track(id, config)]), + config[field], + ], + ), + ); } // An empty root advertising the wall time of `performance.now() === 0`, the zero every js/publish From f562ae6677d97486b006dda76b2bc8a12621f6f9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 11:01:26 -0700 Subject: [PATCH 13/13] fix(publish): refuse a rendition config that points at another track A rendition's media is the broadcast's own track, so a config naming another broadcast or track sent viewers to media this publisher never writes, and an identity change overwrote the target with a local replacement name. Co-Authored-By: Claude Opus 5.5 --- js/publish/src/broadcast.test.ts | 23 +++++++++++++++++++++++ js/publish/src/broadcast.ts | 5 +++++ js/publish/src/rendition.ts | 3 ++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 33cad4e60db..b0d0c5f0be7 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -573,6 +573,29 @@ test("a replacement track name is never reused, even one never served", async () broadcast.close(); }); +test("a rendition config pointing at another track is refused", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("remote.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + + // A remote reference would send viewers to media this publisher never writes. + video.config.set({ + ...videoConfig, + codec: "vp8", + broadcast: "source" as Catalog.RelativeBroadcast, + track: "video", + }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.codec).toBe(videoConfig.codec); + + video.config.set({ ...videoConfig, codec: "vp8" }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.codec).toBe("vp8"); + + broadcast.close(); +}); + // The served identity is a snapshot, so a producer mutating a container it shares can't rewrite it. test("mutating a shared container still takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("shared.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 8123b2fe488..68f940c8a01 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -204,6 +204,11 @@ export class Broadcast { for (const rendition of Object.values(renditions)) { const source = enabled ? effect.get(rendition.config) : undefined; if (source === undefined) continue; + // The rendition's media is this broadcast's own track, so its config can't point elsewhere. + const target = source as { broadcast?: string; track?: string }; + if (target.broadcast !== undefined || target.track !== undefined) { + throw new Error(`rendition ${rendition.name} config sets broadcast or track, which the broadcast owns`); + } let track = names[rendition.name] ?? rendition.name; // Everything else (bitrate, frame rate, coded size, ...) updates the served track in place. const media = identity(source); diff --git a/js/publish/src/rendition.ts b/js/publish/src/rendition.ts index eb5f79e49d1..0fe646a6038 100644 --- a/js/publish/src/rendition.ts +++ b/js/publish/src/rendition.ts @@ -22,7 +22,8 @@ export class Rendition { * `undefined` omits it from the catalog (e.g. while disabled). Changing a field a decoder must * reinitialize for (codec, description, container, sample rate, channel count, or display aspect) * ends the current track and serves the rendition under a fresh catalog `track` name, so a name - * always means the same content. Every other field updates the current track in place. + * always means the same content. Every other field updates the current track in place. The + * broadcast owns `broadcast` and `track`, so a config setting either is refused. */ readonly config = new Signal(undefined);