diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index b00cf7a19a..055135165a 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -18,6 +18,7 @@ on: - "test/lib/**" - "test/interop/**" - "test/max-age/**" + - "test/portrait/**" - "rs/moq-tokio/tests/max_age.rs" # The TS/IRD compliance harness runs in the same job (see below). - "test/ts/**" @@ -125,6 +126,13 @@ jobs: GH_TOKEN: ${{ github.token }} MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs + - name: Portrait transcode + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} + run: nix develop --command just test portrait + shell: bash -leo pipefail {0} + env: + MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs + - name: Interop negative control if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: nix develop --command just test interop-negative diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 8115f38d31..584d9719d1 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -173,6 +173,9 @@ moq --connect https://relay.example.com/anon --broadcast cam.hang transcode moq ... transcode --rung 720:2500000 --rung 360:600000 --encoder nvenc --decoder nvdec ``` +Rung sizes are the coded frame's short side. A 1080x1920 portrait source +produces 720x1280 and 360x640 rungs. Catalog rotation is preserved. + Publishes `cam.hang/transcode.hang` whose catalog references the source's rendition and adds lower rungs that are decoded and encoded only while someone watches them. On NVIDIA the whole pipeline stays on the GPU. The source is the diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index 9637b61a86..a3efb9b350 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { Producer } from "./broadcast.ts"; import { BroadcastCache } from "./consume.ts"; import * as Epoch from "./epoch.ts"; +import { StreamCode, StreamError } from "./error.ts"; import { Route } from "./hop.ts"; import * as Path from "./path.ts"; @@ -114,6 +115,10 @@ test("request validity cancels one selection period and permits the held fallbac expect(first?.aborted).toBe(false); cache.announce(path, b, Route.default); expect(first?.aborted).toBe(true); + // A more specific route taking over ends the old instance's requests the way Rust does, so + // players treat it as the instance going offline rather than an unexpected failure. + expect(first?.reason).toBeInstanceOf(StreamError); + expect(first?.reason).toMatchObject({ code: StreamCode.Unroutable }); const sourceB = new Producer(); const instanceB = cache.instance(path); const heldB = cache.insert(path, instanceB, sourceB.consume()); diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 8f39f638dd..51af398f5d 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -1,5 +1,6 @@ import type * as broadcast from "./broadcast.ts"; import type * as Epoch from "./epoch.ts"; +import { StreamCode, StreamError } from "./error.ts"; import type { Route } from "./hop.ts"; import { compareRouteCandidates, coveringPrefixes } from "./internal.ts"; import type * as Path from "./path.ts"; @@ -16,7 +17,8 @@ class Validity { if (allowed) { if (this.#current.signal.aborted) this.#current = new AbortController(); } else if (!this.#current.signal.aborted) { - this.#current.abort(new Error("broadcast instance retired")); + // Unroutable, as in Rust: the instance going away is an end, not a failure. + this.#current.abort(new StreamError(StreamCode.Unroutable, { message: "broadcast instance retired" })); } } } diff --git a/js/signals/src/index.test.ts b/js/signals/src/index.test.ts index 50fcd9270a..82ebacd697 100644 --- a/js/signals/src/index.test.ts +++ b/js/signals/src/index.test.ts @@ -1146,6 +1146,24 @@ describe("race", () => { expect(await race([Promise.resolve("promise"), once])).toBe("done"); }); + test("a loser after an already settled winner is still observed", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const once = new Once(); + once.set("done"); + let fail!: (err: Error) => void; + const later = new Promise((_, reject) => (fail = reject)); + expect(await race([once, later])).toBe("done"); + fail(new Error("late")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + test("resolves when a pending Once settles", async () => { const once = new Once(); const result = race([new Promise(() => {}), once]); diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index 9677894a29..1619778967 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -449,17 +449,24 @@ function raceUntil(values: readonly unknown[], abort?: AbortSignal): Promise< else reject(settled.error); }; - if (abort) { - if (abort.aborted) return finish({ ok: true, value: undefined }); + if (abort?.aborted) { + finish({ ok: true, value: undefined }); + } else if (abort) { const stop = () => finish({ ok: true, value: undefined }); abort.addEventListener("abort", stop); disposes.push(() => abort.removeEventListener("abort", stop)); } for (const value of values) { + // Observe a loser even once the race is decided, as Promise.race does, so its later + // rejection is not reported as unhandled. A thenable keeps one shared reaction. + if (done) { + listen(value, noop)(); + continue; + } const dispose = listen(value, finish); - if (done) return dispose(); - disposes.push(dispose); + if (done) dispose(); + else disposes.push(dispose); } }); } @@ -471,7 +478,8 @@ function raceUntil(values: readonly unknown[], abort?: AbortSignal): Promise< * Accepts promises and {@link GetPromise} values such as a {@link Once}. Racing a value that * outlives the call (a `closed` pending for the whole track) once per frame therefore leaks nothing, * where `Promise.race` would leave a listener or reaction behind on every call. An already settled - * value wins at once. + * value wins at once. Like `Promise.race`, every value is observed, so a loser that rejects later + * is not an unhandled rejection. */ export function race(values: T): Promise> { return raceUntil(values); diff --git a/quest/m1/README.md b/quest/m1/README.md index b70279cc38..184d25b88f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -151,7 +151,6 @@ session outcomes, and embedder refusals inside session outcomes. - [qmux no-wait opens](/quest/m1/qmux-no-wait.md) - @moq/qmux rejects an over-limit create when waitUntilAvailable is false, like Chrome - [E2EE](/quest/m1/e2ee/README.md) - TypeScript and Rust peers interoperate over encrypted broadcasts no relay can decrypt - [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - the catalog rotation follows the live camera's orientation -- [Portrait ladder](/quest/m1/portrait-ladder.md) - portrait video plays end to end through transcode, with rungs sized by the source's short side - [iOS capture](/quest/m1/mobile-capture-ios.md) - Rust captures the camera and screen on iOS, setting the catalog rotation - [Android capture](/quest/m1/mobile-capture-android.md) - Rust captures through NDK/JNI on Android, reusing the existing codecs and setting the catalog rotation - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent diff --git a/quest/m1/portrait-ladder.md b/quest/m1/portrait-ladder.md deleted file mode 100644 index 75ec8a26f6..0000000000 --- a/quest/m1/portrait-ladder.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Portrait transcode ladders - -## Goal - -Portrait (vertical) video plays correctly end to end through `moq-transcode`, -and transcode rungs are sized by the source's short side, so a 1080x1920 -source gets 720x1280 and 360x640 rungs rather than rungs a third as wide. - -## Plan - -`rs/moq-transcode` defines rungs by height (`rs/moq-transcode/src/ladder.rs`) -and derives width from the source aspect ratio (`resolve_rungs` in -`rs/moq-transcode/src/catalog.rs`). Its own test pins a 1080x1920 source's -360 rung at 202x360, so a portrait source gets landscape-sized pixel counts at -landscape bitrates. Verify the gap end to end before changing it. - -- Treat a rung's configured size as the short side, and apply the never - upscale and below-source checks to the short side too. -- A source coded landscape with a catalog `rotation` keeps its rotation on - every rung, as it does today; check that the short side is taken from the - coded frame so both portrait encodings land on the same rungs. -- Prove one portrait source through publish, transcode, and ``, - plus unit tests for both portrait encodings. - -## Related - -- [#933](/quest/m1/933-video-rotation-metadata-not-propagated-from-mobile-camera.md) - portrait phones publishing a catalog rotation -- [iOS capture](/quest/m1/mobile-capture-ios.md) - native portrait capture -- [Android capture](/quest/m1/mobile-capture-android.md) - native portrait capture diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index 0235f219ff..710e863d35 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -21,10 +21,10 @@ pub struct Args { #[usage(long)] pub output: Option, - /// A ladder rung as `height:bitrate` (pixels : bits per second), repeatable, - /// e.g. `--rung 720:2500000 --rung 360:600000`. Heights and bitrates must + /// A ladder rung as `size:bitrate` (short-side pixels : bits per second), repeatable, + /// e.g. `--rung 720:2500000 --rung 360:600000`. Short sides and bitrates must /// increase together; input order does not matter. Rungs above the source - /// are dropped; a same-height rung needs a lower known bitrate. + /// are dropped; a same-size rung needs a lower known bitrate. /// Defaults to a 240p..1080p ladder. #[usage(long = "rung")] rungs: Vec, @@ -45,7 +45,7 @@ pub struct Args { frames: OutputArg, } -/// A `height:bitrate` rung, e.g. `720:2500000`. +/// A `size:bitrate` rung, e.g. `720:2500000`. /// /// A newtype because Usage builds a value through `FromStr`, and `Rung` is not ours. #[derive(Clone)] @@ -55,15 +55,15 @@ impl std::str::FromStr for RungArg { type Err = String; fn from_str(arg: &str) -> Result { - let (height, bitrate) = arg + let (size, bitrate) = arg .split_once(':') - .ok_or_else(|| format!("expected height:bitrate, got `{arg}`"))?; - let height: u32 = height.parse().map_err(|e| format!("invalid height `{height}`: {e}"))?; + .ok_or_else(|| format!("expected size:bitrate, got `{arg}`"))?; + let size: u32 = size.parse().map_err(|e| format!("invalid short side `{size}`: {e}"))?; let bitrate: u64 = bitrate .parse() .map_err(|e| format!("invalid bitrate `{bitrate}`: {e}"))?; Ok(Self(moq_transcode::Rung::new( - height, + size, moq_net::bandwidth::Rate::from_bps(bitrate), ))) } diff --git a/rs/moq-transcode/README.md b/rs/moq-transcode/README.md index 1b1f4d786a..a870e56773 100644 --- a/rs/moq-transcode/README.md +++ b/rs/moq-transcode/README.md @@ -51,9 +51,10 @@ output.announce(Default::default())?; moq_transcode::run(source, output, config).await?; ``` -Only renditions strictly below the source survive the ladder: a 480p source is -never transcoded up to 720p, and a same-height rung is only offered when it -undercuts a known source bitrate. +Rung sizes measure the coded frame's short side: a 1080x1920 source produces +720x1280 and 360x640 rungs, while catalog rotation is preserved. Only renditions +strictly below the source survive: a 480-pixel short side is never upscaled to +720, and a same-size rung is only offered when it undercuts a known source bitrate. `Config::ladder` is a `Ladder`, built from rungs in any order: @@ -67,7 +68,7 @@ config.ladder = moq_transcode::Ladder::new([ It ranks them by bitrate, lowest first, so every rung above the lowest has a next lower rendition. A ladder with no such ranking is an error rather than a guess: two rungs sharing a bitrate have no lower one, and a rung that costs more without -being taller means bitrate and resolution disagree about which rendition is +having a larger short side means bitrate and resolution disagree about which rendition is below which. Filtering against the source drops rungs but never reorders them. ## Example diff --git a/rs/moq-transcode/src/active.rs b/rs/moq-transcode/src/active.rs index 14b19a1a89..4d260e0cd1 100644 --- a/rs/moq-transcode/src/active.rs +++ b/rs/moq-transcode/src/active.rs @@ -366,7 +366,7 @@ mod tests { fn resolved(name: &str, height: u32) -> Resolved { Resolved { name: name.to_string(), - height, + short_side: height, size: moq_video::Size::new(height * 16 / 9, height), bitrate: moq_net::bandwidth::Rate::from_bps(100_000), framerate: Some(moq_video::Rate::new(30, 1).unwrap()), diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 82716053b2..04546fe60a 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -12,11 +12,11 @@ use crate::{Error, Ladder}; pub(crate) struct Resolved { /// The rendition/track name serving this incarnation, e.g. `video/360p`. /// - /// Later incarnations of the same configured height carry a revision suffix + /// Later incarnations of the same configured short side carry a revision suffix /// (`video/360p.2`); see [`Names`]. pub name: String, - /// The configured height this rung serves, which the name is derived from. - pub height: u32, + /// The configured short side this rung serves, which the name is derived from. + pub short_side: u32, /// The output resolution, derived from the source aspect ratio. pub size: moq_video::Size, pub bitrate: moq_net::bandwidth::Rate, @@ -26,7 +26,7 @@ pub(crate) struct Resolved { impl Resolved { /// Whether `other` resolves to exactly this picture, whatever it is named. pub fn same_shape(&self, other: &Self) -> bool { - self.height == other.height + self.short_side == other.short_side && self.size == other.size && self.bitrate == other.bitrate && self.framerate == other.framerate @@ -43,19 +43,19 @@ impl Resolved { /// used, rather than reusing the one it just finished. #[derive(Default)] pub(crate) struct Names { - /// The revision last issued per configured height. The first is the bare + /// The revision last issued per configured short side. The first is the bare /// `video/360p`, so a suffix only ever appears on a rung that was resolved /// again. revisions: std::collections::HashMap, } impl Names { - /// A name for a fresh incarnation of `height`. - pub fn mint(&mut self, height: u32) -> String { - let revision = self.revisions.entry(height).and_modify(|r| *r += 1).or_insert(1); + /// A name for a fresh incarnation of the short-side `size`. + pub fn mint(&mut self, size: u32) -> String { + let revision = self.revisions.entry(size).and_modify(|r| *r += 1).or_insert(1); match *revision { - 1 => format!("video/{height}p"), - revision => format!("video/{height}p.{revision}"), + 1 => format!("video/{size}p"), + revision => format!("video/{size}p.{revision}"), } } } @@ -288,30 +288,28 @@ pub(crate) fn resolve_rungs(ladder: &Ladder, source_name: &str, source: &VideoCo let mut resolved: Vec = Vec::new(); for rung in ladder.rungs() { - let height = rung.height as u64; - if height > source_height { - // Never upscale. + let side = u64::from(rung.size); + let source_side = source_width.min(source_height); + if side > source_side { continue; } - // A same-height rung is only useful at a lower bitrate, and an unknown - // source bitrate can't prove that. - if height == source_height && source.bitrate.is_none() { + // At equal resolution, only a known lower bitrate makes a rung useful. + if side == source_side && source.bitrate.is_none() { continue; } - if source.bitrate.is_some_and(|bitrate| rung.bitrate.as_bps() >= bitrate) { + if source.bitrate.is_some_and(|rate| rung.bitrate.as_bps() >= rate) { continue; } - // Preserve the source aspect ratio, rounded to even for I420 chroma. - let width = ((source_width * height + source_height / 2) / source_height) & !1; - if width == 0 { + let scale = |dimension| ((dimension * side + source_side / 2) / source_side) & !1; + let width = scale(source_width); + let height = scale(source_height); + if width < 2 || height < 2 { continue; } - - let height = height as u32; resolved.push(Resolved { - name: format!("video/{height}p"), - height, - size: moq_video::Size::new(width as u32, height), + name: format!("video/{}p", rung.size), + short_side: rung.size, + size: moq_video::Size::new(width as u32, height as u32), bitrate: rung.bitrate, framerate, }); @@ -440,7 +438,7 @@ mod tests { let mut published = [Published { rung: Resolved { name: "video/360p".into(), - height: 360, + short_side: 360, size: moq_video::Size::new(640, 360), bitrate: moq_net::bandwidth::Rate::from_bps(600_000), framerate: Some(moq_video::Rate::new(30, 1).unwrap()), @@ -503,15 +501,18 @@ mod tests { (source(1920, 1080, Some(6_000_000)), vec![240, 360, 480, 720]), ] { let resolved = resolve_rungs(&ladder, "video", &picture).unwrap(); - assert_eq!(resolved.iter().map(|rung| rung.height).collect::>(), expected); + assert_eq!( + resolved.iter().map(|rung| rung.short_side).collect::>(), + expected + ); assert!(resolved.windows(2).all(|pair| pair[0].bitrate < pair[1].bitrate)); } } #[test] - fn same_height_needs_lower_bitrate() { + fn same_short_side_needs_lower_bitrate() { let rungs = Ladder::new([Rung::new(480, moq_net::bandwidth::Rate::from_bps(1_200_000))]).unwrap(); - // Unknown source bitrate: a same-height rung can't prove it's below. + // Unknown source bitrate: a same-short-side rung can't prove it's below. assert!( resolve_rungs(&rungs, "video", &source(854, 480, None)) .unwrap() @@ -536,14 +537,54 @@ mod tests { assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].size, moq_video::Size::new(640, 360)); - // Vertical video: aspect preserved, width rounded to even. + // Vertical video keeps the same pixel count and bitrate as a rotated landscape frame. let resolved = resolve_rungs( &Ladder::new([Rung::new(360, moq_net::bandwidth::Rate::from_bps(600_000))]).unwrap(), "video", &source(1080, 1920, Some(6_000_000)), ) .unwrap(); - assert_eq!(resolved[0].size, moq_video::Size::new(202, 360)); + assert_eq!(resolved[0].size, moq_video::Size::new(360, 640)); + } + + #[test] + fn portrait_ladders_use_the_coded_short_side_and_keep_rotation() { + let ladder = Ladder::new([ + Rung::new(360, moq_net::bandwidth::Rate::from_bps(600_000)), + Rung::new(720, moq_net::bandwidth::Rate::from_bps(2_500_000)), + ]) + .unwrap(); + for (width, height, rotation, expected) in [ + (1080, 1920, None, [(360, 640), (720, 1280)]), + (1920, 1080, Some(90.0), [(640, 360), (1280, 720)]), + ] { + let input = source(width, height, Some(6_000_000)); + let resolved = resolve_rungs(&ladder, "video", &input).unwrap(); + let sizes: Vec<_> = resolved + .iter() + .map(|rung| (rung.size.width, rung.size.height)) + .collect(); + assert_eq!(sizes, expected); + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + catalog.video.rotation = rotation; + let published: Vec<_> = resolved + .into_iter() + .map(|rung| Published { + entry: source(rung.size.width, rung.size.height, Some(rung.bitrate.as_bps())), + rung, + }) + .collect(); + let mut output = moq_mux::catalog::hang::Catalog::default(); + populate(&mut output, &catalog, &published, None).unwrap(); + assert_eq!(output.video.rotation, rotation); + assert_eq!(output.video.renditions.len(), 2); + } + // A narrow portrait cannot admit a rung wider than its coded short side. + assert!( + resolve_rungs(&ladder, "video", &source(320, 1080, None)) + .unwrap() + .is_empty() + ); } /// A publisher that advertises its codec before opening its camera has no dimensions yet, and @@ -630,7 +671,7 @@ mod tests { async fn rung_entry_describes_the_rung() { let rung = Resolved { name: "video/360p".to_string(), - height: 360, + short_side: 360, size: moq_video::Size::new(640, 360), bitrate: moq_net::bandwidth::Rate::from_bps(600_000), framerate: Some(moq_video::Rate::new(30, 1).unwrap()), @@ -662,7 +703,7 @@ mod tests { async fn an_unknown_rate_stays_unknown_after_probe() { let rung = Resolved { name: "video/360p".to_string(), - height: 360, + short_side: 360, size: moq_video::Size::new(640, 360), bitrate: moq_net::bandwidth::Rate::from_bps(600_000), framerate: None, diff --git a/rs/moq-transcode/src/config.rs b/rs/moq-transcode/src/config.rs index 9a56ef2d5d..30bdc165ad 100644 --- a/rs/moq-transcode/src/config.rs +++ b/rs/moq-transcode/src/config.rs @@ -12,9 +12,9 @@ use crate::Ladder; #[non_exhaustive] pub struct Config { /// Candidate output renditions, lowest first. Only rungs strictly below the - /// source survive: a rung is dropped when its height exceeds the source, when + /// source survive: a rung is dropped when its short side exceeds the source, when /// its bitrate is not below the source bitrate (when known), or when it - /// matches the source height without a known source bitrate to undercut. A + /// matches the source short side without a known source bitrate to undercut. A /// 480p source is never transcoded up to 720p. /// /// Filtering drops rungs but never reorders them, so the surviving ladder is diff --git a/rs/moq-transcode/src/ladder.rs b/rs/moq-transcode/src/ladder.rs index b15448e6d6..42d3ee72d4 100644 --- a/rs/moq-transcode/src/ladder.rs +++ b/rs/moq-transcode/src/ladder.rs @@ -1,15 +1,15 @@ -//! Validated output renditions in ascending bitrate and height order. +//! Validated output renditions in ascending bitrate and size order. -/// One candidate output rendition: a target resolution (by height) and bitrate. +/// One candidate output rendition: a target resolution (by short side) and bitrate. /// -/// The width is derived from the source aspect ratio at runtime, and a rung is +/// The other side is derived from the source aspect ratio at runtime, and a rung is /// only offered when it is strictly below the source (see [`Ladder`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub struct Rung { - /// Output height in pixels. Rounded down to even when it enters a [`Ladder`] + /// Output short side in pixels. Rounded down to even when it enters a [`Ladder`] /// (I420 chroma is 2x2). - pub height: u32, + pub size: u32, /// The configured maximum in bits per second: the CBR target advertised in the /// derivative catalog. @@ -17,9 +17,9 @@ pub struct Rung { } impl Rung { - /// A rung at `height` pixels and `bitrate` bits per second. - pub fn new(height: u32, bitrate: moq_net::bandwidth::Rate) -> Self { - Self { height, bitrate } + /// A rung at `size` pixels and `bitrate` bits per second. + pub fn new(size: u32, bitrate: moq_net::bandwidth::Rate) -> Self { + Self { size, bitrate } } } @@ -27,12 +27,12 @@ impl Rung { #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum Error { - /// A rung that encodes nothing: a height that rounds to zero pixels, or no + /// A rung that encodes nothing: a size that rounds to zero pixels, or no /// bitrate to spend. - #[error("rung {height}p at {bitrate} bps encodes nothing")] + #[error("rung {size}p at {bitrate} bps encodes nothing")] Empty { - /// The configured height, before rounding to even. - height: u32, + /// The configured size, before rounding to even. + size: u32, /// The configured maximum, in bits per second. bitrate: u64, }, @@ -43,44 +43,44 @@ pub enum Error { DuplicateBitrate { /// The shared maximum, in bits per second. bitrate: u64, - /// The shorter rung's height, in pixels. + /// The shorter rung's size, in pixels. first: u32, - /// The taller rung's height, in pixels. + /// The taller rung's size, in pixels. second: u32, }, /// Resolution runs backwards against bitrate: a rung costs more than the one /// below it without being taller, so bitrate and picture disagree on which /// rendition is lower. - #[error("rung {height}p at {bitrate} bps does not rise above {below_height}p at {below_bitrate} bps")] + #[error("rung {size}p at {bitrate} bps does not rise above {below_size}p at {below_bitrate} bps")] Unordered { - /// The more expensive rung's height, in pixels. - height: u32, + /// The more expensive rung's size, in pixels. + size: u32, /// The more expensive rung's maximum, in bits per second. bitrate: u64, - /// The cheaper rung's height, in pixels. - below_height: u32, + /// The cheaper rung's size, in pixels. + below_size: u32, /// The cheaper rung's maximum, in bits per second. below_bitrate: u64, }, } /// The output ladder, in canonical order: strictly ascending maximum bitrate, -/// and strictly ascending height with it. +/// and strictly ascending size with it. /// /// Construction validates the order; [`Ladder::rungs`] exposes a read-only slice. The /// rungs are still filtered against the source at runtime (nothing above it /// survives), which drops rungs but never reorders them. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Ladder { - // Ascending by bitrate, and by height with it. Heights are even. + // Ascending by bitrate, and by short side with it. Sizes are even. rungs: Vec, } impl Ladder { /// Resolve `rungs` into a ladder, in any order. /// - /// Heights round down to even, and the result is sorted by maximum bitrate. + /// Sizes round down to even, and the result is sorted by maximum bitrate. /// An ambiguous ladder is an [`Error`] rather than a guess: two rungs at the /// same maximum have no lower one, and a rung that costs more without being /// taller means bitrate and picture disagree about which rendition is lower. @@ -88,32 +88,32 @@ impl Ladder { pub fn new(rungs: impl IntoIterator) -> Result { let mut rungs: Vec = rungs.into_iter().collect(); for rung in &mut rungs { - if rung.height < 2 || rung.bitrate.as_bps() == 0 { + if rung.size < 2 || rung.bitrate.as_bps() == 0 { return Err(Error::Empty { - height: rung.height, + size: rung.size, bitrate: rung.bitrate.as_bps(), }); } - // Normalize before checking uniqueness: odd heights can share a track name. - rung.height &= !1; + // Normalize before checking uniqueness: odd sizes can share a track name. + rung.size &= !1; } - rungs.sort_by_key(|rung| (rung.bitrate, rung.height)); + rungs.sort_by_key(|rung| (rung.bitrate, rung.size)); for pair in rungs.windows(2) { let (below, rung) = (pair[0], pair[1]); if below.bitrate == rung.bitrate { return Err(Error::DuplicateBitrate { bitrate: rung.bitrate.as_bps(), - first: below.height, - second: rung.height, + first: below.size, + second: rung.size, }); } - if rung.height <= below.height { + if rung.size <= below.size { return Err(Error::Unordered { - height: rung.height, + size: rung.size, bitrate: rung.bitrate.as_bps(), - below_height: below.height, + below_size: below.size, below_bitrate: below.bitrate.as_bps(), }); } @@ -122,7 +122,7 @@ impl Ladder { Ok(Self { rungs }) } - /// The rungs, lowest first: ascending maximum bitrate and ascending height. + /// The rungs, lowest first: ascending maximum bitrate and ascending size. pub fn rungs(&self) -> &[Rung] { &self.rungs } @@ -147,14 +147,14 @@ impl Default for Ladder { mod tests { use super::*; - fn heights(ladder: &Ladder) -> Vec { - ladder.rungs().iter().map(|rung| rung.height).collect() + fn sizes(ladder: &Ladder) -> Vec { + ladder.rungs().iter().map(|rung| rung.size).collect() } #[test] fn default_ladder_is_ordered() { let ladder = Ladder::default(); - assert_eq!(heights(&ladder), [240, 360, 480, 720, 1080]); + assert_eq!(sizes(&ladder), [240, 360, 480, 720, 1080]); } /// The operator writes the ladder top-down (or in whatever order), and it @@ -167,7 +167,7 @@ mod tests { Rung::new(480, moq_net::bandwidth::Rate::from_bps(1_200_000)), ]) .unwrap(); - assert_eq!(heights(&ladder), [240, 480, 720]); + assert_eq!(sizes(&ladder), [240, 480, 720]); // The neighbour is the next rendition down, which is what the band // formula reads. assert_eq!(ladder.rungs()[1].bitrate.as_bps(), 1_200_000); @@ -192,11 +192,11 @@ mod tests { ); } - /// Odd heights round to even, so a ladder can collide on a height it never + /// Odd sizes round to even, so a ladder can collide on a size it never /// literally wrote. Silently dropping one rung would move its neighbour's /// "next lower rendition", so it is refused too. #[test] - fn duplicate_height_is_refused() { + fn duplicate_size_is_refused() { let err = Ladder::new([ Rung::new(721, moq_net::bandwidth::Rate::from_bps(2_500_000)), Rung::new(720, moq_net::bandwidth::Rate::from_bps(1_200_000)), @@ -205,9 +205,9 @@ mod tests { assert_eq!( err, Error::Unordered { - height: 720, + size: 720, bitrate: 2_500_000, - below_height: 720, + below_size: 720, below_bitrate: 1_200_000, } ); @@ -225,9 +225,9 @@ mod tests { assert_eq!( err, Error::Unordered { - height: 360, + size: 360, bitrate: 3_000_000, - below_height: 1080, + below_size: 1080, below_bitrate: 1_000_000, } ); @@ -238,16 +238,13 @@ mod tests { assert_eq!( Ladder::new([Rung::new(1, moq_net::bandwidth::Rate::from_bps(350_000))]).unwrap_err(), Error::Empty { - height: 1, + size: 1, bitrate: 350_000 } ); assert_eq!( Ladder::new([Rung::new(240, moq_net::bandwidth::Rate::from_bps(0))]).unwrap_err(), - Error::Empty { - height: 240, - bitrate: 0 - } + Error::Empty { size: 240, bitrate: 0 } ); } diff --git a/rs/moq-transcode/src/pipeline.rs b/rs/moq-transcode/src/pipeline.rs index 1a753fe79e..a0cedd5929 100644 --- a/rs/moq-transcode/src/pipeline.rs +++ b/rs/moq-transcode/src/pipeline.rs @@ -129,7 +129,7 @@ impl Pipeline { entry } None => { - rung.name = self.names.mint(rung.height); + rung.name = self.names.mint(rung.short_side); catalog::rung_entry(&rung, source, &self.config.encoder).await? } }; diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 70e66e3bbb..850f88570f 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -795,7 +795,7 @@ mod tests { fn write_banks_the_frames_that_reached_the_group() { let rung = Resolved { name: "video/120p".to_string(), - height: 120, + short_side: 120, size: moq_video::Size::new(160, 120), bitrate: moq_net::bandwidth::Rate::from_bps(100_000), framerate: Some(moq_video::Rate::new(30, 1).unwrap()), diff --git a/test/interop/clients/js/portrait.ts b/test/interop/clients/js/portrait.ts new file mode 100644 index 0000000000..aa8e53d9aa --- /dev/null +++ b/test/interop/clients/js/portrait.ts @@ -0,0 +1,48 @@ +import { parseArgs } from "node:util"; +import type MoqWatch from "@moq/watch/element"; +import { finishTraces, launch, open, pageUrl, serve, throwPageErrors } from "./harness"; + +const { values } = parseArgs({ options: { url: { type: "string" } } }); +if (!values.url) throw new Error("portrait.ts requires --url"); +const server = serve(); +const browser = await launch(); +let failed = true; +try { + const [page, errors] = await open( + browser, + pageUrl(server.origin, "subscribe", { + url: values.url, + broadcast: "portrait.hang/transcode.hang", + muted: "true", + visible: "always", + }), + "portrait", + true, + ); + await page.evaluate(() => { + const watch = document.querySelector("moq-watch") as MoqWatch; + watch.controls.target.set({ name: "video/360p" }); + }); + // The real renderer's frame and clock prove the rung was downloaded, decoded and painted. + await page.waitForFunction( + () => { + const watch = document.querySelector("moq-watch") as MoqWatch; + const frame = watch.renderer.out.frame.peek(); + return ( + watch.video.source.out.track.peek() === "video/360p" && + frame?.displayWidth === 360 && + frame.displayHeight === 640 && + (watch.renderer.out.timestamp.peek() ?? 0) > 1000 + ); + }, + undefined, + { timeout: 30_000 }, + ); + throwPageErrors(errors); + console.log("portrait publish -> transcode -> moq-watch: 360x640 rung painted"); + failed = false; +} finally { + await finishTraces(failed); + await browser.close(); + server.stop(); +} diff --git a/test/justfile b/test/justfile index 02cc3d462d..bd71008b6a 100644 --- a/test/justfile +++ b/test/justfile @@ -48,6 +48,10 @@ interop *args: wire-compat: ./interop/compat.sh +# Paint a short-side transcode rung from a coded portrait through moq-watch. +portrait: + ./portrait/run.sh + # Negative control: no publisher, every subscriber must time out (proves the # harness can actually report failure). diff --git a/test/portrait/relay.toml b/test/portrait/relay.toml new file mode 100644 index 0000000000..aa127e3c42 --- /dev/null +++ b/test/portrait/relay.toml @@ -0,0 +1,16 @@ +# Private loopback relay for the portrait transcode harness. +[log] +level = "info" + +[listen] +bind = "127.0.0.1:4443" +tls.generate = ["localhost", "127.0.0.1"] +# The CLI peers negotiate lite-07 so their epochs carry an initial request across a +# WebSocket-to-QUIC upgrade; lite-06 refuses it as a new instance. Chromium offers lite-06. +version = ["moq-lite-07-wip", "moq-lite-06"] + +[web.http] +listen = "127.0.0.1:4443" + +[auth] +public = "**" diff --git a/test/portrait/run.sh b/test/portrait/run.sh new file mode 100755 index 0000000000..ef3adea930 --- /dev/null +++ b/test/portrait/run.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Publish a coded portrait, transcode a short-side rung, and paint it in Chromium. +set -euo pipefail +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +WORKSPACE=$(cd "$DIR/../.." && pwd) +# shellcheck source-path=SCRIPTDIR source=../lib/harness.sh +source "$DIR/../lib/harness.sh" +harness_begin portrait 'just test portrait' +cd "$WORKSPACE" +cargo build --locked -p moq-relay -p moq-cli --features moq-cli/transcode +bun install --frozen-lockfile +(cd test/interop/clients/js && bunx vite build --outDir "$HARNESS_RUN/js-dist" && bunx playwright install chromium) +harness_port relay +URL="http://127.0.0.1:$HARNESS_PORT" +sed "s/4443/$HARNESS_PORT/g" "$DIR/relay.toml" >"$HARNESS_RUN/relay.toml" +harness_spawn relay "$HARNESS_RUN/relay.log" "$WORKSPACE/target/debug/moq-relay" "$HARNESS_RUN/relay.toml" +harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID" +# shellcheck disable=SC2329 # invoked by harness_spawn +publish_portrait() { + ffmpeg -hide_banner -loglevel error -re -f lavfi -i testsrc2=size=720x1280:rate=10 \ + -c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline -pix_fmt yuv420p -g 10 -f h264 - | + "$WORKSPACE/target/debug/moq" --connect "$URL" --connect-version moq-lite-07-wip --broadcast portrait.hang import avc3 +} +harness_spawn publisher "$HARNESS_RUN/publisher.log" publish_portrait +harness_spawn transcode "$HARNESS_RUN/transcode.log" "$WORKSPACE/target/debug/moq" \ + --connect "$URL" --connect-version moq-lite-07-wip --broadcast portrait.hang transcode --rung 360:600000 --encoder software --decoder software +cd test/interop/clients/js +bun portrait.ts --url "$URL"