Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
18 changes: 18 additions & 0 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,24 @@ continuity error, and the rest carry on. It takes neither `--linger`,
`--stitch` nor `--mux-rate`. Only the hang catalog names the track, so an
`.msf` broadcast needs `--catalog-format hang`.

`export ts --sync` renders every packet from the broadcast alone, so two exports
of it, joined at different moments and fed with different skew, emit identical
TS for the content both receive, continuity counters included, as the legs of a
1+1 pair must; an SMPTE ST 2022-7 pair also needs identical RTP framing around
it, which this command does not write. Each PID gets a fixed lane of the constant rate, sized
for its codec's most, with video taking the rest; content one leg lacks leaves
its packets as stuffing there. The PCR rides a PID of its own, and each PID's
continuity counter restarts at every group with the break flagged. It needs a
constant rate, the same on every leg since the lanes follow from it
(`--mux-rate`, or the catalog's), costing headroom over the
source's (a 10 Mb/s channel needs about 12), and takes neither `--stitch` nor
`--passthrough`. A group that starts behind the one before it, on a lane too
narrow to absorb that one, is logged and counted in `overruns`: the legs can
differ there. So can reordered video whose SPS declares no reorder depth
(`max_num_reorder_frames`), which is logged, and SI while one leg holds an older
revision than the other or around an SI table the catalog adds or drops, which
each leg takes up when its catalog update arrives.

A stdout export ends with the broadcast. `export ts --linger 10s` waits that
long for the same publisher instance to come back, and carries on with the same
stream. A return that cannot be read yet, such as one still routed through a
Expand Down
2 changes: 2 additions & 0 deletions doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ source's. `export ts --passthrough` reports it without `mpegts`.
| `offset` | Gauge, milliseconds: how far the output clock sits from the source's plus the delay. Positive holds frames longer than the delay. The clock pulls it shut over ten minutes. |
| `drift` | Gauge, parts per million: the source clock's rate against ours, positive running fast. |
| `outOfTolerance` | Measurements that found the source clock past the 30 ppm ISO/IEC 13818-1 allows, which the output cannot follow. |
| `overruns` | `export ts --sync` only: groups that began behind the end of the one before on their PID, where legs can differ until the lane catches up. |
| `late` | `export ts --sync` only: packets dropped for reaching their slot after it went out, leaving stuffing. |

## Broadcasts

Expand Down
4 changes: 3 additions & 1 deletion quest/m1/ts-reliability/pmt-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ Decided 2026-10-20 by the maintainer:
A `programDescriptors` change is an entry change too: the PMT carries the
revised `program_info` at the next `version_number`.

Under `moq export ts --sync`, the revision is a function of the catalog
alone, so identical legs revise their PMT identically.

Rust: `rs/moq-mux` TS export. Draft
([mpegts](/drafts/draft-lcurley-moq-mpegts.md)): the rebuild section says
how a consumer revises the PMT.
Expand All @@ -49,4 +52,3 @@ TSDuck round trip with a generated mid-stream PMT revision so
## Related

- [PAT changes](/quest/m1/ts-reliability/pat-lifecycle.md) - a moved PMT PID reaches the export as a new PAT
- [Hitless TS](/quest/m2/ts-hitless.md) - identical legs must revise their PMT identically
1 change: 0 additions & 1 deletion quest/m2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ ranked just below deterministic TS legs, which it requires.
- [Synced data playback](/quest/m2/watch-data-sync.md) - js/watch releases JSON and binary payloads on the media playhead, and a slow data track holds media back
- [Watch decode gate](/quest/m2/watch-decode-gate.md) - video lookahead stays encoded until it is near presentation, like audio
- [Drop announce="never"](/quest/m2/publish-announce-never.md) - `@moq/publish` offers only announce modes a watcher can reach
- [Deterministic TS legs](/quest/m2/ts-hitless.md) - two `--sync` legs of one broadcast render every common slot identically, continuity counters included, through complementary loss
- [Deterministic H.265 TS legs](/quest/m2/ts-hitless-h265.md) - H.265's buffering period gets the same group anchor as H.264
- [TS over RTP legs](/quest/m2/ts-rtp-egress.md) - two legs send identical RTP packets, headers included, so an ST 2022-7 receiver can merge them
- [moqsrc A/V sync](/quest/m2/moqsrc-av-sync.md) - a nightly test measures A/V skew from `moqsrc` through clock-synced sinks
Expand Down
12 changes: 4 additions & 8 deletions quest/m2/ts-hitless-h265.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,10 @@ common slot identically through complementary loss.

## Plan

Decided 2026-10-19 by the maintainer (#5864): the
[deterministic TS legs](/quest/m2/ts-hitless.md) anchor reads H.264's buffering
period SEI; H.265 lays its SEI out differently, so it keeps today's anchor
and counts overruns until this lands. Reuse the H.264 tests with an H.265
Decided 2026-10-19 by the maintainer (#5864): the `--sync` group anchor
(`codec::h264::initial_cpb_removal_delay`) reads H.264's buffering period
SEI; H.265 lays its SEI out differently, so it keeps the plain anchor and
counts overruns until this lands. Reuse the H.264 tests with an H.265
fixture an encoder wrote.

Public API: none.

## Required

- [Deterministic TS legs](/quest/m2/ts-hitless.md) - the HRD anchor this extends
105 changes: 0 additions & 105 deletions quest/m2/ts-hitless.md

This file was deleted.

6 changes: 1 addition & 5 deletions quest/m2/ts-rtp-egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ identity across a replaced broadcast.
## Plan

Decided 2026-10-13 by the maintainer: RTP identity is a
function of [TS slot identity](/quest/m2/ts-hitless.md) and the epoch, and of
function of TS slot identity (`moq export ts --sync`) and the epoch, and of
nothing a leg mints.

- Datagrams are cut on the slot grid, so their boundaries fall at the same
Expand Down Expand Up @@ -54,7 +54,3 @@ epochless route is refused. Claims of ST 2022-7 compatibility cite the standard
RTP-level test, not TS identity.

Public API: an RTP output for TS export, in the shape settled above.

## Required

- [Deterministic TS legs](/quest/m2/ts-hitless.md) - the slots and counters these datagrams carry
33 changes: 33 additions & 0 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ impl ExportSink {
fragment_duration: None,
mux_rate: None,
passthrough: false,
sync: false,
};
let buffered = |format, args: &Buffered| Stdout {
format,
Expand All @@ -1065,6 +1066,7 @@ impl ExportSink {
fragment_duration: None,
mux_rate: None,
passthrough: false,
sync: false,
};
Some(match self {
Self::Fmp4(args) => Stdout {
Expand All @@ -1083,6 +1085,7 @@ impl ExportSink {
fragment_duration: None,
mux_rate: args.mux_rate,
passthrough: args.passthrough,
sync: args.sync,
},
Self::Flv(args) => buffered(SubscribeFormat::Flv, args),
Self::H264(args) => container(SubscribeFormat::H264, args),
Expand All @@ -1105,6 +1108,8 @@ pub struct Stdout {
pub mux_rate: Option<u64>,
/// Write the `m2ts` passthrough track back out (`ts` only).
pub passthrough: bool,
/// Render identical packets to every other `--sync` export of the broadcast (`ts` only).
pub sync: bool,
}

/// Staleness options for stdout sinks without a fixed release delay.
Expand Down Expand Up @@ -1227,6 +1232,13 @@ pub struct Transport {
#[usage(long, conflicts("--linger", "--stitch", "--mux-rate"))]
pub passthrough: bool,

/// Render every packet from the broadcast alone, on a fixed lane per PID, so two exports
/// of it emit identical TS for the content both receive, continuity counters included,
/// as the legs of a 1+1 pair must. Needs a constant rate, the same on every leg
/// (`--mux-rate`, or the catalog's).
#[usage(long, conflicts("--stitch", "--passthrough"))]
pub sync: bool,

/// The released spelling of [`Self::delay`].
#[usage(long = "max-age", hide = true)]
max_age: Option<crate::duration::Duration>,
Expand Down Expand Up @@ -1348,6 +1360,27 @@ mod tests {
}
}

/// `export ts --sync` keeps its legs identical within one publisher epoch, so it refuses
/// `--stitch`, and `--passthrough`, whose output is already the source's own packets.
#[test]
fn export_ts_sync_refuses_a_stitch_and_a_passthrough() {
let parse = |args: &[&str]| {
let mut argv = vec!["moq", "export", "--broadcast", "b", "ts", "--sync"];
argv.extend_from_slice(args);
Invocation::try_parse_from(argv)
};
let cli = parse(&["--mux-rate", "12000000"]).unwrap();
let Command::Export(export) = &cli.stages[0] else {
panic!("not an export");
};
let stdout = export.sink.stdout().unwrap();
assert!(stdout.sync);
assert_eq!(stdout.mux_rate, Some(12_000_000));
for flags in [&["--stitch"][..], &["--passthrough"]] {
assert!(parse(flags).is_err(), "{flags:?} must not start a run");
}
}

fn export(flags: &[&str]) -> Result<Invocation, ParseError> {
let argv = ["moq", "--connect", "http://relay", "--broadcast", "room", "export"];
Invocation::try_parse_from(argv.iter().chain(flags).copied())
Expand Down
1 change: 1 addition & 0 deletions rs/moq-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ fn spawn_export(
fragment_duration: stdout.fragment_duration,
mux_rate: stdout.mux_rate,
passthrough: stdout.passthrough,
sync: stdout.sync,
catalog: export.catalog_format,
select: export.select,
};
Expand Down
21 changes: 18 additions & 3 deletions rs/moq-cli/src/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ pub struct SubscribeArgs {
/// Write the `m2ts` passthrough track back out instead of muxing the media (TS only).
pub passthrough: bool,

/// Render identical packets to every other synced export of the broadcast (TS only).
pub sync: bool,

/// Catalog format for track discovery (default: detect from the broadcast suffix).
pub catalog: Option<CatalogFormatArg>,

Expand Down Expand Up @@ -267,6 +270,8 @@ fn log_release(release: &moq_mux::container::ts::stats::Release, message: &'stat
offset_ms = ?release.offset,
drift_ppm = ?release.drift,
out_of_tolerance = release.out_of_tolerance,
overruns = release.overruns,
late = release.late,
"{message}"
);
}
Expand Down Expand Up @@ -414,6 +419,9 @@ impl Subscribe {
if let Some(mux_rate) = self.args.mux_rate {
ts = ts.with_mux_rate(mux_rate);
}
if self.args.sync {
ts = ts.with_sync();
}

// A TS byte stream carries no per-frame timing, so delivery time is the only
// carrier of each frame's spacing (#2984). The export lays each slice of the PCR
Expand All @@ -427,8 +435,8 @@ impl Subscribe {
// the way `publish` reports one that stops arriving.
let mut log = moq_mux::container::ts::stats::Log::default();
let mut sampled = moq_clock::now();
// The export's drop, gap and out-of-tolerance counts, as last logged.
let mut reported = (0, 0, 0, 0);
// The export's drop, gap, out-of-tolerance and `--sync` counts, as last logged.
let mut reported = (0, 0, 0, 0, 0, 0);
let sections = self.sections;
let report = |stats: &moq_mux::container::ts::stats::Export| {
if let Some(sections) = &sections {
Expand All @@ -452,7 +460,14 @@ impl Subscribe {
let stats = ts.export().stats();
report(&stats);
let release = &stats.release;
let counts = (release.dropped, release.trimmed, release.gaps, release.out_of_tolerance);
let counts = (
release.dropped,
release.trimmed,
release.gaps,
release.out_of_tolerance,
release.overruns,
release.late,
);
if counts != reported {
reported = counts;
log_release(release, "TS export release clock");
Expand Down
4 changes: 4 additions & 0 deletions rs/moq-mux/benches/ts_schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ use std::time::Duration;

mod export {
pub const PCR_INTERVAL: Duration = Duration::from_millis(25);
pub const PSI_INTERVAL: Duration = Duration::from_millis(500);
use std::time::Duration;
}
#[path = "../src/container/ts/lane.rs"]
#[allow(dead_code)]
mod lane;
#[path = "../src/container/ts/schedule.rs"]
#[allow(dead_code)]
mod schedule;
Expand Down
Loading