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
10 changes: 10 additions & 0 deletions doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion js/publish/src/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Catalog.TextConfig> {
return this.#register<Catalog.TextConfig>(name, "text");
Expand Down
41 changes: 38 additions & 3 deletions js/publish/src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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<Catalog.Root>({ 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) => {
Expand Down Expand Up @@ -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();
});
});

Expand Down
14 changes: 12 additions & 2 deletions js/publish/src/catalog.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep standalone catalog clocks caller-controlled

CatalogProducer is a public standalone API because src/index.ts re-exports ./catalog, so callers can use it with media whose timestamps are not based on this realm's performance.now(). After this change, those existing callers silently advertise the page origin as an authoritative mapping, causing HLS/DASH and other consumers to derive incorrect wall times. Seed pageClock() in Broadcast or accept an explicit clock instead of changing the generic producer's default.

AGENTS.md reference: AGENTS.md:L53-L59

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right that CatalogProducer is exported, so I corrected the PR description. Keeping the default, though: it matches Rust's catalog::Producer, which seeds Clock::new() and lets a caller override it. Here a standalone caller with a different timeline already controls it via mutate((c) => { c.clock = ... }), and every js/publish source stamps on performance.now(), so the default is correct for the common case. Adding a constructor option would be new API with no consumer yet.

(Written by Claude Opus 5.5)

#outputs = new Set<Json.Snapshot.Producer<Catalog.Root>>();

/** Edit the catalog in place; the result is published to all current subscribers. */
Expand Down Expand Up @@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions quest/m1/cli-import-clock.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions quest/m1/native-clock-fixtures.md
Original file line number Diff line number Diff line change
@@ -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`
33 changes: 0 additions & 33 deletions quest/m1/publisher-clock.md

This file was deleted.

2 changes: 1 addition & 1 deletion quest/m2/teleop/correlation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading