Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ jobs:
if: ${{ !cancelled() }}
run: nix develop --command just rs test -p moq-mux benchmark_damage_pid_fanout --run-ignored only --success-output immediate

- name: Container export benchmark smoke
if: ${{ !cancelled() }}
run: nix develop --command just rs bench-export --test

- name: RTMP chunk-stream benchmark smoke
if: ${{ !cancelled() }}
run: nix develop --command just rs bench-rtmp --test
Expand Down
18 changes: 12 additions & 6 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,18 +306,24 @@ See [Authentication](/bin/relay/auth).
## Retention and latency

`import --max-age` (default 30 s) tells relays how long to keep old groups
fetchable, which the [HLS gateway](/bin/hls) depends on. `export --max-delay`
(default 500 ms) is how far a stalled group may fall behind the live edge
before *this* consumer skips it. Raising the first never delays playback.
fetchable, which the [HLS gateway](/bin/hls) depends on. `export fmp4`, `export h264`,
and `export h265` take `--max-delay` (default 500 ms): how far a stalled group
may fall behind the live edge before *this* consumer skips it. Raising
`--max-age` never delays playback.
At stdin EOF, `import` keeps the finished broadcast up for its max-age, so a
subscriber still catching up can drain it; Ctrl-C ends that early.

`export ts` takes `--delay` (default 500 ms) instead, like an SRT receiver's
`export ts`, `export flv`, and `export mkv` take `--delay` (default 500 ms)
instead, like an SRT receiver's
latency: each frame is muxed that long after its decode time, all tracks in
decode order, so two exporters of one broadcast emit the same order. A frame
arriving after its deadline is dropped, and video resumes at its next
keyframe. The output's PCR follows the source's clock within what ISO/IEC
13818-1 allows. `--delay 0` writes frames in arrival order and drops nothing.
keyframe. The delay also bounds how stale a group may get, so `--delay 0` writes each
frame as soon as it is read and drops none as late, but still skips a stalled
group once a newer one is ready.
FLV and MKV reject their former `--max-delay` spelling with a migration
message. TS output's PCR follows the source's clock within what ISO/IEC
13818-1 allows.

`export ts --passthrough` reads the track an `import ts --passthrough` published
and writes its packets back out unchanged, from its first group on, each run
Expand Down
5 changes: 5 additions & 0 deletions doc/bin/rtmp.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ and anchors the catalog clock on the first frame, so it names the wall time the
push arrived. A group starting before the previous group's start, such as an
encoder restarting its timestamps mid-push, ends that push with an error.

Egress uses `--max-delay` (default 500 ms) for both the FLV release delay and
the stalled-group budget. Frames go out in decode order; a late video frame
is dropped and video resumes at the next keyframe. Zero releases available
frames immediately without deadline drops.

A player that advertises enhanced-RTMP multitrack receives every rendition.
Any other player receives one video rendition, the largest picture (then
highest bitrate) in a codec it advertised, and one audio rendition, the highest
Expand Down
20 changes: 11 additions & 9 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,15 @@ These land with the next breaking release, not the 2026-09-23 train.
the rendition's own delay added on top. `Sync.out.jitter` now always
equals `Sync.out.delay`, and `"auto"` with no decoder registered resolves to
0 rather than 100 ms.
- **`moq export ts --delay` replaces `--max-age`** (#4645). Each frame is
- **`moq export ts`, `flv`, and `mkv` take `--delay`** (#4645). Each frame is
written that long after its decode time, on a clock that follows the
source's; `--max-age` and `--latency-max` are refused with the new flag
named. In Rust, moq-mux's `ts::Export::with_max_age` is `with_delay`, and
`ts::stats::Export` gains the dropped-frame count (late frames, and the
video frames then dropped waiting for a keyframe), measured drift and
out-of-tolerance count beside its `streams` rows. It is no longer `Eq`.
source's; the older staleness spellings are refused with the new flag
named. In Rust, moq-mux's `ts::Export::with_max_age` and the FLV and MKV
exports' `with_max_age` are `with_delay`. moq-rtmp's FLV egress releases
frames on its `max_delay` the same way. `ts::stats::Export` gains the
dropped-frame count (late frames, and the video frames then dropped waiting
for a keyframe), measured drift and out-of-tolerance count beside its
`streams` rows. It is no longer `Eq`.
- **moq-mux has no clock translators.** `clock::Anchor`, `clock::Lane`, and
`SourceMap` (#4667) are gone, along with the importers' `live()`. Publish the
source's own timestamps and let the catalog clock map them to wall time.
Expand Down Expand Up @@ -232,7 +234,7 @@ These land with the next breaking release, not the 2026-09-23 train.
shares a name with a publisher's retention, which keeps `max_age`. In Rust,
`track::Subscription::max_age` and `with_max_age` are `max_delay` and
`with_max_delay`; moq-mux's `container::Consumer::set_max_age` and the fMP4,
MKV, FLV, H.264, and H.265 exports' `with_max_age` are `set_max_delay` and
H.264, and H.265 exports' `with_max_age` are `set_max_delay` and
`with_max_delay`; moq-audio's and moq-video's `decode::Options::max_age` is
`max_delay`, as is moq-audio's `decode::Consumer::max_age()`; and moq-rtmp's
`Play::with_max_age`, `Client::with_export_max_age`,
Expand All @@ -247,8 +249,8 @@ These land with the next breaking release, not the 2026-09-23 train.
`MoqVideoDecoderOutput` take `max_delay_us` (each binding in its own casing),
and so do C's `moq_subscription`, `moq_audio_decoder_output`,
`moq_video_decoder_output`, `moq_consume_video`, and `moq_consume_audio`.
`moq export fmp4`, `mkv`, `flv`, `h264`, `h265`, and `rtmp` take
`--max-delay`, and refuse `--max-age`. `track::Info::max_age`,
`moq export fmp4`, `h264`, `h265`, and `rtmp` take `--max-delay`, and
refuse `--max-age`. `track::Info::max_age`,
`MoqTrackInfo.max_age_us`, and `moq import --max-age` are unchanged, as is the wire.
- **moq-relay auth takes the client-CA answer.** `auth::Config::validate` and
`init` take `client_ca: bool`, whether any listener verifies client
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,6 @@ disabled rung strands WHEP, HLS, and moq-mux export viewers.
- [moqsink first catalog](/quest/m1/gst-first-catalog.md) - a restarted `moqsink` lists every requested pad in its first catalog, so `moqsrc` resumes each on its held pad
- [moqsrc A/V alignment](/quest/m1/gst-src-av-sync.md) - every `moqsrc` pad of a run shares one timestamp reference and segment base
- [TS passthrough export](/quest/m1/ts-passthrough-export.md) - `export ts --passthrough` writes the `m2ts` track back byte-identical (less late drops) on a fixed delay
- [FLV and MKV export delay](/quest/m1/export-delay.md) - FLV and MKV interleave through the shared jitter buffer on a fixed delay, breaking the CLI once
- [Bindings size profile](/quest/m1/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib
- [Go mirror delivery](/quest/m1/go-mirror-delivery.md) - the Go binding's staticlibs stop growing git history by ~210 MiB per release
- [Dart on iOS](/quest/m1/dart-ios.md) - prove the shipped iOS native asset actually loads on a device, which no CI can
Expand Down
29 changes: 0 additions & 29 deletions quest/m1/export-delay.md

This file was deleted.

5 changes: 5 additions & 0 deletions rs/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,8 @@ bench-stats *args:
# Sweep latency observations against writer count and report density.
bench-latency:
bash sh/rs/nextest.sh cargo nextest run --locked --release -p moq-bench --run-ignored only -E 'test(benchmark_observation_contention)' --success-output immediate

# Sweep container export tracks and queued frames independently.
[positional-arguments]
bench-export *args:
cargo bench --locked -p moq-mux --bench export -- "$@"
103 changes: 89 additions & 14 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,9 +906,11 @@ impl Export {
found.flag("--name", None, "--broadcast");
}
match &self.sink {
ExportSink::Fmp4(args) | ExportSink::Mkv(args) => found.extend(args.container.deprecated()),
ExportSink::Fmp4(args) => found.extend(args.container.deprecated()),
ExportSink::Mkv(args) => found.extend(args.container.deprecated()),
ExportSink::Ts(args) => found.extend(args.deprecated()),
ExportSink::Flv(args) | ExportSink::H264(args) | ExportSink::H265(args) => found.extend(args.deprecated()),
ExportSink::Flv(args) => found.extend(args.deprecated()),
ExportSink::H264(args) | ExportSink::H265(args) => found.extend(args.deprecated()),
ExportSink::Hls(hls) => found.extend(hls.tls.deprecated()),
ExportSink::Rtmp(rtmp) => found.extend(rtmp.deprecated()),
_ => {}
Expand All @@ -924,11 +926,11 @@ pub enum ExportSink {
/// Fragmented MP4 / CMAF to stdout.
Fmp4(Fragmented),
/// Matroska / WebM to stdout.
Mkv(Fragmented),
Mkv(BufferedFragmented),
/// MPEG-TS to stdout.
Ts(Transport),
/// FLV / RTMP container to stdout.
Flv(Container),
Flv(Buffered),
/// H.264 Annex-B elementary stream to stdout.
H264(Container),
/// H.265 Annex-B elementary stream to stdout.
Expand Down Expand Up @@ -976,14 +978,23 @@ impl ExportSink {
mux_rate: None,
passthrough: false,
};
let buffered = |format, args: &Buffered| Stdout {
format,
max_delay: args.delay.into_std(),
linger: args.linger.into_std(),
stitch: false,
fragment_duration: None,
mux_rate: None,
passthrough: false,
};
Some(match self {
Self::Fmp4(args) => Stdout {
fragment_duration: args.fragment_duration.map(crate::duration::Duration::into_std),
..container(SubscribeFormat::Fmp4, &args.container)
},
Self::Mkv(args) => Stdout {
fragment_duration: args.fragment_duration.map(crate::duration::Duration::into_std),
..container(SubscribeFormat::Mkv, &args.container)
..buffered(SubscribeFormat::Mkv, &args.container)
},
Self::Ts(args) => Stdout {
format: SubscribeFormat::Ts,
Expand All @@ -994,7 +1005,7 @@ impl ExportSink {
mux_rate: args.mux_rate,
passthrough: args.passthrough,
},
Self::Flv(args) => container(SubscribeFormat::Flv, args),
Self::Flv(args) => buffered(SubscribeFormat::Flv, args),
Self::H264(args) => container(SubscribeFormat::H264, args),
Self::H265(args) => container(SubscribeFormat::H265, args),
_ => return None,
Expand All @@ -1005,7 +1016,7 @@ impl ExportSink {
/// A stdout sink's format and the options that apply to it.
pub struct Stdout {
pub format: SubscribeFormat,
/// The staleness budget, which `ts` also holds every frame for (`--delay`).
/// The staleness budget, also used as the TS/FLV/MKV release delay.
pub max_delay: Duration,
pub linger: Duration,
/// Follow a replacement as a program switch (`ts` only).
Expand All @@ -1016,7 +1027,7 @@ pub struct Stdout {
pub passthrough: bool,
}

/// Options shared by the stdout container sinks other than `ts`.
/// Staleness options for stdout sinks without a fixed release delay.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct Container {
Expand Down Expand Up @@ -1051,6 +1062,56 @@ impl Container {
}
}

/// A stdout container with a fixed release delay.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct Buffered {
/// How long after its decode time each frame is written (e.g. `500ms`); late frames are dropped.
/// The same delay bounds source staleness. Zero writes each frame as soon as it is read and
/// drops none as late, but still skips a stalled group once a newer one is ready.
#[usage(long, default = "500ms")]
pub delay: crate::duration::Duration,
/// Accepted only to refuse a nonzero value: only TS can mark a broadcast restart.
#[usage(long, default = "0s", hide = true)]
pub linger: crate::duration::Duration,
/// The retired staleness-only setting.
#[usage(long, hide = true)]
max_delay: Option<crate::duration::Duration>,
/// An older spelling of the staleness setting.
#[usage(long = "max-age", hide = true)]
max_age: Option<crate::duration::Duration>,
/// An older spelling of the staleness setting.
#[usage(long = "latency-max", hide = true)]
latency_max: Option<crate::duration::Duration>,
}

impl Buffered {
fn deprecated(&self) -> moq_tokio::cli::Deprecated {
let mut found = moq_tokio::cli::Deprecated::default();
for (present, flag) in [
(self.max_delay.is_some(), "--max-delay"),
(self.max_age.is_some(), "--max-age"),
(self.latency_max.is_some(), "--latency-max"),
] {
if present {
found.flag(flag, None, "--delay");
}
}
found
}
}

/// A buffered Matroska output with an optional cluster cap.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct BufferedFragmented {
#[usage(flatten)]
pub container: Buffered,
/// Cap the output cluster duration (e.g. `2s`); defaults to video GOPs.
#[usage(long)]
pub fragment_duration: Option<crate::duration::Duration>,
}

/// The MPEG-TS stdout container.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
Expand Down Expand Up @@ -1108,15 +1169,15 @@ impl Transport {
}
}

/// The fmp4 / mkv stdout containers: [`Container`] plus a fragment cap.
/// The fMP4 stdout container with a fragment cap.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct Fragmented {
#[usage(flatten)]
pub container: Container,

/// Cap the output fragment/cluster duration (e.g. `2s`).
/// Defaults to publisher groups for fMP4 and video GOPs for MKV.
/// Defaults to publisher groups.
#[usage(long)]
pub fragment_duration: Option<crate::duration::Duration>,
}
Expand Down Expand Up @@ -1157,7 +1218,7 @@ mod tests {
.unwrap()
};
assert!(parse("ts", "10s").validate().is_ok());
for format in ["fmp4", "mkv", "flv", "h264", "h265"] {
for format in ["fmp4", "h264", "h265"] {
let err = parse(format, "10s").validate().unwrap_err().to_string();
assert!(err.contains("--linger"), "{format}: {err}");
assert!(
Expand Down Expand Up @@ -1873,11 +1934,11 @@ mod tests {
else {
panic!("--latency-max must not start a run");
};
assert!(err.to_string().contains("--latency-max -> --max-delay"), "{}", err);
assert!(err.to_string().contains("--latency-max -> --delay"), "{}", err);
}

/// A publisher's retention is `--max-age` on `import`; a subscriber's staleness budget is
/// `--max-delay` on `export`, except `export ts`, which spells it `--delay`.
/// `--max-delay` for immediate exports and `--delay` for buffered exports.
#[test]
fn export_staleness_is_max_delay_and_import_retention_is_max_age() {
let export = |args: &[&str]| {
Expand All @@ -1893,7 +1954,7 @@ mod tests {
export.sink.stdout().unwrap().max_delay
};

for format in ["fmp4", "mkv", "flv", "h264", "h265"] {
for format in ["fmp4", "h264", "h265"] {
assert_eq!(stdout(&[format]), Duration::from_millis(500), "{format}");
assert_eq!(
stdout(&[format, "--max-delay", "2s"]),
Expand All @@ -1906,6 +1967,20 @@ mod tests {
assert!(err.to_string().contains("--max-age -> --max-delay"), "{format}: {err}");
}

for format in ["flv", "mkv"] {
assert_eq!(stdout(&[format]), Duration::from_millis(500));
assert_eq!(stdout(&[format, "--delay", "2s"]), Duration::from_secs(2));
for old in ["--max-delay", "--max-age", "--latency-max"] {
let Err(err) = export(&[format, old, "2s"]) else {
panic!("{old} must be refused")
};
assert!(
err.to_string().contains(&format!("{old} -> --delay")),
"{format}: {err}"
);
}
}

assert_eq!(stdout(&["ts", "--delay", "2s"]), Duration::from_secs(2));
assert!(export(&["ts", "--max-delay", "2s"]).is_err(), "ts spells it --delay");

Expand Down
4 changes: 2 additions & 2 deletions rs/moq-cli/src/rtmp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ pub struct ExportArgs {
#[usage(flatten)]
pub endpoint: Args,

/// How far a group may fall behind the live edge before it is skipped. RTMP is
/// unpaced, so this bounds buffering, not the wire rate.
/// FLV frame release delay and stalled-group budget. Late video resumes at
/// its next keyframe; zero releases available frames without deadline drops.
#[usage(long, default = "500ms")]
pub max_delay: crate::duration::Duration,

Expand Down
4 changes: 2 additions & 2 deletions rs/moq-cli/src/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ impl Subscribe {
// shape internally (synthesizing avcC/hvcC from inline parameter sets).
let stream = self.stream().await?;
let mut mkv = moq_mux::container::mkv::Export::new(self.source, stream)
.with_max_delay(self.args.max_delay)
.with_delay(self.args.max_delay)
.with_fragment_duration(self.args.fragment_duration);

while let Some(chunk) = mkv.next().await? {
Expand Down Expand Up @@ -480,7 +480,7 @@ impl Subscribe {
// internally (synthesizing avcC from inline parameter sets). Only H.264 video
// and AAC audio are supported; `fragment_duration` does not apply to FLV.
let stream = self.stream().await?;
let mut flv = moq_mux::container::flv::Export::new(self.source, stream).with_max_delay(self.args.max_delay);
let mut flv = moq_mux::container::flv::Export::new(self.source, stream).with_delay(self.args.max_delay);

while let Some(chunk) = flv.next().await? {
stdout.write_all(&chunk).await?;
Expand Down
4 changes: 4 additions & 0 deletions rs/moq-mux/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ tracing-test = { workspace = true, features = ["no-env-filter"] }
[[bench]]
name = "catalog"
harness = false

[[bench]]
name = "export"
harness = false
Loading
Loading