Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/interop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down Expand Up @@ -125,6 +126,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs

- name: Portrait transcode
run: nix develop --command just test portrait
Comment on lines +129 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the portrait suite independent after setup

Unlike the surrounding suites, this step has no explicit if, so GitHub Actions applies the implicit success() condition. If any earlier independently-run suite fails during a nightly or manual run, the portrait test is skipped instead of reporting its own result, contrary to the workflow's stated independent-suite behavior. Give it the same !cancelled() && steps.setup.outcome == 'success' condition as the adjacent test steps.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in d78aa1b: the portrait step now uses the same !cancelled() && steps.setup.outcome == 'success' condition as its neighbours.

(Written by Claude Opus 5.5)

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
Expand Down
3 changes: 3 additions & 0 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions js/net/src/consume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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());
Expand Down
4 changes: 3 additions & 1 deletion js/net/src/consume.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" }));
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions js/signals/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
once.set("done");
let fail!: (err: Error) => void;
const later = new Promise<never>((_, 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<number>();
const result = race([new Promise(() => {}), once]);
Expand Down
18 changes: 13 additions & 5 deletions js/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,17 +449,24 @@ function raceUntil<T>(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);
}
});
}
Expand All @@ -471,7 +478,8 @@ function raceUntil<T>(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<const T extends readonly unknown[]>(values: T): Promise<Awaited<T[number]>> {
return raceUntil(values);
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 0 additions & 29 deletions quest/m1/portrait-ladder.md

This file was deleted.

16 changes: 8 additions & 8 deletions rs/moq-cli/src/transcode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ pub struct Args {
#[usage(long)]
pub output: Option<String>,

/// 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<RungArg>,
Expand All @@ -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)]
Expand All @@ -55,15 +55,15 @@ impl std::str::FromStr for RungArg {
type Err = String;

fn from_str(arg: &str) -> Result<Self, Self::Err> {
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),
)))
}
Expand Down
9 changes: 5 additions & 4 deletions rs/moq-transcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rs/moq-transcode/src/active.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading
Loading