diff --git a/doc/concept/audio-jitter.md b/doc/concept/audio-jitter.md index 5926617f5dc..986ccbfbc04 100644 --- a/doc/concept/audio-jitter.md +++ b/doc/concept/audio-jitter.md @@ -337,6 +337,17 @@ reference itself only ever moves earlier, so once the earliest subscribed track leaves, the clock stays anchored to it until playback re-anchors; that errs on the safe side. +A track that replaces another in the same slot and on the same broadcast path, +such as the new name a codec change publishes under, continues the outgoing +track's histogram and startup ramp instead of the prior. The path's arrivals +did not change with the name; a track on another path starts from the prior. +Restarting would drop the target to the cold-start 100 ms at the switch, then +raise it as the lateness is relearned, and the playout follows both moves: +buffered audio is skipped on the drop and playback parks on each rise. Only the +reference window restarts, so the new track's frames are measured against its +own fastest. Its opening backlog would read as seconds late against the old +track's arrivals. + ## Rise, fall, startup, and the ceiling There is **no separate rise or fall limiter**. The histogram is the only diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index e25087e4105..033856ebab1 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -694,8 +694,19 @@ test("Consumer measures arrivals as they land", async () => { } expect(consumer.spread.peek()).toBeLessThan(100 as Milli); + // A replacement on the same path continues the estimate rather than restarting cold. + const next = new Track.Producer("next"); + const replacement = new Consumer(next.subscribe(), { + format: new LegacyFormat("audio"), + maxDelay: 5000 as Milli, + predecessor: consumer, + }); + expect(replacement.spread.peek()).toBe(consumer.spread.peek()); + track.close(); consumer.close(); + next.close(); + replacement.close(); }); // --- Ordering --- diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index 42028690817..9c909fcff4a 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -18,6 +18,13 @@ export interface ConsumerProps { */ // Read-only: a Getter (e.g. another component's output) is accepted directly. maxDelay?: GetterInit; + /** + * The consumer this one replaces on the same path, such as the previous rendition. + * + * Its arrival estimate carries over instead of restarting from the cold-start prior, so a + * switch neither lowers nor raises {@link Consumer.spread} on its own. + */ + predecessor?: Consumer; } /** One item of a {@link Consumer.next} read, in delivery order. Mirrors Rust's `container::Read`. */ @@ -122,15 +129,15 @@ export class Consumer { /** The time ranges currently buffered and ready to play. */ readonly buffered: Getter = this.#buffered; - #jitter = new Jitter(); - #spread = new Signal(this.#jitter.measured); + #jitter: Jitter; + #spread: Signal; /** * How much buffer late arrivals need, measured as frames come off the transport. * * The measured term of doc/concept/audio-jitter.md, excluding the codec frame and any advertised - * floor. Starts at the cold-start estimate before any frame arrives. + * floor. Starts at the predecessor's estimate, or the cold-start estimate without one. */ - readonly spread: Getter = this.#spread; + readonly spread: Getter; #signals = new Effect(); #closed = new Once(); @@ -141,6 +148,9 @@ export class Consumer { this.#track = track; this.#format = props.format; this.#maxDelay = getter(props.maxDelay ?? Milli.zero); + this.#jitter = props.predecessor ? props.predecessor.#jitter.fork() : new Jitter(); + this.#spread = new Signal(this.#jitter.measured); + this.spread = this.#spread; this.#signals.spawn(this.#run.bind(this)); this.#signals.cleanup(() => { diff --git a/js/hang/src/container/jitter.test.ts b/js/hang/src/container/jitter.test.ts index 327638aaa48..bcc0de58eb6 100644 --- a/js/hang/src/container/jitter.test.ts +++ b/js/hang/src/container/jitter.test.ts @@ -93,4 +93,24 @@ describe("Jitter", () => { } expect(far.measured).toBe(near.measured); }); + + it("forks what it learned, but measures the replacement's arrivals against their own", () => { + // A path where every tenth frame lands 300ms late. + const warm = new Jitter(); + for (let i = 0; i < 500; i++) { + warm.observe((i * 20 + (i % 10 === 0 ? 300 : 0)) as Milli, (i * 20) as Milli); + } + const learned = warm.measured; + expect(learned).toBeGreaterThan(100 as Milli); + + const fork = warm.fork(); + expect(fork.measured).toBe(learned); + + // The replacement opens with a backlog: older media arriving at once, long after the warm + // track's frames. Measured against the warm track's arrivals it would read as seconds late. + for (let i = 0; i < 100; i++) + fork.observe((20_000 + Math.floor(i / 50) * 1000) as Milli, (8000 + i * 20) as Milli); + expect(fork.measured).toBeLessThanOrEqual(learned); + expect(warm.measured).toBe(learned); + }); }); diff --git a/js/hang/src/container/jitter.ts b/js/hang/src/container/jitter.ts index aa27973d354..25ab8e9f07f 100644 --- a/js/hang/src/container/jitter.ts +++ b/js/hang/src/container/jitter.ts @@ -106,6 +106,18 @@ export class Jitter { this.#peak = delay; } + /** + * A new estimator that keeps what this one learned but measures arrivals afresh, for a track that + * replaces this one on the same path. Its frames are measured against their own fastest, never + * against this track's. + */ + fork(): Jitter { + const fork = new Jitter(); + fork.#histogram = Float64Array.from(this.#histogram); + fork.#count = this.#count; + return fork; + } + /** The measured term: the upper edge of the bucket holding the 95th percentile. */ get measured(): Milli { let cumulative = 0; diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts index fb0125aebbb..ed8f49defe5 100644 --- a/js/publish/src/preview.ts +++ b/js/publish/src/preview.ts @@ -155,14 +155,21 @@ export class Renderer { }); }); + // Not raced against teardown, which would drop a frame whose read had already resolved. effect.spawn(async () => { for (;;) { - const next = await effect.race(reader.read()); - if (!next?.value) break; + const { value: frame } = await reader.read(); + if (!frame) break; + + // The cleanup above already released the latest frame, so keep nothing past it. + if (effect.abort.aborted) { + frame.close(); + break; + } this.#latest.update((prev) => { prev?.close(); - return next.value; + return frame; }); } }); @@ -265,14 +272,15 @@ export class Transcode { reader.cancel().catch(() => {}); }); + // Not raced against teardown, which would drop a frame whose read had already resolved. inner.spawn(async () => { for (;;) { - const next = await inner.race(reader.read()); - if (!next?.value) break; + const { value: frame } = await reader.read(); + if (!frame) break; // Ours now, so close it once the encoder has taken what it needs. - const frame = next.value; try { + if (inner.abort.aborted) break; if (encoder.state !== "configured") continue; // Mirror Encoder.serve: default to a 2s GOP unless the rendition overrides it. diff --git a/js/publish/src/ui/components/stats-tab.ts b/js/publish/src/ui/components/stats-tab.ts index 8c67139650e..21e916ba1ef 100644 --- a/js/publish/src/ui/components/stats-tab.ts +++ b/js/publish/src/ui/components/stats-tab.ts @@ -114,13 +114,14 @@ export function statsTab(parent: Effect, publish: MoqPublish): HTMLElement { reader.cancel().catch(() => {}); }); + // Not raced against teardown, which would drop a frame whose read had already resolved. effect.spawn(async () => { for (;;) { - const next = await effect.race(reader.read()); - if (!next?.value) break; + const { value: frame } = await reader.read(); + if (!frame) break; frames++; - next.value.close(); + frame.close(); } }); }); diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 626db0473b8..8c20731bf3b 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -8,6 +8,7 @@ import type { StreamTrack } from "./types"; mock.module("./capture-worker.ts?worklet", () => ({ default: async () => "blob:fake-worker" })); const { Capture } = await import("./capture"); const { Encoder } = await import("./encoder"); +const { Renderer } = await import("../preview"); class Frame { // Every frame built, so a test can assert none is left open. @@ -185,8 +186,9 @@ function frameSource() { }; } -// Let the capture pump drain everything pushed so far. -const flush = () => new Promise((resolve) => setTimeout(resolve, 10)); +// Let the capture pump drain everything pushed so far. The pipeline runs on promises alone, so one +// macrotask turn settles every microtask queued before it, with no wall-clock wait. +const flush = () => new Promise((resolve) => setImmediate(resolve)); const open = () => Frame.all.filter((frame) => !frame.closed); @@ -462,3 +464,101 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi else Reflect.deleteProperty(globalThis, "VideoEncoder"); } }); + +// Detaches a reader at every microtask offset after a push, since a demand gap or a hidden preview can +// land anywhere while a frame crosses the capture. A frame already read is the reader's to close, even +// when the teardown wins, so only the capture's held picture stays open between readers. +async function sweep(input: ReturnType, attach: (attached: boolean) => void): Promise { + let timestamp = 2_000_000; + for (let yields = 0; yields < 40; yields++) { + attach(true); + await flush(); + + timestamp += 100_000; + input.push(timestamp); + for (let i = 0; i < yields; i++) await Promise.resolve(); + attach(false); + await flush(); + + expect({ yields, open: open().length }).toEqual({ yields, open: 1 }); + } +} + +test("a preview hidden while a frame is in flight closes that frame", async () => { + using input = frameSource(); + const capture = new Capture({ source: input.source }); + const enabled = new Signal(false); + const preview = new Renderer({ frames: capture.out.frames, enabled }); + + try { + input.push(1_000_000); + await flush(); + await sweep(input, (attached) => enabled.set(attached)); + } finally { + preview.close(); + capture.close(); + } + expect(open()).toEqual([]); +}); + +test("an encoder torn down while a frame is in flight closes that frame", async () => { + using input = frameSource(); + + class NullVideoEncoder { + state: CodecState = "unconfigured"; + #output: VideoEncoderInit["output"]; + #codec?: string; + + constructor(init: VideoEncoderInit) { + this.#output = init.output; + } + + static async isConfigSupported(config: VideoEncoderConfig): Promise<{ supported: boolean }> { + return { supported: config.codec.startsWith("avc1") }; + } + + configure(config: VideoEncoderConfig): void { + this.state = "configured"; + this.#codec = config.codec; + } + + encode(): void {} + + // Only the probe flushes; report the configured codec, as Chrome does. + async flush(): Promise { + const chunk = { type: "key", timestamp: 0, byteLength: 1, copyTo: () => {} }; + this.#output(chunk as never, { decoderConfig: { codec: this.#codec } } as never); + } + + close(): void { + this.state = "closed"; + } + } + const original = Object.getOwnPropertyDescriptor(globalThis, "VideoEncoder"); + Object.defineProperty(globalThis, "VideoEncoder", { configurable: true, value: NullVideoEncoder, writable: true }); + + const capture = new Capture({ source: input.source }); + const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); + const live = new Signal(undefined); + const rendition = { config: new Signal(undefined), track: live, close: () => {} }; + const encoder = new Encoder("video", { + enabled: true, + broadcast: { video: () => rendition, baseline: new Baseline() } as never, + capture, + }); + + try { + input.push(1_000_000); + for (let i = 0; i < 50 && !encoder.out.resolved.peek(); i++) await flush(); + expect(encoder.out.resolved.peek()).toBeDefined(); + + await sweep(input, (attached) => live.set(attached ? track : undefined)); + } finally { + encoder.close(); + capture.close(); + track.close(); + if (original) Object.defineProperty(globalThis, "VideoEncoder", original); + else Reflect.deleteProperty(globalThis, "VideoEncoder"); + } + expect(open()).toEqual([]); +}); diff --git a/js/publish/src/video/capture.ts b/js/publish/src/video/capture.ts index be02b419449..2f900cceac9 100644 --- a/js/publish/src/video/capture.ts +++ b/js/publish/src/video/capture.ts @@ -189,6 +189,7 @@ class Frames extends Fanout { effect.cleanup(release); const reader = live.getReader(); + let cancelled = false; return new ReadableStream( { pull: async (controller) => { @@ -203,7 +204,13 @@ class Frames extends Fanout { for (;;) { const { value } = await reader.read(); if (!value) { - controller.close(); + if (!cancelled) controller.close(); + return; + } + + // Cancelled while this read was resolving, so the stream can no longer take it. + if (cancelled) { + value.close(); return; } @@ -217,6 +224,7 @@ class Frames extends Fanout { } }, cancel: async (reason) => { + cancelled = true; release(); await reader.cancel(reason); }, diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 7f6ba926e5b..fbb092e4733 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -426,7 +426,8 @@ export class Encoder { if (!fanout) return; // Our own stream off the shared capture, so the preview or another rendition reading - // slowly can't take frames from this one. + // slowly can't take frames from this one. Teardown ends a pending read by cancelling it; + // racing the read against teardown would drop a frame that had already resolved, unclosed. const reader = fanout.subscribe(effect).getReader(); effect.cleanup(() => { reader.cancel().catch(() => {}); @@ -434,12 +435,12 @@ export class Encoder { effect.spawn(async () => { for (;;) { - const next = await effect.race(reader.read()); - if (!next?.value) break; + const { value: frame } = await reader.read(); + if (!frame) break; // Ours now: every path below has to close it. - const frame = next.value; try { + if (effect.abort.aborted) break; if (encoder.state !== "configured") continue; // This doesn't need to be reactive. diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index 42e6fa32340..5bf2acdfaa7 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, spyOn } from "bun:test"; -import { Manual, Micro, Milli, Nano, now } from "@moq/clock"; +import { Manual, Micro, Milli, Nano, now, type Second } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; import * as Moq from "@moq/net"; -import { type Effect, Signal } from "@moq/signals"; +import { Effect, Signal } from "@moq/signals"; +import { ringSamples } from "../audio/latency"; +import { AudioRingBuffer } from "../audio/ring-buffer"; import type { Broadcast } from "../broadcast"; import { type Delay, Sync } from "../sync"; import { Decoder } from "./decoder"; @@ -196,7 +198,9 @@ function sample(group: number, timestamp: number, keyframe = true): Read { } const gap: Read = { kind: "gap", timestamp: Micro(0), duration: Micro(0) }; -async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: readonly number[] = []) { +// `reads` stands in for the container consumer, unless `live`, which reads what tests publish into +// `producer` through real consumers instead. +async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: readonly number[] = [], live = false) { const originalDecoder = Object.getOwnPropertyDescriptor(globalThis, "VideoDecoder"); const originalChunk = Object.getOwnPropertyDescriptor(globalThis, "EncodedVideoChunk"); const submitted: number[] = []; @@ -207,6 +211,7 @@ async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: rea const errors: VideoDecoderInit["error"][] = []; const frames: VideoDecoderInit["output"][] = []; const next = spyOn(Container.Consumer.prototype, "next").mockImplementation(async () => reads.shift()); + if (live) next.mockRestore(); class Codec { state = "unconfigured"; // Like WebCodecs, a configure requires a key chunk before any delta. @@ -286,6 +291,9 @@ async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: rea const delay = new Signal("instant"); const sync = new Sync({ delay }); const enabled = new Signal(true); + // Published before the decoder subscribes, since a broadcast with no request handler refuses + // an unknown track. + const track = live ? mediaProducer.createTrack("video", { timescale: Moq.Time.Timescale.MILLI }) : undefined; const decoder = new Decoder({ source, sync, enabled }); // Opened before the reads below, so it observes every one of them. const arrivals = decoder.arrivals(); @@ -304,6 +312,11 @@ async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: rea }, enabled, catalog, + // The broadcast the rendition reads, and its first track, for a live playback to publish into. + get producer() { + return mediaProducer; + }, + track, submitted, outputs, sync, @@ -327,11 +340,18 @@ async function guardedPlayback(kind: "legacy" | "cmaf", reads: Read[], hold: rea producer = new Moq.Broadcast.Producer(); active.set(producer.consume()); }, - // Only the media the rendition reads restarts, under the same catalog. - republishMedia() { + // Only the media the rendition reads restarts, under the same catalog, or moves to `path`. A + // live playback gets its video track back, published before the decoder asks for it. + republishMedia(path?: string): Moq.Track.Producer | undefined { media.peek().close(); - mediaProducer = new Moq.Broadcast.Producer(); + mediaProducer = path + ? new Moq.Origin.Producer().createBroadcast(Moq.Path.from(path)) + : new Moq.Broadcast.Producer(); + const track = live + ? mediaProducer.createTrack("video", { timescale: Moq.Time.Timescale.MILLI }) + : undefined; media.set(mediaProducer.consume()); + return track; }, close() { decoder.close(); @@ -800,3 +820,144 @@ for (const kind of ["legacy", "cmaf"] as const) { } }); } + +// A legacy container frame: the media timestamp, then one payload byte. +function legacyFrame(media: Micro): Moq.Group.Frame { + const timestamp = Moq.Varint.encode(media); + const payload = new Uint8Array(timestamp.byteLength + 1); + payload.set(timestamp, 0); + return { payload, timestamp: Moq.Time.Timestamp.fromMicros(media) }; +} + +// A live video track on a manual clock: one frame per group every 33ms, flushed in runs of `burst`, +// so each run's first frame lands that much late. A replacement track continues the same timeline. +function liveVideo(clock: ReturnType, track: Moq.Track.Producer, burst: number) { + const FRAME = 33; + let frame = 0; + let sequence = 0; + let held: number[] = []; + return { + burst, + replace(next: Moq.Track.Producer) { + track = next; + sequence = 0; + }, + // Advances the clock by `step` to `t`, publishing every frame due by then. + async advance(t: number, step: number) { + await clock.advance(step); + for (; frame * FRAME <= t; frame++) { + held.push(frame * FRAME); + if (held.length < this.burst) continue; + for (const media of held) { + const group = new Moq.Group.Producer(sequence++); + group.writeFrame(legacyFrame(Micro.fromMilli(Milli(media)))); + group.close(); + track.writeGroup(group); + } + held = []; + } + await new Promise((resolve) => setImmediate(resolve)); + await microtasks(); + }, + }; +} + +// A codec change renames the video track, and the replacement's consumer starts reading the same +// path. Restarting its arrival estimate from the cold-start prior dropped the shared "auto" delay +// at promotion and climbed back as the new track relearned the lateness, so the audio ring +// skipped ahead on the drop and went silent refilling on each rise. moq-dev/moq#5635. +it("a renamed video track keeps the delay, so audio neither skips nor goes silent", async () => { + const clock = manualAt(0); + const playback = await guardedPlayback("legacy", [], [], true); + + // The postMessage ring every page without cross-origin isolation plays from, following the + // shared delay the way the audio decoder drives the worklet. + const RATE = 48_000; + const STEP = 20; // ms of audio inserted and rendered per tick, one Opus frame + const samples = (RATE * STEP) / 1000; + const ring = new AudioRingBuffer({ rate: RATE, channels: 1, latency: Milli(STEP) }); + const follow = new Effect(); + follow.subscribe(playback.sync.out.delay, (delay) => { + ring.resize(Milli.fromSecond((ringSamples(RATE, delay) / RATE) as Second)); + }); + + // Flushed in runs of 12, so each run's first frame lands ~360ms late. + const video = liveVideo(clock, playback.track as Moq.Track.Producer, 12); + + let inserted = 0; + let played = 0; + let silent = 0; + const output = [new Float32Array(samples)]; + + const run = async (from: number, until: number) => { + for (let t = from + STEP; t <= until; t += STEP) { + await video.advance(t, STEP); + ring.write(Micro.fromMilli(Milli(t - STEP)), [new Float32Array(samples).fill(0.5)]); + inserted += samples; + const count = ring.read(output); + played += count; + if (count < samples) silent++; + } + }; + + try { + playback.delay.set("auto"); + await microtasks(); + + // Long enough for the estimate to settle on the lateness this path shows. + await run(0, 20_000); + const delay = playback.sync.out.delay.peek(); + expect(delay).toBeGreaterThan(Milli(300)); + + const before = { inserted, played, buffered: ring.length }; + silent = 0; + + (playback.track as Moq.Track.Producer).close(); + playback.switchRendition("video/1"); + video.replace(playback.producer.createTrack("video/1", { timescale: Moq.Time.Timescale.MILLI })); + await run(20_000, 26_000); + + // The replacement read the new track and showed it. + expect(playback.decoder.out.timestamp.peek()).toBeGreaterThan(Milli(25_000)); + const skipped = inserted - before.inserted - (played - before.played) - (ring.length - before.buffered); + expect({ skipped, silent }).toEqual({ skipped: 0, silent: 0 }); + expect(playback.sync.out.delay.peek()).toBeGreaterThanOrEqual(delay); + } finally { + follow.close(); + playback.close(); + await microtasks(); + clock.restore(); + } +}); + +// Another path's arrivals say nothing about this one's, so a rendition read from elsewhere, such as +// through a `broadcast` override, learns its own estimate instead of inheriting the outgoing one. +it("a video track read from another path does not inherit the outgoing delay", async () => { + const clock = manualAt(0); + const playback = await guardedPlayback("legacy", [], [], true); + const video = liveVideo(clock, playback.track as Moq.Track.Producer, 12); + const run = async (from: number, until: number) => { + for (let t = from + 20; t <= until; t += 20) await video.advance(t, 20); + }; + + try { + playback.delay.set("auto"); + await microtasks(); + + await run(0, 20_000); + const warm = playback.sync.out.delay.peek(); + expect(warm).toBeGreaterThan(Milli(300)); + + // The same media from another path, where every frame lands on time. + video.replace(playback.republishMedia("elsewhere") as Moq.Track.Producer); + video.burst = 1; + await run(20_000, 22_000); + + expect(playback.decoder.out.timestamp.peek()).toBeGreaterThan(Milli(21_000)); + expect(playback.sync.out.delay.peek()).toBeLessThan(warm); + } finally { + playback.close(); + await microtasks(); + clock.restore(); + } +}); diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 6f9d9b57717..a65349c78c3 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -228,6 +228,11 @@ export class Decoder { this.#instance = { path: active.path, closed: active.closed }; if (republished) this.sync.reset(); + // Only a rendition read from the same path continues the outgoing arrival estimate: another + // path's arrivals have nothing to do with what this one learned. + const outgoing = this.#active.peek(); + const predecessor = outgoing?.broadcast.path === active.path ? outgoing.consumer : undefined; + // Start a new pending effect. let pending: DecoderTrack | undefined = new DecoderTrack({ sync: this.sync, @@ -238,6 +243,7 @@ export class Decoder { identity: key, stats: this.#out.stats, catalog: broadcast.out.catalog, + predecessor, }); this.#selected = pending; effect.run((inner) => { @@ -399,6 +405,9 @@ interface DecoderTrackProps { // The broadcast catalog this subscription started from. Updates on that broadcast still apply; // a later source does not. catalog: Getter; + // The consumer of the rendition on screen when this one was selected from the same path, whose + // arrival estimate it continues. + predecessor?: Container.Consumer; stats: Signal; } @@ -434,6 +443,11 @@ class DecoderTrack { // The playhead moved and the next decoded frame decides whether the decoder resets. #resetPending = false; + // The newest consumer reading this track, whose estimate a replacement continues. The predecessor + // is only held until the first one opens. + consumer?: Container.Consumer; + #predecessor?: Container.Consumer; + #signals = new Effect(); constructor(props: DecoderTrackProps) { @@ -445,10 +459,25 @@ class DecoderTrack { this.identity = props.identity; this.catalog = props.catalog; this.stats = props.stats; + this.#predecessor = props.predecessor; this.#signals.run(this.#run.bind(this)); } + // Reorders groups and frames up to the shared max delay. A rendition switch is not a cold start: + // the path's arrivals are what they were, so the estimate continues from whatever read before. + #consume(effect: Effect, sub: Moq.Track.Subscriber, format: Container.Format): Container.Consumer { + const consumer = new Container.Consumer(sub, { + format, + maxDelay: this.sync.out.maxDelay, + predecessor: this.consumer ?? this.#predecessor, + }); + effect.cleanup(() => consumer.close()); + this.consumer = consumer; + this.#predecessor = undefined; + return consumer; + } + #run(effect: Effect): void { const sub = subscribeMedia(effect, { broadcast: this.broadcast, @@ -533,12 +562,7 @@ class DecoderTrack { this.config.container.kind === "loc" ? new Container.Loc.Format("video") : new Container.Legacy.Format(this.config); - // Create consumer that reorders groups/frames up to the provided latency. - const consumer = new Container.Consumer(sub, { - format, - maxDelay: this.sync.out.maxDelay, - }); - effect.cleanup(() => consumer.close()); + const consumer = this.#consume(effect, sub, format); // Combine network jitter buffer with decode buffer effect.run((inner) => { @@ -623,11 +647,7 @@ class DecoderTrack { const init = Container.Cmaf.decodeInitSegment(initSegment); const description = this.config.description ? Util.Hex.toBytes(this.config.description) : init.description; - const consumer = new Container.Consumer(sub, { - format: new Container.Cmaf.Format(init), - maxDelay: this.sync.out.maxDelay, - }); - effect.cleanup(() => consumer.close()); + const consumer = this.#consume(effect, sub, new Container.Cmaf.Format(init)); // Combine network jitter buffer with decode buffer effect.run((inner) => { diff --git a/quest/m1/browser-rename-check.md b/quest/m1/browser-rename-check.md index 656b7cab5f4..2321be6555b 100644 --- a/quest/m1/browser-rename-check.md +++ b/quest/m1/browser-rename-check.md @@ -20,6 +20,43 @@ disabled; the size case must catch an unnecessary rename. Public API: none. Wire: none. +### Evidence + +2026-10-15: the existing media harness now exercises AVC to VP8, followed by +VP8 growing from 320x240 to 640x480. Presented pixels carry a source generation, +and readback checks actual decoded dimensions as well as the canvas. A focused +case keeps the content and lifecycle oracles while leaving the full run's +cold-start grading and unrelated controls explicitly unselected. + +The real-browser focused run minted `video/1`, presented the codec transition +at 30.0 fps with median/p95 skew 0/0 ms, and played afterwards at 29.9 fps with +0/200 ms skew. The resize kept `video/1`, reached 640x480 decoded and presented +frames, and measured 29.9 fps with 0/200 ms skew. No previous-generation picture +returned after the new generation started presenting. + +With the publisher's identity-minting block removed, the real-browser case +fails at the existing decode-error oracle: WebCodecs refuses a chunk marked as +key that is not a keyframe after reconfiguration. + +The whole oracle is not green. The original full run stopped before the new +case at cold-start audio tone (59/70 samples, below the unchanged 90% floor). +The focused run subsequently failed post-resize audio tone (67/75 samples), +with silent/stalled intervals. These observations do not establish their cause +or a connection to the isolated Opus scheduling finding. Keep this quest until +smooth playback is established with the complete applicable oracle; no timing, +tone or skew threshold has been relaxed. + +2026-10-16: the post-rename tone failure (CI: 71/81) was the replacement +track's jitter estimate restarting from its cold-start prior. The shared delay +dropped at promotion and climbed back, so the audio ring skipped and then +parked. A replacement consumer now continues its predecessor's estimate. Five +focused runs on a loaded machine (load 47 on 24 cores): three passed every +window; one failed coded-size growth tone (60/81) while the delay rose with +real video lateness on the same `video/1`; one failed on a subscribe update +racing the old track's finish (`stream aborted by the remote server`). + ## Related - [Rust identity enforcement](/quest/m1/catalog-identity-rs.md) - the same contract for the Rust publishers +- [Capture channel count and cold-start tone](/quest/m1/publish-audio-channel-count.md) - already owns the unpinned cold-start audio-tone symptom +- [Isolated Opus target](/quest/m1/audio-jitter-target/opus-isolated-target.md) - related playout stalls, with no shared cause established diff --git a/test/interop/README.md b/test/interop/README.md index 17424651b61..e8be61a2a6b 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -282,6 +282,11 @@ Each run covers, against a real local relay: within 1.5s of the fixture's painted frame within 2s, both bounds about twice the worst seen in looped runs under CPU load. A rejoin and a reattach cross the same demand gap, so they must reach live the same way. +- **codec identity and coded-size changes** - an AVC-to-VP8 change keeps the + broadcast and rendition ID while moving to a fresh track. Playback must keep + advancing and in sync through the change. A VP8 resize doubles the presented + dimensions on the same track. The pixels carry a source generation, so old + pictures cannot reappear after the new generation starts presenting. - **resources return to baseline** - the page wraps `WebTransport`, `WebSocket`, `AudioContext`, and `Worker` to count live instances, so a detach that leaks a session is visible rather than merely invisible. @@ -296,6 +301,11 @@ Each window opens once the player's audio buffer has filled and plays. Until then the player holds its first decoded frame, silent, for its sync delay, which rises past 400ms under load; that is startup, not a gap in playback. +Run a focused lifecycle case with `just test media --media-cases rename`. +It uses the same real fixture, player and content oracles while leaving cold-start +grading and the unrelated negative controls to the default full run. A focused +pass makes no claim about those omitted checks. + The run ends with negative controls. Each injects a defect and names the assertion that has to catch it, and passes only by failing there: diff --git a/test/interop/clients/js/media.ts b/test/interop/clients/js/media.ts index 0f4b71f3f06..fc8376d0d76 100644 --- a/test/interop/clients/js/media.ts +++ b/test/interop/clients/js/media.ts @@ -60,7 +60,7 @@ import * as Pattern from "./src/pattern"; import { resumeSkew } from "./src/resume"; /** Cases beyond the mandatory capability probe, publisher readiness, and cold start. */ -const CASES = ["capture-denial", "pause", "disable", "rejoin", "detach", "republish", "late-join"] as const; +const CASES = ["capture-denial", "pause", "disable", "rejoin", "detach", "republish", "late-join", "rename"] as const; type Case = (typeof CASES)[number]; const { values } = parseArgs({ @@ -70,6 +70,7 @@ const { values } = parseArgs({ timeout: { type: "string", default: "30" }, fault: { type: "string", default: "none" }, cases: { type: "string" }, + focused: { type: "boolean", default: false }, leak: { type: "boolean", default: false }, lag: { type: "boolean", default: false }, "expect-fail": { type: "string" }, @@ -91,10 +92,11 @@ if ( timeoutMs <= 0 || !FAULTS.some((f) => f === fault) || unknown.length > 0 || - !ENGINES.some((engine) => engine === requestedEngine) + !ENGINES.some((engine) => engine === requestedEngine) || + (values.focused && (values.cases === undefined || selected.size === 0)) ) { console.error( - `usage: media.ts --url U [--player-engine ${ENGINES.join("|")}] [--timeout S>0] [--fault ${FAULTS.join("|")}] [--cases none|${CASES.join(",")}] [--leak] [--lag] [--expect-fail TEXT]`, + `usage: media.ts --url U [--player-engine ${ENGINES.join("|")}] [--timeout S>0] [--fault ${FAULTS.join("|")}] [--cases none|${CASES.join(",")}] [--focused] [--leak] [--lag] [--expect-fail TEXT]`, ); process.exit(2); } @@ -305,6 +307,7 @@ function traceLine(sample: Sample, start: number): string { ` +${((sample.at - start) / 1000).toFixed(2)}s frame=${sample.frameId ?? "-"} ` + `step=${sample.toneStep ?? "-"}/${step} tone=${margin.toFixed(0)}dB ${sample.toneHz?.toFixed(0) ?? "-"}Hz ` + `rms=${sample.rms?.toFixed(3) ?? "-"} ats=${sample.audioTimestamp?.toFixed(0) ?? "-"} vts=${sample.videoTimestamp?.toFixed(0) ?? "-"} ` + + `generation=${sample.generation ?? "-"} track=${sample.videoTrack ?? "-"} codec=${sample.videoCodec ?? "-"} ` + `paused=${sample.paused} delay=${sample.delay}ms audio=${sample.audioBytes}B${sample.audioStalled ? " stalled" : ""}` ); } @@ -647,7 +650,65 @@ try { predicate: (state) => state.audioContext === "running", }); - await measurePlayback(player, playerErrors, "cold start"); + if (values.focused) { + console.error(" focused case run: cold-start grading and unrelated cases are not selected"); + await waitForState(player, playerErrors, { + deadline: Date.now() + SETTLE_MS, + assertion: "playback starts", + description: "the audio buffer to fill before the selected lifecycle case", + predicate: (state) => state.audioContext === "running" && !state.audioStalled, + }); + } else { + await measurePlayback(player, playerErrors, "cold start"); + } + + // A codec changes decoder identity; a VP8 resize does not. Measure through each + // transition, then keep checking pixels after its first new-generation picture. + if (wants("rename")) { + console.error("=== codec identity and coded-size changes ==="); + const before = await readPlayerState(player); + await command(publisher, "changeVideoCodec"); + const changed = await collect(player, playerErrors, WINDOW_MS); + const first = changed.findIndex( + (sample) => + sample.generation === 1 && + sample.videoCodec?.startsWith("vp8") && + sample.videoTrack !== before.videoTrack, + ); + check(first >= 0, "codec rename", () => `no renamed VP8 picture: ${JSON.stringify(changed.at(-1))}`); + check( + changed.slice(first).every((sample) => sample.frameId === undefined || sample.generation === 1), + "old track picture", + () => "a pre-switch picture appeared after the new codec's first picture", + ); + assertMedia(changed, "codec rename"); + const renamed = changed[first].videoTrack; + await measurePlayback(player, playerErrors, "after codec rename"); + + await command(publisher, "resizeVideo"); + const resized = await collect(player, playerErrors, WINDOW_MS); + const grown = resized.findIndex( + (sample) => + sample.generation === 2 && + sample.frameWidth === Pattern.WIDTH * 2 && + sample.frameHeight === Pattern.HEIGHT * 2 && + sample.videoCodedWidth === Pattern.WIDTH * 2 && + sample.videoCodedHeight === Pattern.HEIGHT * 2, + ); + check(grown >= 0, "coded-size growth", () => `no larger presented picture: ${JSON.stringify(resized.at(-1))}`); + check( + resized.every((sample) => sample.videoTrack === renamed && sample.videoCodec?.startsWith("vp8")), + "resize keeps identity", + () => `VP8 resize changed track: ${JSON.stringify(resized.at(-1))}`, + ); + check( + resized.slice(grown).every((sample) => sample.frameId === undefined || sample.generation === 2), + "old track picture", + () => "a pre-resize picture appeared after the first larger picture", + ); + assertMedia(resized, "coded-size growth"); + await measurePlayback(player, playerErrors, "after coded-size growth"); + } // ── pause and resume ───────────────────────────────────────────────────── if (wants("pause")) { diff --git a/test/interop/clients/js/src/capabilities.test.ts b/test/interop/clients/js/src/capabilities.test.ts index 37b3c889ddd..ca3c2eeb4a0 100644 --- a/test/interop/clients/js/src/capabilities.test.ts +++ b/test/interop/clients/js/src/capabilities.test.ts @@ -7,6 +7,7 @@ const player: Capabilities = { AudioDecoder: true, AudioWorkletNode: true, "decode avc1.42001f": true, + "decode vp8": true, "decode opus": true, }; diff --git a/test/interop/clients/js/src/capabilities.ts b/test/interop/clients/js/src/capabilities.ts index b5b410bb57f..530d287e5b8 100644 --- a/test/interop/clients/js/src/capabilities.ts +++ b/test/interop/clients/js/src/capabilities.ts @@ -36,6 +36,12 @@ export async function probe(wanted: { sampleRate: number; width: number; height: codedHeight: wanted.height, }), ), + "encode vp8": await supports(() => + VideoEncoder.isConfigSupported({ codec: "vp8", width: wanted.width, height: wanted.height }), + ), + "decode vp8": await supports(() => + VideoDecoder.isConfigSupported({ codec: "vp8", codedWidth: wanted.width, codedHeight: wanted.height }), + ), "encode opus": await supports(() => AudioEncoder.isConfigSupported({ codec: "opus", sampleRate: wanted.sampleRate, numberOfChannels: 1 }), ), @@ -57,8 +63,17 @@ export function missing(found: Capabilities, role: Role): string[] { "MediaStreamTrackProcessor", "canvas.captureStream", "encode avc1.42001f", + "encode vp8", "encode opus", ] - : ["WebSocket", "VideoDecoder", "AudioDecoder", "AudioWorkletNode", "decode avc1.42001f", "decode opus"]; + : [ + "WebSocket", + "VideoDecoder", + "AudioDecoder", + "AudioWorkletNode", + "decode avc1.42001f", + "decode vp8", + "decode opus", + ]; return required.filter((name) => found[name] !== true); } diff --git a/test/interop/clients/js/src/contract.ts b/test/interop/clients/js/src/contract.ts index 8429eec6e88..ca4f3c2f7c1 100644 --- a/test/interop/clients/js/src/contract.ts +++ b/test/interop/clients/js/src/contract.ts @@ -115,6 +115,17 @@ export type Sample = { painted: boolean; /** The fixture frame counter read out of the canvas, absent when the picture is not the fixture. */ frameId?: number; + /** Source generation read from the presented pixels. */ + generation?: number; + /** Presented canvas dimensions, independent of the received catalog. */ + frameWidth?: number; + frameHeight?: number; + /** Coded dimensions of the decoded frame supplied to the renderer. */ + videoCodedWidth?: number; + videoCodedHeight?: number; + /** The catalog's resolved video track and codec. */ + videoTrack?: string; + videoCodec?: string; /** Gap between the fixture's reference blocks, 0-255. Absent when the picture is not the fixture. */ contrast?: number; /** Frames the decoder has produced. */ @@ -209,6 +220,10 @@ export type InteropControl = { disableVideo(): void; /** Re-enable the fixture's video rendition. */ enableVideo(): void; + /** Switch the fixture from AVC to VP8 without replacing its broadcast. */ + changeVideoCodec(): void; + /** Double the fixture's coded dimensions while keeping its VP8 identity. */ + resizeVideo(): void; }; /** The `window` property the commands are published on. */ diff --git a/test/interop/clients/js/src/fixture.ts b/test/interop/clients/js/src/fixture.ts index 387635ed780..cea91c81fa6 100644 --- a/test/interop/clients/js/src/fixture.ts +++ b/test/interop/clients/js/src/fixture.ts @@ -52,11 +52,15 @@ export class Fixture { readonly #audioState = new Signal("suspended"); readonly #videoEnabled = new Signal(true); #timer: number | undefined; + readonly #canvas: HTMLCanvasElement; + readonly #video: Publish.Video.Encoder; + #generation = 0; constructor(host: HTMLElement, url: string, name: string, fault: Fault) { this.host = host; const canvas = document.createElement("canvas"); + this.#canvas = canvas; canvas.width = Pattern.WIDTH; canvas.height = Pattern.HEIGHT; host.appendChild(canvas); @@ -100,10 +104,22 @@ export class Fixture { broadcast, capture, enabled: this.#videoEnabled, - config: { frameRate: Pattern.FPS, keyframeInterval: KEYFRAME_INTERVAL, maxBitrate: MAX_BITRATE }, + config: { + codec: "avc1", + frameRate: Pattern.FPS, + keyframeInterval: KEYFRAME_INTERVAL, + maxBitrate: MAX_BITRATE, + }, }); + this.#video = video; this.#signals.cleanup(() => video.close()); + // Mark the codec change only once the probe has published it. Marking on request would paint the + // new generation into pictures the old codec still encodes onto the old track. + this.#signals.run((effect) => { + if (this.#generation === 0 && effect.get(video.out.catalog)?.codec.startsWith("vp8")) this.#generation = 1; + }); + // Handed the source at page load, before any gesture, like a pre-granted microphone. Readiness // then requires an audio catalog after the click, which covers the capture resuming its own // suspended graph. @@ -198,7 +214,7 @@ export class Fixture { if (frameId === painted) return; painted = frameId; - Pattern.paint(ctx, fault === "frozen-video" ? 0 : frameId); + Pattern.paint(ctx, fault === "frozen-video" ? 0 : frameId, this.#generation); track.requestFrame(); this.#frameId.set(frameId); }; @@ -217,6 +233,18 @@ export class Fixture { this.#videoEnabled.set(enabled); } + /** Switch codecs on the existing rendition, marking pictures once the catalog advertises VP8. */ + changeVideoCodec(): void { + this.#video.config.update((config) => ({ ...config, codec: "vp8" })); + } + + /** Grow past the accepted coded-size ceiling without changing the VP8 codec. */ + resizeVideo(): void { + this.#generation = 2; + this.#canvas.width = Pattern.WIDTH * 2; + this.#canvas.height = Pattern.HEIGHT * 2; + } + /** Stop publishing and release the capture, audio graph, and session. */ close(): void { this.#signals.close(); diff --git a/test/interop/clients/js/src/pattern.test.ts b/test/interop/clients/js/src/pattern.test.ts new file mode 100644 index 00000000000..b87b7343a8e --- /dev/null +++ b/test/interop/clients/js/src/pattern.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { CELLS, decode } from "./pattern"; + +// Frame five, generation two, with even parity. A literal pixel vector keeps +// the readback oracle independent of the painter it grades in real browsers. +const blocks = [1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1]; + +function pixels(blocks: number[]): Uint8ClampedArray { + const data = new Uint8ClampedArray(CELLS * 8 * 48 * 4); + for (let y = 0; y < 48; y++) { + for (let x = 0; x < CELLS * 8; x++) { + data.fill(blocks[Math.floor(x / 8)] * 255, (y * CELLS * 8 + x) * 4, (y * CELLS * 8 + x) * 4 + 3); + } + } + return data; +} + +test("presented pixels name both the frame and its source generation", () => { + expect(decode(pixels(blocks), CELLS * 8, 48)).toEqual({ frameId: 5, generation: 2, contrast: 255 }); +}); + +test("a corrupted generation bit cannot become a valid new-generation picture", () => { + const corrupted = [...blocks]; + corrupted[15] = 1; + expect(decode(pixels(corrupted), CELLS * 8, 48)).toBeUndefined(); +}); diff --git a/test/interop/clients/js/src/pattern.ts b/test/interop/clients/js/src/pattern.ts index 62aae9b490a..3aa3f9129b5 100644 --- a/test/interop/clients/js/src/pattern.ts +++ b/test/interop/clients/js/src/pattern.ts @@ -28,14 +28,15 @@ export const HEIGHT = 240; export const FRAME_BITS = 13; /** - * Blocks along the top strip: a white reference, a black reference, one per counter bit, and an + * Blocks along the top strip: a white reference, a black reference, one per counter and source-generation bit, and an * even parity block. * * Parity is what separates "this frame is not the fixture" from "one block was misread". Without * it a single flipped bit reads as a wild frame number, which looks exactly like the picture * jumping backwards. */ -export const CELLS = FRAME_BITS + 3; +const GENERATION_BITS = 2; +export const CELLS = FRAME_BITS + GENERATION_BITS + 3; /** Fraction of the frame height occupied by the counter strip. */ const STRIP = 1 / 6; @@ -99,14 +100,15 @@ export function stepSkew(actual: number, expected: number): number { } // Even parity over the painted counter bits. -function parity(frameId: number): number { +function parity(frameId: number, generation: number): number { let bit = 0; for (let i = 0; i < FRAME_BITS; i++) bit ^= (frameId >> i) & 1; + for (let i = 0; i < GENERATION_BITS; i++) bit ^= (generation >> i) & 1; return bit; } /** Paint one fixture frame: the counter strip on top, a sweeping bar below. */ -export function paint(ctx: CanvasRenderingContext2D, frameId: number): void { +export function paint(ctx: CanvasRenderingContext2D, frameId: number, generation = 0): void { const width = ctx.canvas.width; const height = ctx.canvas.height; const strip = height * STRIP; @@ -116,10 +118,11 @@ export function paint(ctx: CanvasRenderingContext2D, frameId: number): void { ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, width, height); - // Cell 0 is the white reference, cell 1 the black reference, then the counter LSB first, then parity. + // Cell 0 is the white reference, cell 1 the black reference, then counter and generation LSB first, then parity. const bits = [1, 0]; for (let i = 0; i < FRAME_BITS; i++) bits.push((frameId >> i) & 1); - bits.push(parity(frameId)); + for (let i = 0; i < GENERATION_BITS; i++) bits.push((generation >> i) & 1); + bits.push(parity(frameId, generation)); for (let i = 0; i < CELLS; i++) { ctx.fillStyle = bits[i] ? "#ffffff" : "#000000"; @@ -137,6 +140,8 @@ export function paint(ctx: CanvasRenderingContext2D, frameId: number): void { export type Decoded = { /** The painted frame counter. */ frameId: number; + /** The source generation, so a reconfiguration cannot keep presenting old pictures. */ + generation: number; /** Gap between the white and black reference blocks, 0-255. Below {@link MIN_CONTRAST} is unreadable. */ contrast: number; }; @@ -181,8 +186,13 @@ export function decode(pixels: Uint8ClampedArray, width: number, height: number) if (luma(i + 2) > threshold) frameId |= 1 << i; } + let generation = 0; + for (let i = 0; i < GENERATION_BITS; i++) { + if (luma(i + FRAME_BITS + 2) > threshold) generation |= 1 << i; + } + // A misread block is not a frame number, so report it as unreadable rather than as a jump. - if ((luma(CELLS - 1) > threshold ? 1 : 0) !== parity(frameId)) return undefined; + if ((luma(CELLS - 1) > threshold ? 1 : 0) !== parity(frameId, generation)) return undefined; - return { frameId, contrast }; + return { frameId, generation, contrast }; } diff --git a/test/interop/clients/js/src/probe.ts b/test/interop/clients/js/src/probe.ts index 7667dcd9881..de7ea20f21d 100644 --- a/test/interop/clients/js/src/probe.ts +++ b/test/interop/clients/js/src/probe.ts @@ -82,7 +82,12 @@ function readCanvas(canvas: HTMLCanvasElement | null) { } } - return { painted, ...Pattern.decode(pixels, canvas.width, canvas.height) }; + return { + painted, + frameWidth: canvas.width, + frameHeight: canvas.height, + ...Pattern.decode(pixels, canvas.width, canvas.height), + }; } /** @@ -136,6 +141,10 @@ export function attach(el: MoqWatch): () => void { ...readCanvas(el.querySelector("canvas")), videoFrames: el.video.out.stats.peek()?.frameCount ?? 0, videoTimestamp: el.video.out.timestamp.peek() ?? undefined, + videoCodedWidth: el.video.out.frame.peek()?.codedWidth, + videoCodedHeight: el.video.out.frame.peek()?.codedHeight, + videoTrack: el.catalog?.video?.renditions.video?.track ?? "video", + videoCodec: el.catalog?.video?.renditions.video?.codec, hasAudio: el.catalog?.audio !== undefined, audioBytes: el.audio.out.stats.peek()?.bytesReceived ?? 0, audioContext: el.audio.out.context.peek()?.state, diff --git a/test/interop/clients/js/src/setup.ts b/test/interop/clients/js/src/setup.ts index 54a821026a9..e007b2eeffa 100644 --- a/test/interop/clients/js/src/setup.ts +++ b/test/interop/clients/js/src/setup.ts @@ -69,6 +69,8 @@ if (role === "publish") { }, disableVideo: () => fixture?.setVideo(false), enableVideo: () => fixture?.setVideo(true), + changeVideoCodec: () => fixture?.changeVideoCodec(), + resizeVideo: () => fixture?.resizeVideo(), }); } else if (role === "subscribe") { await customElements.whenDefined("moq-watch"); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 96e953bf077..75f1ea733c7 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -60,6 +60,7 @@ URL="" KEY="" # the HMAC key every cell's token is signed with (set once the relay's auth server starts) NEGATIVE=0 MEDIA=0 +MEDIA_CASES="" MEDIA_ENGINES="chromium" TAIL_ONLY=0 @@ -110,6 +111,11 @@ while [[ $# -gt 0 ]]; do MEDIA=1 shift ;; + --media-cases) + require_value "$@" + MEDIA_CASES="$2" + shift 2 + ;; --media-engines) require_value "$@" MEDIA_ENGINES="$2" @@ -131,6 +137,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -n "$MEDIA_CASES" && "$MEDIA" -ne 1 ]]; then + echo "error: --media-cases requires --media" >&2 + exit 2 +fi + # Numeric guards so a fat-fingered --timeout / INTEROP_PORT fails clearly here # instead of surfacing later as a cryptic `timeout` or relay-bind error. [[ "$TIMEOUT" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { @@ -1101,6 +1112,10 @@ if [[ "$MEDIA" -eq 1 ]]; then else echo "=== media output and lifecycle ===" for engine in "${MEDIA_ENGINE_LIST[@]}"; do + if [[ -n "$MEDIA_CASES" ]]; then + run_media "$engine: media cases: $MEDIA_CASES" --player-engine "$engine" --cases "$MEDIA_CASES" --focused + continue + fi run_media "$engine: media output + lifecycle" --player-engine "$engine" run_media "$engine: control: frozen video" --player-engine "$engine" --fault frozen-video --cases none --expect-fail "video progress" run_media "$engine: control: silent audio" --player-engine "$engine" --fault silent-audio --cases none --expect-fail "audio tone"