From 30185168d5b33a282473563ec3a074d4a24a1df9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:10:53 -0700 Subject: [PATCH 1/4] chore(quest): claim quest/m1/publisher-clock Co-Authored-By: Claude Opus 5.5 From 24a6d5e56943375f2f88c9f0137cec28863c63c0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 18:20:25 -0700 Subject: [PATCH 2/4] feat(publish): advertise the page clock at the catalog root Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/publish.md | 10 +++++++++ js/publish/src/broadcast.ts | 3 ++- js/publish/src/catalog.test.ts | 40 +++++++++++++++++++++++++++++++--- js/publish/src/catalog.ts | 14 ++++++++++-- quest/m1/README.md | 2 +- quest/m1/publisher-clock.md | 24 +++++++++++--------- 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 9601324139..0ed8d52703 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -54,6 +54,16 @@ framerate, and bitrate are tunable through `el.video.config`; the audio encoder exposes its codec and volume. For simulcast or several renditions, drop the element and register your own encoders on a `Publish.Broadcast`. +## Clock + +Every timestamp the publisher writes is `performance.now()` in microseconds, +so camera, microphone, screen, and file sources share one timeline. The catalog +advertises that mapping as its root `clock` from the first snapshot, with PTS +zero at `performance.timeOrigin`, so a viewer or an HLS export can name any +frame's wall time. The mapping is fixed for the page: a system-clock +adjustment never retimes the broadcast. Stamp your own tracks (e.g. text cues) +on the same timeline to stay in sync. + ## Custom tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 1ed9f1fc66..465e221dd7 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -111,7 +111,8 @@ export class Broadcast { * * Set the returned rendition's `config` to a {@link Catalog.TextConfig}, then write one cue per * group into its `track` with `Hang.Container.Legacy.Producer` (each cue is a keyframe, so it opens - * its own group). See the module docs for the cue framing. + * its own group). See the module docs for the cue framing. Stamp cues with `performance.now()` in + * microseconds, the broadcast clock the catalog advertises. */ text(name: string): Rendition { return this.#register(name, "text"); diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index d8a9c3f1e1..e502973e67 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -45,7 +45,7 @@ test("catalog producer publishes every update as a snapshot group", async () => const first = await subscriber.nextGroup(); expect(first?.sequence).toBe(0); - expect(await first?.readJson()).toEqual({ video: { renditions: {} } }); + expect(await first?.readJson()).toEqual({ clock: expect.anything(), video: { renditions: {} } }); expect(first?.done).toBe(true); catalog.mutate((c) => { @@ -54,12 +54,45 @@ test("catalog producer publishes every update as a snapshot group", async () => const second = await subscriber.nextGroup(); expect(second?.sequence).toBe(1); - expect(await second?.readJson()).toEqual({ video: { renditions: {} }, scte35: { splices: [] } }); + expect(await second?.readJson()).toEqual({ + clock: expect.anything(), + video: { renditions: {} }, + scte35: { splices: [] }, + }); expect(second?.done).toBe(true); effect.close(); }); +test("catalog producer advertises the page clock from the first snapshot", async () => { + const catalog = new CatalogProducer(); + + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); + + // Before any rendition: a live-only publisher exposes its clock without an archive. + const first = Catalog.RootSchema.parse(await consumer.next()); + if (!first.clock) throw new Error("expected a root clock"); + expect(first.archive).toBeUndefined(); + expect(first.clock.timescale).toBe(1_000_000); + + // A timestamp stamped the way capture does (performance.now() in microseconds) maps to now. + const pts = Math.round(performance.now() * 1000); + const wall = Catalog.wallClockTime(first.clock, pts, 1_000_000).getTime(); + expect(Math.abs(wall - Date.now())).toBeLessThan(50); + + // Later edits keep the mapping: it is fixed for the broadcast. + catalog.mutate((c) => { + c.video = { renditions: {} }; + }); + const second = Catalog.RootSchema.parse(await consumer.next()); + expect(second.clock).toEqual(first.clock); + + effect.close(); +}); + test("a reconnecting subscriber is seeded with the full current catalog", async () => { const catalog = new CatalogProducer(); catalog.mutate((c) => { @@ -105,7 +138,8 @@ test("catalog producer refuses zero jitter before retaining an edit", () => { ).toThrow("omit jitter"); } catalog.mutate((value) => { - expect(value).toEqual({}); + expect(value.audio).toBeUndefined(); + expect(value.video).toBeUndefined(); }); }); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 130a7c8507..65ccd1e2e0 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -1,4 +1,4 @@ -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; import type { Effect } from "@moq/signals"; @@ -11,9 +11,13 @@ import type { Effect } from "@moq/signals"; * current catalog before receiving updates. Independent owners (the base `video`/`audio` and an * application's own sections, e.g. `scte35`) each edit only their own keys, so their sections * compose instead of clobbering one another. + * + * The root `clock` is advertised from the first snapshot: every js/publish timestamp is + * `performance.now()` in microseconds, so PTS zero is `performance.timeOrigin`. The mapping is fixed + * for the page, so a system-clock adjustment never retimes the broadcast. */ export class CatalogProducer { - #value: Catalog.Root = {}; + #value: Catalog.Root = { clock: pageClock() }; #outputs = new Set>(); /** Edit the catalog in place; the result is published to all current subscribers. */ @@ -54,3 +58,9 @@ export class CatalogProducer { }); } } + +// The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. +function pageClock(): Catalog.Clock { + const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); + return { wall: Catalog.u53(wall), timescale: 1_000_000 }; +} diff --git a/quest/m1/README.md b/quest/m1/README.md index c46ec57cd5..a5dd385c96 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -33,7 +33,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native and browser publisher restarts +- [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native capture and CLI import restarts - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included diff --git a/quest/m1/publisher-clock.md b/quest/m1/publisher-clock.md index 23f437f7c9..502324119d 100644 --- a/quest/m1/publisher-clock.md +++ b/quest/m1/publisher-clock.md @@ -2,25 +2,29 @@ ## Goal -Native video/audio capture, CLI imports, and `js/publish` publish timestamps -against the shared broadcast clock, including source and encoder restarts. +Native video/audio capture and CLI imports publish timestamps against the +shared broadcast clock, including source and encoder restarts. A live-only publisher exposes its clock without constructing an archive. ## Plan -Use the dev clock owner and catalog schema. Select each source's initial mapping -once, account for delayed first frames, and translate source resets onto the -same monotonic clock while preserving real idle gaps. Share the clock between +Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; `js/publish` +already advertises its `performance.now()` mapping. Native video maps the +device timeline at open and audio stamps arrival, both on `catalog.clock()`. +CLI imports (fMP4, TS, FLV) still publish source PTS verbatim against a wall +sampled at startup; fMP4 is passthrough, so a translation must rewrite `tfdt`, +and a muxed source needs one mapping for all its tracks, since interleaved +audio and video can step back further than `SourceMap::MAX_REORDER`. + +Select each source's initial mapping once, account for delayed first frames, +and translate source resets onto the same monotonic clock while preserving real +idle gaps. Share the clock between audio and video. System-wall adjustments do not retime a running broadcast or old archive records. Preserve allowed B-frame ordering within a group. Keep source-specific timestamp conversion at the adapter boundary. Refuse an unmappable source explicitly. Discontinuity markers signal the existing -playhead contract; they do not replace the wall epoch. In `js/publish`, the -video encoder marks a break through `Container.Legacy.Producer.cut()` whenever -its encode loop stops (demand gap or capture swap). The audio encoder writes its -own marker on a demand gap only: an audio pipeline rebuild and the framer's -input-gap reset still write none. +playhead contract; they do not replace the wall epoch. Add CI fixtures for simultaneous A/V, late first frames, restart to zero, restart after idle, system-wall adjustment, and retained archive playback. From 9981d8c6a4da077a31e3522158d049925150c06e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 22:27:15 -0700 Subject: [PATCH 3/4] docs(quest): split the remaining publisher clock work Co-Authored-By: Claude Opus 5.5 --- ...l-clock-latency-target-for-synchronized.md | 2 +- quest/m1/README.md | 3 +- quest/m1/cli-import-clock.md | 34 +++++++++++++++++ quest/m1/native-clock-fixtures.md | 19 ++++++++++ quest/m1/publisher-clock.md | 37 ------------------- quest/m2/teleop/correlation.md | 2 +- 6 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 quest/m1/cli-import-clock.md create mode 100644 quest/m1/native-clock-fixtures.md delete mode 100644 quest/m1/publisher-clock.md diff --git a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md index 5eaff2b733..80800d1b8a 100644 --- a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md +++ b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md @@ -30,7 +30,7 @@ a live-only broadcast with no archive timeline. ## Required -- [Publisher clocks](/quest/m1/publisher-clock.md) - built-in publishers populate the mapping applications read +- [CLI import clock](/quest/m1/cli-import-clock.md) - built-in publishers populate the mapping applications read ## Closes diff --git a/quest/m1/README.md b/quest/m1/README.md index a5dd385c96..b9887d3f7c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -33,7 +33,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native capture and CLI import restarts +- [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - CI drives native capture through clock edge cases and asserts the published timestamps - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included diff --git a/quest/m1/cli-import-clock.md b/quest/m1/cli-import-clock.md new file mode 100644 index 0000000000..ccdf7f764b --- /dev/null +++ b/quest/m1/cli-import-clock.md @@ -0,0 +1,34 @@ +# [M] CLI imports publish on the broadcast clock + +## Goal + +`moq import` of fMP4, TS, and FLV publishes timestamps on the shared broadcast +clock, like native capture and `js/publish` already do, including source +restarts, late first frames, and real idle gaps. Today the imports publish +source PTS verbatim against a wall clock sampled at startup, so a TS feed with +a large starting PTS or a late first frame advertises the wrong wall time. + +## Plan + +Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; this adds +no clock API or catalog field. Select each source's initial mapping once, +account for a delayed first frame, and translate source resets onto the same +monotonic clock while preserving real idle gaps. System-wall adjustments do not +retime a running broadcast or old archive records. Preserve allowed B-frame +ordering within a group. + +- fMP4 is passthrough, so translation must rewrite `tfdt`. +- A muxed source needs one mapping for all of its tracks, since interleaved + audio and video can step back further than `SourceMap::MAX_REORDER`. +- Keep conversion at the adapter boundary and refuse an unmappable source + explicitly. Discontinuity markers signal the existing playhead contract; + they do not replace the wall epoch. + +CI fixtures drive the import path, not only the clock helper: simultaneous +A/V, a late first frame, a restart to zero, a restart after idle, and retained +archive playback. Update the import docs. + +## Related + +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - the same scenarios through native capture +- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/native-clock-fixtures.md b/quest/m1/native-clock-fixtures.md new file mode 100644 index 0000000000..88b158e028 --- /dev/null +++ b/quest/m1/native-clock-fixtures.md @@ -0,0 +1,19 @@ +# [S] Native capture proves the broadcast clock in CI + +## Goal + +Per-PR CI drives the native video and audio capture publishers through clock +edge cases and asserts the published timestamps: simultaneous A/V, a late +first frame, a restart to zero, a restart after idle, a system-wall +adjustment, and retained archive playback. Anything they catch is fixed here. + +## Plan + +Native video already maps the device timeline onto `catalog.clock()` at open, +and native audio stamps arrival on it. The fixtures exercise publisher +integration with a synthetic device source and an injected clock, rather than +only the clock helper. No new clock API or catalog representation. + +## Related + +- [CLI import clock](/quest/m1/cli-import-clock.md) - the same scenarios through `moq import` diff --git a/quest/m1/publisher-clock.md b/quest/m1/publisher-clock.md deleted file mode 100644 index 502324119d..0000000000 --- a/quest/m1/publisher-clock.md +++ /dev/null @@ -1,37 +0,0 @@ -# [L] Use the broadcast clock across media publishers - -## Goal - -Native video/audio capture and CLI imports publish timestamps against the -shared broadcast clock, including source and encoder restarts. -A live-only publisher exposes its clock without constructing an archive. - -## Plan - -Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; `js/publish` -already advertises its `performance.now()` mapping. Native video maps the -device timeline at open and audio stamps arrival, both on `catalog.clock()`. -CLI imports (fMP4, TS, FLV) still publish source PTS verbatim against a wall -sampled at startup; fMP4 is passthrough, so a translation must rewrite `tfdt`, -and a muxed source needs one mapping for all its tracks, since interleaved -audio and video can step back further than `SourceMap::MAX_REORDER`. - -Select each source's initial mapping once, account for delayed first frames, -and translate source resets onto the same monotonic clock while preserving real -idle gaps. Share the clock between -audio and video. System-wall adjustments do not retime a running broadcast or -old archive records. Preserve allowed B-frame ordering within a group. - -Keep source-specific timestamp conversion at the adapter boundary. Refuse an -unmappable source explicitly. Discontinuity markers signal the existing -playhead contract; they do not replace the wall epoch. - -Add CI fixtures for simultaneous A/V, late first frames, restart to zero, -restart after idle, system-wall adjustment, and retained archive playback. -The fixtures must exercise publisher integration rather than only the clock -helper. Update publisher and import docs; this quest adds no new clock API or -catalog representation. GStreamer's clock observation remains its own quest. - -## Related - -- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m2/teleop/correlation.md b/quest/m2/teleop/correlation.md index 7a0c44f451..779d6f6e07 100644 --- a/quest/m2/teleop/correlation.md +++ b/quest/m2/teleop/correlation.md @@ -30,4 +30,4 @@ the same property that makes an MCAP recording valuable. ## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) -- [Publisher clocks](/quest/m1/publisher-clock.md) - publishers populate the fixed broadcast mapping used to join tracks +- [CLI import clock](/quest/m1/cli-import-clock.md) - publishers populate the fixed broadcast mapping used to join tracks From 0c1f02d0aa3334765d2a1a117991f1fe96656596 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 23:04:00 -0700 Subject: [PATCH 4/4] test(publish): check the page clock against timeOrigin, not Date.now Co-Authored-By: Claude Opus 5.5 --- js/publish/src/catalog.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 864c49f0f2..58873544ec 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -78,10 +78,11 @@ test("catalog producer advertises the page clock from the first snapshot", async expect(first.archive).toBeUndefined(); expect(first.clock.timescale).toBe(1_000_000); - // A timestamp stamped the way capture does (performance.now() in microseconds) maps to now. - const pts = Math.round(performance.now() * 1000); - const wall = Catalog.wallClockTime(first.clock, pts, 1_000_000).getTime(); - expect(Math.abs(wall - Date.now())).toBeLessThan(50); + // A timestamp stamped the way capture does (performance.now() in microseconds) maps onto the + // page's own wall timeline, not Date.now(), which a system-clock adjustment can move. + const now = performance.now(); + const wall = Catalog.wallClockTime(first.clock, Math.round(now * 1000), 1_000_000).getTime(); + expect(Math.abs(wall - (performance.timeOrigin + now))).toBeLessThanOrEqual(1); // Later edits keep the mapping: it is fixed for the broadcast. catalog.mutate((c) => {