From 2b36e2dcd98ae79774ae0c94cb2cff3742c4aff5 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Thu, 1 Oct 2026 10:39:24 +0200 Subject: [PATCH 1/2] quest: claim quest/m1/clock-anchor-public From 7608fe5ae3fa1e3164d9b8f076b7143372c439da Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Thu, 1 Oct 2026 11:03:31 +0200 Subject: [PATCH 2/2] feat(mux): export clock::Anchor and clock::Lane An application running its own demuxer can now put several tracks of one source on the broadcast clock with one shared offset, as the TS, fMP4, and FLV importers do. Co-Authored-By: Claude Opus 5.5 --- doc/lib/rs/moq-mux.md | 20 +++++++++++++++++ quest/m1/README.md | 1 - quest/m1/clock-anchor-public.md | 29 ------------------------ quest/m1/source-map-removal.md | 4 ---- rs/moq-mux/src/clock.rs | 39 ++++++++++++++++++++++++++------- rs/moq-mux/src/lib.rs | 4 +++- 6 files changed, 54 insertions(+), 43 deletions(-) delete mode 100644 quest/m1/clock-anchor-public.md diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 85dba3b09c..6ac125f6bf 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -104,6 +104,26 @@ real idle gap. fMP4 passthrough rewrites each fragment's `tfdt` to match. Use it for a live feed with its own zero; publish verbatim only when the catalog's clock (`Config::with_clock`) already names the source's zero. +An application running its own demuxer gets the same mapping from +`clock::Anchor`: one per source, plus one `clock::Lane` per track. A single +`SourceMap` per track would let tracks drift apart by their first-PTS +difference, and one `SourceMap` shared across tracks reads their interleaving +as a reset. Each lane detects its own restarts, and the anchor moves once for +all of them. Call `Lane::restart()` before a frame when the demuxer sees a +discontinuity out of band. + +```rust +use moq_mux::clock; + +let mut anchor = clock::Anchor::new(catalog.clock()); +let mut video = clock::Lane::default(); +let mut klv = clock::Lane::default(); + +// Both tracks keep their source spacing on the broadcast clock. +let video_ts = anchor.translate(&mut video, video_pts)?; +let klv_ts = anchor.translate(&mut klv, klv_pts)?; +``` + ```bash cargo add moq-mux ``` diff --git a/quest/m1/README.md b/quest/m1/README.md index da1236cff0..3441d672bb 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -75,7 +75,6 @@ QUIC studies there on that rule. - [Data consumer timestamps](/quest/m1/data-consumer-timestamps.md) - json and binary consumers return each value's timestamp, in Rust and every binding - [Nested data configs](/quest/m1/data-config-nesting.md) - docs nest `BinaryConfig`/`JsonConfig` in an application section instead of flattening it - [Omit empty catalog sections](/quest/m1/catalog-omit-empty.md) - a Rust catalog with no video or audio leaves those keys out, as JS does -- [Public clock anchor](/quest/m1/clock-anchor-public.md) - an application demuxer shares one source offset across its tracks through `Anchor` and `Lane` - [Delete SourceMap](/quest/m1/source-map-removal.md) - on dev, `Anchor` with its `Lane`s is the one way onto the broadcast clock - [Draft-20 FETCH](/quest/m1/ietf-fetch-location.md) - a draft-20+ FETCH within one group is served from its LOCATION_FILTER, as older drafts are - [Fetch without SUBSCRIBE](/quest/m1/ietf-fetch-only.md) - a relay fetches from an IETF upstream without subscribing, finished tracks included, with End of Track always reported diff --git a/quest/m1/clock-anchor-public.md b/quest/m1/clock-anchor-public.md deleted file mode 100644 index d491aa6098..0000000000 --- a/quest/m1/clock-anchor-public.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] moq-mux exports the per-source clock anchor - -## Goal - -An application running its own demuxer puts several tracks of one source on -the broadcast `Clock` with one shared offset, as the built-in TS, fMP4, and -FLV importers do, without going through a built-in importer. - -## Plan - -Requested by an external consumer (OneTooMany, Discord): their MPEG-TS -ingest publishes video, KLV, and sometimes audio from one program. One -`SourceMap` per track lets tracks drift apart by their first-PTS difference, -and one shared `SourceMap` reads interleave (beyond `MAX_REORDER`) as a reset. - -Decided: make `clock::Anchor` and `clock::Lane` public with the API the -importers already use: `Anchor::new(clock)`, `translate(&mut lane, pts)`, -`translate_at`, `extend`, and `Lane::restart`, with `Lane: Default`. Additive, -so it lands on `main` for a 0.10 patch. - -Open for the PR: the export path. Proposal: `pub mod clock` with -`moq_mux::clock::{Anchor, Lane}`, keeping the root `Clock` re-export. Ask the -maintainer with alternatives. - -Document both in `doc/lib/rs/moq-mux.md`, with a two-track example. - -Their other `ts::Import` gaps (MPEG-2 video, KLV reassembly) stay unplanned -until requested directly. The H.265 per-packet error is -[TS damaged units](/quest/m1/ts-damaged-units.md). diff --git a/quest/m1/source-map-removal.md b/quest/m1/source-map-removal.md index f4eddc12de..a3a60899fe 100644 --- a/quest/m1/source-map-removal.md +++ b/quest/m1/source-map-removal.md @@ -11,7 +11,3 @@ with its `Lane`s is the one way to map a source onto the broadcast clock. it. A single-track source uses `Anchor` with one `Lane`. Migrate the `SourceMap` tests in `rs/moq-mux/src/clock.rs` onto `Anchor`, and update `doc/lib/rs/moq-mux.md`. - -## Required - -- [Public clock anchor](/quest/m1/clock-anchor-public.md) - `Anchor` and `Lane` must be public first diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index ee2e0694b8..ac813e0edf 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -15,6 +15,9 @@ //! A source with its own zero (a file, a restarted encoder) is translated onto the mapping with //! [`SourceMap`]: the first frame anchors onto the live edge, and a reset re-anchors forward, //! preserving the real idle gap measured on this monotonic clock. +//! +//! A source that muxes several tracks (an MPEG-TS program, an fMP4 file) shares one [`Anchor`] +//! across them, with one [`Lane`] per track, so every track keeps the same offset. use std::time::{Duration, Instant, SystemTime}; @@ -240,7 +243,21 @@ impl SourceMap { /// video step back further than [`SourceMap::MAX_REORDER`], which would read as a reset. So /// each track keeps its own [`Lane`] that detects its own backwards steps, and a restart any /// lane detects moves the anchor once; the other lanes adopt that mapping when they restart too. -pub(crate) struct Anchor { +/// +/// ``` +/// use moq_mux::{Clock, clock}; +/// +/// let mut anchor = clock::Anchor::new(Clock::new()); +/// let (mut video, mut klv) = (clock::Lane::default(), clock::Lane::default()); +/// +/// // Both tracks of the program keep the 800ms between their source timestamps. +/// let v = anchor.translate(&mut video, moq_net::Timestamp::from_micros(10_800_000)?)?; +/// let k = anchor.translate(&mut klv, moq_net::Timestamp::from_micros(10_000_000)?)?; +/// assert_eq!(v.as_micros() - k.as_micros(), 800_000); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug)] +pub struct Anchor { clock: Clock, /// Broadcast micros minus source micros for the current generation; `None` until the first /// frame anchors it. @@ -256,8 +273,8 @@ pub(crate) struct Anchor { } /// One track's position on its source's [`Anchor`]. -#[derive(Default)] -pub(crate) struct Lane { +#[derive(Debug, Default)] +pub struct Lane { /// The offset this lane translates with, in micros; `None` until it adopts one. offset: Option, generation: u64, @@ -270,13 +287,14 @@ pub(crate) struct Lane { impl Lane { /// The next frame starts a new source timeline, however its PTS compares to the last. - pub(crate) fn restart(&mut self) { + pub fn restart(&mut self) { self.restart = true; } } impl Anchor { - pub(crate) fn new(clock: Clock) -> Self { + /// A source mapping onto `clock`, unanchored until the first frame on any lane. + pub fn new(clock: Clock) -> Self { Self { clock, offset: None, @@ -288,12 +306,15 @@ impl Anchor { } /// Translate one of `lane`'s timestamps, sampling the arrival time. - pub(crate) fn translate(&mut self, lane: &mut Lane, pts: moq_net::Timestamp) -> crate::Result { + pub fn translate(&mut self, lane: &mut Lane, pts: moq_net::Timestamp) -> crate::Result { self.translate_at(lane, pts, self.clock.now().value()) } /// Translate one of `lane`'s timestamps, arriving at monotonic `now` micros. - pub(crate) fn translate_at( + /// + /// The deterministic core behind [`translate`](Self::translate): `now` is what + /// [`Clock::now`] would read, pinned for synthetic sources and tests. + pub fn translate_at( &mut self, lane: &mut Lane, pts: moq_net::Timestamp, @@ -366,7 +387,9 @@ impl Anchor { } /// Record that the broadcast has published up to `end`, e.g. a fragment's last sample end. - pub(crate) fn extend(&mut self, end: moq_net::Timestamp) { + /// + /// A restart then lands after `end` even when no frame's start reached it. + pub fn extend(&mut self, end: moq_net::Timestamp) { self.extend_micros(end.as_micros()); } diff --git a/rs/moq-mux/src/lib.rs b/rs/moq-mux/src/lib.rs index b128bf22de..9722b5ac7a 100644 --- a/rs/moq-mux/src/lib.rs +++ b/rs/moq-mux/src/lib.rs @@ -12,6 +12,8 @@ //! raw bitstream to a broadcast. //! - [`catalog`] publishes and subscribes to the broadcast catalog, //! the JSON manifest listing every track and how to decode it. +//! - [`clock`](mod@clock) holds the broadcast [`Clock`] and the translators +//! that move a source's own timestamps onto it. //! - [`import`](mod@import) is the front door for callers who only have //! a format string. It picks the right concrete importer for you. //! - [`select`] picks which renditions of a broadcast to keep, on either @@ -28,7 +30,7 @@ pub mod binary; pub mod catalog; -mod clock; +pub mod clock; pub mod codec; pub mod container; mod error;