diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 4da8ae931a..6c9c1dd0c5 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -59,6 +59,16 @@ clock when they flush frames. Catalog jitter is the spread above each rendition's own recent minimum lateness, so a constant encoder delay is not jitter. The advertised value only rises; frame duration alone does not set it. +## 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 9a0294afc4..58873544ec 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,46 @@ 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 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) => { + 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 +139,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 1c37d4f72d..7c45ec7962 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/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 6f8b01252c..620769e929 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 and browser publisher 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 23f437f7c9..0000000000 --- a/quest/m1/publisher-clock.md +++ /dev/null @@ -1,33 +0,0 @@ -# [L] Use the broadcast clock across media publishers - -## Goal - -Native video/audio capture, CLI imports, and `js/publish` 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 -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. - -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