Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e6cc996
chore(quest): claim quest/m1/simulcast-rung-disable.md
kixelated Oct 25, 2026
1216bca
feat(rate)!: disable simulcast rungs the grant cannot fund
kixelated Oct 25, 2026
fc4e2e0
feat(ffi): disable a video rung its grant cannot fund
kixelated Oct 25, 2026
6bb7266
feat(c)!: disable a video rung its grant cannot fund
kixelated Oct 25, 2026
3ba5e10
feat(net)!: an idle JS reservation reads the share it would get
kixelated Oct 25, 2026
506d634
feat(bindings): pass a lower video rendition through every wrapper
kixelated Oct 25, 2026
1c4d279
docs(obs): a missing grant now means only no estimate
kixelated Oct 25, 2026
99f4ef1
chore(quest): narrow simulcast-rung-disable to OBS and the shaped proof
kixelated Oct 25, 2026
b04d1b0
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 25, 2026
a82a81d
feat(c)!: moq_encode_video takes a moq_video_encode_options
kixelated Oct 25, 2026
30c4025
fix(rate): a new lower rendition keeps the applied target
kixelated Oct 25, 2026
f8a24df
fix(ffi,c): take back a target dropped for a moved policy
kixelated Oct 25, 2026
fa58566
Merge origin/main into quest/m1/simulcast-rung-disable
kixelated Oct 25, 2026
7b6e6c7
fix(ffi): adopt typed durations in the rung tests after the main merge
kixelated Oct 25, 2026
aee8421
fix(rate): close the rung state holes and model-check Control
kixelated Oct 26, 2026
80ab074
fix(ffi,c): a rung watches its lower rendition's ceiling directly
kixelated Oct 26, 2026
0b62584
Merge origin/main into quest/m1/simulcast-rung-disable
kixelated Oct 26, 2026
d7ee6bf
Merge origin/main into quest/m1/simulcast-rung-disable
kixelated Oct 26, 2026
813ef89
Merge remote-tracking branch 'origin/main' into land-6781
kixelated Oct 26, 2026
def2ee5
docs(lib): a simulcast rung only disables while the session probes
kixelated Oct 26, 2026
47f86d5
Merge origin/main into quest/m1/simulcast-rung-disable
kixelated Oct 26, 2026
4b01197
Merge origin/main into quest/m1/simulcast-rung-disable
kixelated Oct 27, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions cpp/obs/src/moq-rate.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class MoQRate {
uint64_t Max() const { return max; }

// Feed a grant, returning the new target when it moved enough to apply.
// A missing grant (no estimate, or no subscriber) holds the target: losing
// the estimate is not evidence the uplink got better.
// A missing grant (no estimate) holds the target: losing the estimate is not
// evidence the uplink got better.
std::optional<uint64_t> Update(std::optional<uint64_t> grant, Clock::time_point now)
{
if (!grant)
Expand Down
67 changes: 64 additions & 3 deletions dart/moq_ffi/lib/src/moq.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4718,11 +4718,13 @@ class MoqVideoProducerOptions {
final MoqVideoEncoderOutput output;
final String? track;
final MoqBandwidth? bandwidth;
final MoqVideoProducer? lower;
MoqVideoProducerOptions({
required this.input,
required this.output,
this.track = null,
this.bandwidth = null,
this.lower = null,
});
}

Expand Down Expand Up @@ -4753,12 +4755,18 @@ class FfiConverterMoqVideoProducerOptions {
);
final bandwidth = bandwidth_lifted.value;
new_offset += bandwidth_lifted.bytesRead;
final lower_lifted = FfiConverterOptionalMoqVideoProducer.read(
Uint8List.view(buf.buffer, new_offset),
);
final lower = lower_lifted.value;
new_offset += lower_lifted.bytesRead;
return LiftRetVal(
MoqVideoProducerOptions(
input: input,
output: output,
track: track,
bandwidth: bandwidth,
lower: lower,
),
new_offset - buf.offsetInBytes,
);
Expand All @@ -4770,6 +4778,7 @@ class FfiConverterMoqVideoProducerOptions {
FfiConverterMoqVideoEncoderOutput.allocationSize(value.output) +
FfiConverterOptionalString.allocationSize(value.track) +
FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) +
FfiConverterOptionalMoqVideoProducer.allocationSize(value.lower) +
0;
final buf = Uint8List(total_length);
write(value, buf);
Expand All @@ -4794,6 +4803,10 @@ class FfiConverterMoqVideoProducerOptions {
value.bandwidth,
Uint8List.view(buf.buffer, new_offset),
);
new_offset += FfiConverterOptionalMoqVideoProducer.write(
value.lower,
Uint8List.view(buf.buffer, new_offset),
);
return new_offset - buf.offsetInBytes;
}

Expand All @@ -4802,6 +4815,7 @@ class FfiConverterMoqVideoProducerOptions {
FfiConverterMoqVideoEncoderOutput.allocationSize(value.output) +
FfiConverterOptionalString.allocationSize(value.track) +
FfiConverterOptionalMoqBandwidth.allocationSize(value.bandwidth) +
FfiConverterOptionalMoqVideoProducer.allocationSize(value.lower) +
0;
}
}
Expand Down Expand Up @@ -16180,6 +16194,53 @@ class FfiConverterOptionalMoqVideoHint {
}
}

class FfiConverterOptionalMoqVideoProducer {
static MoqVideoProducer? lift(RustBuffer buf) {
return FfiConverterOptionalMoqVideoProducer.read(buf.asUint8List()).value;
}

static LiftRetVal<MoqVideoProducer?> read(Uint8List buf) {
if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) {
return LiftRetVal(null, 1);
}
final result = FfiConverterMoqVideoProducer.read(
Uint8List.view(buf.buffer, buf.offsetInBytes + 1),
);
return LiftRetVal<MoqVideoProducer?>(result.value, result.bytesRead + 1);
}

static int allocationSize([MoqVideoProducer? value]) {
if (value == null) {
return 1;
}
return FfiConverterMoqVideoProducer.allocationSize(value) + 1;
}

static RustBuffer lower(MoqVideoProducer? value) {
if (value == null) {
return toRustBuffer(Uint8List.fromList([0]));
}
final buf = Uint8List(
FfiConverterOptionalMoqVideoProducer.allocationSize(value),
);
FfiConverterOptionalMoqVideoProducer.write(value, buf);
return toRustBuffer(buf);
}

static int write(MoqVideoProducer? value, Uint8List buf) {
if (value == null) {
buf[0] = 0;
return 1;
}
buf[0] = 1;
return FfiConverterMoqVideoProducer.write(
value,
Uint8List.view(buf.buffer, buf.offsetInBytes + 1),
) +
1;
}
}

class FfiConverterOptionalMoqVideoRead {
static MoqVideoRead? lift(RustBuffer buf) {
return FfiConverterOptionalMoqVideoRead.read(buf.asUint8List()).value;
Expand Down Expand Up @@ -20788,7 +20849,7 @@ void _checkApiChecksums() {
38021) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqreservation_grant() != 59401) {
if (uniffi_moq_ffi_checksum_method_moqreservation_grant() != 10773) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqreservation_update() != 9626) {
Expand Down Expand Up @@ -21331,7 +21392,7 @@ void _checkApiChecksums() {
if (uniffi_moq_ffi_checksum_method_moqvideoproducer_set_bitrate() != 28203) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_method_moqvideoproducer_write() != 6141) {
if (uniffi_moq_ffi_checksum_method_moqvideoproducer_write() != 17427) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_constructor_moqaudiocodec_aac() != 62161) {
Expand Down Expand Up @@ -21439,7 +21500,7 @@ void _checkApiChecksums() {
if (uniffi_moq_ffi_checksum_constructor_moqvideoencoder_new() != 22531) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
if (uniffi_moq_ffi_checksum_constructor_moqvideoproducer_new() != 34351) {
if (uniffi_moq_ffi_checksum_constructor_moqvideoproducer_new() != 51291) {
throw UniffiInternalError.panicked("UniFFI API checksum mismatch");
}
}
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/c/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ if (session < 0)
return fail(moq_error());
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Publication track names stay separate from codec settings. For simulcast, give each rendition the next one down as the `lower` handle in its `moq_video_encode_options`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/cpp/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ ok(broadcast->announce(route));
ok(broadcast->close()); // keep the producer alive while publishing, then close explicitly
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Futures

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/dart/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ mine.close();
await moq.close();
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/go/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ _ = video.Finish()
broadcast.Close() // keep the producer reachable while publishing, then close explicitly
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings. For simulcast, give each rendition the next one down as `Lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ publication.announce();
- **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too.
- **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream.
- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host.
- **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate.
- **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing, though its reservation still reads the share it would get. Publishers reserve against it so their targets sum to the estimate.
- **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, syncs at once with what this side holds, and the peer's tracks follow as `added`. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery).
- **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a track that replaces it, created or requested, starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups by writing its first group with an explicit sequence.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Omitting it means untimed, as in Rust. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers.
Expand Down
2 changes: 1 addition & 1 deletion doc/lib/kt/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Moq.connect("https://relay.example.com").use { moq ->
}
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Producer options hold input, codec settings, track naming, and optional session bandwidth ownership; standalone encoders take only codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/py/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def main():
asyncio.run(main())
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Pass the publication track name to the producer, alongside its input and codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Pass the publication track name to the producer, alongside its input and codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/swift/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ session.shutdown()
try await session.closed()
```

Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Pass the publication track name to the producer, alongside its input and codec settings.
Raw video encoders take an exact numerator/denominator frame rate, such as 30000/1001. Pass the publication track name to the producer, alongside its input and codec settings. For simulcast, give each rendition the next one down as `lower`: it stops encoding, advertised as disabled, while its bandwidth grant cannot fund it, and resumes once the grant recovers. A rung only disables while the session probes for bandwidth, which on native transports takes moq-quic with BBR3.

## Things to know

Expand Down
4 changes: 2 additions & 2 deletions go/wrapper/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ func (b *Bandwidth) Reserve(demand *TrackDemand, maxBps uint64) *Reservation {

// Reservation is one track's standing claim on a Bandwidth.
//
// Grant is a snapshot: nil means no estimate or no demand, so hold the current
// rate, and 0 is a real zero grant.
// Grant is a snapshot: nil means no estimate, so hold the current rate, and 0 is
// a real zero grant. While nothing subscribes it is the share the track would get.
// Update(0) relinquishes the allocation while the track stays subscribed; the
// handle remains usable and a later Update can claim bandwidth again.
type Reservation struct {
Expand Down
10 changes: 9 additions & 1 deletion go/wrapper/video/video.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ type ProducerOptions struct {
Track *string
// Bandwidth reserves the track against a session allocator when non-nil.
Bandwidth *moq.Bandwidth
// Lower is the next rendition down a simulcast ladder: when non-nil, this one
// disables while the grant cannot fund it and resumes once it recovers. Needs
// Bandwidth and a bitrate above the lower rendition's.
Lower *Producer
}

// NewProducer opens a video track on the catalog's broadcast and registers its rendition.
Expand All @@ -93,7 +97,11 @@ func NewProducer(catalog *media.CatalogProducer, options ProducerOptions) (*Prod
raw := bridge.Bandwidth(options.Bandwidth)
bw = &raw
}
inner, err := ffi.NewMoqVideoProducer(bridge.CatalogProducer(catalog), ffi.MoqVideoProducerOptions{Input: options.Input, Output: options.Output, Track: options.Track, Bandwidth: bw})
var lower **ffi.MoqVideoProducer
if options.Lower != nil {
lower = &options.Lower.inner
}
inner, err := ffi.NewMoqVideoProducer(bridge.CatalogProducer(catalog), ffi.MoqVideoProducerOptions{Input: options.Input, Output: options.Output, Track: options.Track, Bandwidth: bw, Lower: lower})
if err != nil {
return nil, err
}
Expand Down
16 changes: 12 additions & 4 deletions js/net/src/bandwidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ test("concurrent tracks split the estimate", () => {
allocator.close();
});

test("an idle track claims nothing", () => {
test("an idle track claims nothing", async () => {
const estimate = new Signal<number | undefined>(2_000_000);
const allocator = new Allocator(estimate);

Expand All @@ -99,10 +99,18 @@ test("an idle track claims nothing", () => {
const idle = allocator.reserve(idleTrack.demand(), 4_000_000);

expect(watched.peek()).toBe(2_000_000);
// Not zero: an idle share reports "no opinion" so a sender that is mid-shutdown
// holds its rate instead of retuning to the floor on the way out.
expect(idle.peek()).toBeUndefined();
// What it would get if a viewer joined, so a rendition stopped for lack of
// bandwidth can resume after its viewers left.
expect(idle.peek()).toBe(1_000_000);
expect(idle.grant.peek()).toBe(1_000_000);

// The idle share keeps following the estimate.
const next = idle.grant.changed();
estimate.set(4_000_000);
expect(await next).toBe(2_000_000);
expect(watched.peek()).toBe(4_000_000);

estimate.set(2_000_000);
idleTrack.subscribe();
expect(watched.peek()).toBe(1_000_000);
expect(idle.peek()).toBe(1_000_000);
Expand Down
Loading
Loading