From a77e055d010801208033938f230a2b94528b592a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 09:45:39 -0700 Subject: [PATCH 1/8] quest: claim portrait transcode ladders Co-Authored-By: GPT-6 From 8878ec791761743d8e5301be3ea8d741e6c2bb43 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 10:15:41 -0700 Subject: [PATCH 2/8] fix(transcode): size portrait ladder rungs by the coded short side Co-Authored-By: GPT-6 --- .github/workflows/interop.yml | 7 ++ doc/bin/cli.md | 3 + quest/m1/README.md | 1 - quest/m1/portrait-ladder.md | 29 -------- rs/moq-cli/src/transcode.rs | 16 ++--- rs/moq-transcode/src/active.rs | 2 +- rs/moq-transcode/src/catalog.rs | 107 +++++++++++++++++++--------- rs/moq-transcode/src/config.rs | 4 +- rs/moq-transcode/src/ladder.rs | 93 ++++++++++++------------ rs/moq-transcode/src/pipeline.rs | 2 +- rs/moq-transcode/src/rung.rs | 2 +- test/interop/clients/js/portrait.ts | 35 +++++++++ test/justfile | 4 ++ test/portrait/run.sh | 28 ++++++++ 14 files changed, 209 insertions(+), 124 deletions(-) delete mode 100644 quest/m1/portrait-ladder.md create mode 100644 test/interop/clients/js/portrait.ts create mode 100755 test/portrait/run.sh diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 832c647d2b..5813e1d0b0 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/**" @@ -117,6 +118,12 @@ jobs: GH_TOKEN: ${{ github.token }} MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs + - name: Portrait transcode + 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 run: nix develop --command just test interop-negative shell: bash -leo pipefail {0} diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 652af7df57..b259173c92 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -171,6 +171,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/quest/m1/README.md b/quest/m1/README.md index 25c59174d5..bfa8c28408 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -157,7 +157,6 @@ ladders. - [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/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..15dad5c5dd --- /dev/null +++ b/test/interop/clients/js/portrait.ts @@ -0,0 +1,35 @@ +import { parseArgs } from "node:util"; +import type MoqWatch from "@moq/watch/element"; +import { finishTraces, launch, open, pageUrl, serve } 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] = 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 }); + 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 ecf90ca316..e76be7a062 100644 --- a/test/justfile +++ b/test/justfile @@ -46,6 +46,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/run.sh b/test/portrait/run.sh new file mode 100755 index 0000000000..3a646810c8 --- /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" test/interop/interop.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" --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" --broadcast portrait.hang transcode --rung 360:600000 --encoder software --decoder software +cd test/interop/clients/js +bun portrait.ts --url "$URL" From 985e91f91be5404fbc67da3a713c2ff17b0d8f08 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 20:50:02 -0700 Subject: [PATCH 3/8] test(transcode): isolate portrait relay and reject browser errors Retain the portrait quest while strict browser validation exposes the subscription cancellation failure. Co-Authored-By: GPT-6 Astra --- quest/m1/README.md | 1 + quest/m1/portrait-ladder.md | 42 +++++++++++++++++++++++++++++ rs/moq-transcode/README.md | 9 ++++--- test/interop/clients/js/portrait.ts | 41 ++++++++++++++++++---------- test/portrait/relay.toml | 13 +++++++++ test/portrait/run.sh | 2 +- 6 files changed, 89 insertions(+), 19 deletions(-) create mode 100644 quest/m1/portrait-ladder.md create mode 100644 test/portrait/relay.toml diff --git a/quest/m1/README.md b/quest/m1/README.md index e19289e3ed..ecedacafb7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -156,6 +156,7 @@ bench all require its crate. - [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 new file mode 100644 index 0000000000..c3bf243e1c --- /dev/null +++ b/quest/m1/portrait-ladder.md @@ -0,0 +1,42 @@ +# [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 + +The draft uses coded short-side pixels in `rs/moq-transcode/src/ladder.rs` +and `resolve_rungs` in `rs/moq-transcode/src/catalog.rs`. The original +1080x1920 source produced 202x360 for a 360 rung; the implementation now +produces 360x640 and preserves rotation. + +- 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. + +### Progress + +Geometry regressions and the full package checks pass. All-language interop +passes. The browser paints the selected 360x640 rung through software +publish/transcode/decode, and the harness now has its own anonymous local +relay fixture instead of depending on the interop authentication server. + +End-to-end validation remains blocked: closing a superseded catalog subscription +raises an uncaught `subscribe stream ended without SUBSCRIBE_END` error in the +unchanged JS network code. The portrait harness now rejects collected browser +errors instead of silently passing them. Resolve the local cancellation and +promise ownership path, then complete the strict portrait run before closing +this quest. No timeout or retry is used to hide that failure. + +## 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-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/test/interop/clients/js/portrait.ts b/test/interop/clients/js/portrait.ts index 15dad5c5dd..aa8e53d9aa 100644 --- a/test/interop/clients/js/portrait.ts +++ b/test/interop/clients/js/portrait.ts @@ -1,6 +1,6 @@ import { parseArgs } from "node:util"; import type MoqWatch from "@moq/watch/element"; -import { finishTraces, launch, open, pageUrl, serve } from "./harness"; +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"); @@ -8,24 +8,37 @@ const server = serve(); const browser = await launch(); let failed = true; try { - const [page] = await open(browser, pageUrl(server.origin, "subscribe", { - url: values.url, - broadcast: "portrait.hang/transcode.hang", - muted: "true", - visible: "always", - }), "portrait", true); + 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 }); + 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 { diff --git a/test/portrait/relay.toml b/test/portrait/relay.toml new file mode 100644 index 0000000000..27a0fadb0d --- /dev/null +++ b/test/portrait/relay.toml @@ -0,0 +1,13 @@ +# 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"] + +[web.http] +listen = "127.0.0.1:4443" + +[auth] +public = "**" diff --git a/test/portrait/run.sh b/test/portrait/run.sh index 3a646810c8..f469980fb8 100755 --- a/test/portrait/run.sh +++ b/test/portrait/run.sh @@ -12,7 +12,7 @@ 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" test/interop/interop.toml >"$HARNESS_RUN/relay.toml" +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 From 68657a6eafd8def65a7309a53fbfe5915aab9ab9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 18:52:32 -0700 Subject: [PATCH 4/8] fix(net): end retired JS broadcast instances as unroutable A retired instance aborted its requests with a plain Error, which players log as an unexpected failure. Rust ends them with Unroutable; mirror it so watch treats a superseded instance as going offline. Co-Authored-By: Claude Opus 5.5 --- js/net/src/consume.test.ts | 5 +++++ js/net/src/consume.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index 9637b61a86..5d9979f75d 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 as StreamError).code).toBe(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" })); } } } From 5d1700769997daf5532d08da2ec7666021f57974 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 19:06:11 -0700 Subject: [PATCH 5/8] fix(signals): observe every race input after the winner race() returned as soon as an input had already settled, without observing the rest. A loser that rejected later, such as a subscription's response reader after a local close, surfaced as an unhandled rejection in the browser. Observe every input like Promise.race, keeping one shared reaction. Co-Authored-By: Claude Opus 5.5 --- js/signals/src/index.test.ts | 18 ++++++++++++++++++ js/signals/src/index.ts | 18 +++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) 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); From b06b00092893e4ea5de55d90ef720a034974b47b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 19:31:47 -0700 Subject: [PATCH 6/8] test(transcode): negotiate lite-07 for the portrait CLI peers A CLI peer that falls back to WebSocket and then upgrades to QUIC replaces its session. On lite-06 that is a new epochless instance, so the source and derivative catalog requests fail as unroutable by design. Lite-07 epochs carry them across the upgrade. The strict browser run now passes, so the portrait quest is complete. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/portrait-ladder.md | 42 ------------------------------------- test/portrait/relay.toml | 3 +++ test/portrait/run.sh | 4 ++-- 4 files changed, 5 insertions(+), 45 deletions(-) delete mode 100644 quest/m1/portrait-ladder.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 43ac7d1960..5f8bc13574 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 c3bf243e1c..0000000000 --- a/quest/m1/portrait-ladder.md +++ /dev/null @@ -1,42 +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 - -The draft uses coded short-side pixels in `rs/moq-transcode/src/ladder.rs` -and `resolve_rungs` in `rs/moq-transcode/src/catalog.rs`. The original -1080x1920 source produced 202x360 for a 360 rung; the implementation now -produces 360x640 and preserves rotation. - -- 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. - -### Progress - -Geometry regressions and the full package checks pass. All-language interop -passes. The browser paints the selected 360x640 rung through software -publish/transcode/decode, and the harness now has its own anonymous local -relay fixture instead of depending on the interop authentication server. - -End-to-end validation remains blocked: closing a superseded catalog subscription -raises an uncaught `subscribe stream ended without SUBSCRIBE_END` error in the -unchanged JS network code. The portrait harness now rejects collected browser -errors instead of silently passing them. Resolve the local cancellation and -promise ownership path, then complete the strict portrait run before closing -this quest. No timeout or retry is used to hide that failure. - -## 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/test/portrait/relay.toml b/test/portrait/relay.toml index 27a0fadb0d..aa127e3c42 100644 --- a/test/portrait/relay.toml +++ b/test/portrait/relay.toml @@ -5,6 +5,9 @@ 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" diff --git a/test/portrait/run.sh b/test/portrait/run.sh index f469980fb8..ef3adea930 100755 --- a/test/portrait/run.sh +++ b/test/portrait/run.sh @@ -19,10 +19,10 @@ harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID" 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" --broadcast portrait.hang import avc3 + "$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" --broadcast portrait.hang transcode --rung 360:600000 --encoder software --decoder software + --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" From 4b93a173dd8f5d5d8097be5ebc38124b7cc0d5ba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 19:31:47 -0700 Subject: [PATCH 7/8] test(net): satisfy biome in the retirement assertion Co-Authored-By: Claude Opus 5.5 --- js/net/src/consume.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index 5d9979f75d..a3efb9b350 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -118,7 +118,7 @@ test("request validity cancels one selection period and permits the held fallbac // 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 as StreamError).code).toBe(StreamCode.Unroutable); + expect(first?.reason).toMatchObject({ code: StreamCode.Unroutable }); const sourceB = new Producer(); const instanceB = cache.instance(path); const heldB = cache.insert(path, instanceB, sourceB.consume()); From d78aa1b414a7680b41f25300f01a65d98d6cc2fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 11 Oct 2026 19:56:53 -0700 Subject: [PATCH 8/8] ci: run the portrait suite independently like its neighbours Co-Authored-By: Claude Opus 5.5 --- .github/workflows/interop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 709c70acf8..055135165a 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -127,6 +127,7 @@ jobs: 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: