Skip to content

feat(moq-net): mandatory timestamps for moq-lite-05#1896

Open
kixelated wants to merge 12 commits into
devfrom
claude/moq-lite-05-mandatory-timestamp-xc7tg8
Open

feat(moq-net): mandatory timestamps for moq-lite-05#1896
kixelated wants to merge 12 commits into
devfrom
claude/moq-lite-05-mandatory-timestamp-xc7tg8

Conversation

@kixelated

@kixelated kixelated commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Makes per-track timescale and per-frame timestamps mandatory on moq-lite-05, gives both Rust and JS a proper timestamp type, and wires real timestamps through the media/transport paths.

Behavioral contract:

  • Every track is timed. TrackInfo.timescale defaults to milliseconds (Rust and JS).
  • moq-lite-05 always carries timestamps: TRACK_INFO encodes the timescale as a mandatory non-zero varint, and every frame is prefixed with a zigzag-delta timestamp at the track's scale.
  • moq-transport carries timestamps as per-object extension headers — Timestamp (0x06, absolute) in Timescale (0x08) units, the MOQ Object Properties registry ids shared with draft-ietf-moq-loc — decoded on receive and emitted on send.
  • Timeless sources (a peer/track with no real presentation time) fall back to wall-clock: Rust write_frame_now/create_frame_now, JS Timestamp.now().
  • Timestamps auto-convert into the track's timescale.

Rust

  • Timescale (default MILLI) and Timestamp (value + scale) carry their own scale; Timestamp::now() / From<Instant> are millisecond-scale and anchored at 2020-01-01 (a timestamp is non-negative + roughly monotonic, not a real clock — the smaller magnitude trims the first frame's varint).
  • Frame.timestamp is a required Timestamp; GroupProducer::create_frame / write_frame (and TrackProducer::write_frame) require it, with create_frame_now / write_frame_now for the wall-clock case.
  • TrackInfo.timescale and lite::TrackInfo.timescale are non-optional.
  • Surfaced a real bug: the moq-mux LOC and fMP4 export paths wrote the net frame with size only (wall-clock), dropping the media PTS — both now pass it through.
  • IETF publisher/subscriber encode/decode the Timestamp/Timescale object properties (ietf::{encode,decode}_object_time).

JS (@moq/net + consumers)

  • New Timestamp (value + scale) and Timescale types mirroring Rust; Timestamp.now() is performance.now() ms.
  • Frame.timestamp is required; writeFrame(frame) / readFrame() both use the Frame type (symmetric). No writeFrameNow — timeless callers pass Timestamp.now().
  • Model TrackInfo gains a timescale (default ms): the lite publisher advertises it and emits each frame converted to it; the subscriber reconstructs a Timestamp at the wire scale and carries it into the accepted track (so a relay re-publishes at the same scale). hang/loc/publish pass Timestamp.fromMicros.

Public API changes (rs/moq-net, breaking — targets dev)

  • Frame.timestamp: Option<Timestamp>Timestamp; size-only From<uN> shortcuts removed.
  • GroupProducer: create_frame(Frame) requires a timestamp; new create_frame_now(size); write_frame(timestamp, data) + write_frame_now(data); timescale()Timescale.
  • GroupConsumer::timescale()Timescale; TrackProducer::write_frame(timestamp, data) + write_frame_now(data).
  • TrackInfo.timescale / lite::TrackInfo.timescale: Option<Timescale>Timescale.
  • Timescale: Default = MILLI; Timestamp::now() / From<Instant> now millisecond-scale, 2020-anchored.
  • ietf::{encode_object_time, decode_object_time} added.

Test plan

  • cargo test -p moq-net -p moq-mux --lib (400 + 288), incl. ietf::group object-property round-trips; cargo clippy + cargo doc -D warnings clean.
  • @moq/net 189 / @moq/json 32 / hang container 26 / @moq/loc 6; tsc + biome clean across net/hang/loc/json/publish/watch.
  • moq-native integration tests can't run in the sandbox (no IPv6 → [::]:0 bind), but pass in CI.

Follow-ups

  • doc/concept note on the lite-05 mandatory timestamp + moq-transport object properties.
  • Optional: drop the per-process wall-clock jitter on the Rust anchor if epoch-obfuscation is no longer wanted.

🤖 Generated with Claude Code

(Written by Claude)

claude added 2 commits June 23, 2026 22:30
moq-lite-05 now always carries a per-track timescale and per-frame
timestamps. Previously a lite-05 track could opt out (timescale = 0, no
per-frame timestamp byte); that path is removed.

- Timescale gains a Default of milliseconds. TrackInfo.timescale,
  GroupProducer, and GroupConsumer become non-optional, so every track is
  timed. The default timescale is milliseconds.
- GroupProducer::create_frame is now the single normalization point: it
  converts a frame's timestamp into the track's timescale, or stamps it
  with wall-clock now when the caller didn't supply one. Protocols whose
  wire can't carry a timestamp (pre-lite-05 moq-lite, IETF moq-transport)
  therefore surface millisecond wall-clock timestamps instead of none.
- lite TRACK_INFO encodes the timescale as a mandatory non-zero varint.
- Timestamp::now() and From<Instant> now produce the default (millisecond)
  scale, documented as a one-way wall-clock bridge with no inverse.

Cross-package sync (js/net, js/hang, doc/concept) is deferred to a
follow-up; see the PR description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Frame.timestamp is now a mandatory Timestamp (no longer Option). create_frame
and write_frame require it; create_frame_now / write_frame_now stamp wall-clock
time for data with no presentation time of its own (catalogs, JSON state) or
sources whose protocol can't carry one. TrackProducer gains the same pair.

Making the timestamp required surfaced a real bug: the moq-mux LOC and fMP4
export paths wrote the net frame with size only, so the relay-visible timestamp
was wall-clock instead of the media PTS the container already had. Both now pass
the real timestamp through (the model converts it into the track's timescale).

The lite subscriber decodes the wire timestamp when present (lite-05) and falls
back to create_frame_now for pre-lite-05 drafts. The IETF subscriber uses
create_frame_now since moq-transport doesn't yet decode per-object timestamps.

JS sync (js/net): the lite publisher now advertises a millisecond timescale and
emits a per-frame zigzag-delta wall-clock timestamp, instead of sending
timescale 0. Without this a Rust lite-05 subscriber would reject the stream,
since TRACK_INFO timescale is now a mandatory non-zero varint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
@kixelated kixelated changed the title feat(moq-net): make timestamps mandatory for moq-lite-05 feat(moq-net): mandatory timestamps for moq-lite-05 Jun 23, 2026
claude added 4 commits June 23, 2026 23:29
frame.timestamp is no longer Option, so the lite-05 assertions read it
directly; backend.rs uses write_frame_now for its untimed frames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
IETF moq-transport now carries the frame presentation timestamp as per-object
extension headers, using the MOQ Object Properties registry ids shared with
draft-ietf-moq-loc: Timestamp (0x06) in Timescale (0x08) units, each a varint.
The publisher sets the group's extensions flag and emits them per object; the
subscriber parses them and reconstructs the timestamp (a missing timescale
defaults to microseconds per the registry). When no Timestamp property is
present (an older or non-compliant peer), frames fall back to wall-clock via
create_frame_now, so only genuinely timeless sources lose the real PTS.

Encoding matches moq-loc's existing use of these ids: the timestamp is the
absolute value (not a delta) so a relay or LOC-aware peer reads the same bytes.
The moq-lite wire keeps its zigzag-delta per-frame encoding; this is the
moq-transport object-property form.

Removes the now-unused Reader::skip (its only caller parsed the extension block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Encode the Timestamp object property (0x06) as a per-object zigzag delta against
the previous object in the subgroup, rather than an absolute value. The Timescale
property (0x08) stays absolute. This matches the moq-lite per-frame encoding, so
both wire formats delta-compress out-of-order PTS (B-frames) the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Revert the moq-transport Timestamp object property (0x06) to an absolute varint
rather than a per-object zigzag delta. This matches moq-loc's encoding of the same
registry id, so a relay or LOC-aware peer reads identical bytes. The moq-lite wire
keeps its own zigzag-delta per-frame timestamps; only the moq-transport object
property is absolute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Comment thread js/net/src/lite/publisher.ts Outdated
if (!frame) break;

if (timestamps) {
const ts = BigInt(Math.round(Milli.now()));

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.

I'm missing context, why are we not using the timestamp provided by the app?

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.

Good catch — it was a stopgap. The JS frame model stored bare Uint8Arrays with no per-frame timestamp (hang embedded its timestamp inside the payload), so at the publisher there was no app-provided time to read, and wall-clock was just there to keep the mandatory lite-05 timestamp on the wire.

Fixed in df988c7: the model now carries a per-frame microsecond timestamp (Group/TrackProducer take writeFrame(timestamp, data), with writeFrameNow(data) for genuinely timeless data like catalogs/JSON). The lite publisher advertises a microsecond timescale and delta-encodes each frame's real timestamp, and the subscriber decodes it (it was previously read and discarded). hang/loc/publish pass their µs timestamps through.

(Written by Claude)


Generated by Claude Code

The JS frame model now stores a per-frame microsecond timestamp instead of a bare
Uint8Array, so the lite publisher emits the app's real presentation time rather
than wall-clock. Group/TrackProducer gain writeFrame(timestamp, data) plus a
writeFrameNow(data) for timeless data (catalogs, JSON state); the lite publisher
advertises a microsecond timescale and delta-encodes each frame's timestamp; the
lite subscriber decodes the wire timestamp and converts it to microseconds (it was
read and discarded before). hang/loc/publish pass their micro timestamps through.

Addresses review feedback: don't synthesize wall-clock when the app provided a time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Comment thread js/net/src/lite/publisher.ts Outdated
// Lite05 mandates per-frame timestamps. Model frames carry a microsecond
// timestamp (the app's, or wall-clock for timeless data), so we advertise
// microseconds and emit each frame's own timestamp in `#runGroup`.
timescale: MICRO_TIMESCALE,

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.

This should be set by each track independently. It's a huge waste of bytes to use microseconds if we don't need it.

ex. anything using writeFrameNow should probably use milliseconds, because clock accuracy is shit anyway.

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.

Switched the publisher to advertise milliseconds (1000) instead of microseconds, and writeFrame's default is now performance.now() in ms — so the byte-waste is gone and timeless data uses ms as you said.

True per-track timescale (different scales per track) I left for the follow-up: with the current branded-number approach the per-frame value can't carry its own scale, so per-track only works cleanly once we add the proper Timestamp type (value + scale). For now every JS track is ms. Flagged it in the commit + PR.

(Written by Claude)


Generated by Claude Code

Comment thread js/net/src/group.ts Outdated
* Reads the next frame's payload from the group.
* @returns A promise that resolves to the next payload or undefined
*/
async readFrame(): Promise<Uint8Array | undefined> {

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.

MAke this return Frame instead of a separate Timed method.

Comment thread js/net/src/group.ts Outdated
* @param data - The frame payload
*/
writeFrame(frame: Uint8Array) {
writeFrame(timestamp: Micro, data: Uint8Array) {

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.

Can you put timestamp second and make it ?. If undefined, we use performance.now().

Or honestly just require it. have the caller do performance.now() if they don't care about timestamps. Milli not Micro.

But really we should have a proper Timestamp type like Rust. I think it would be a big improvement anyway, as type aliases are convient but very easy to get wrong.

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.

I mean I'm okay with writeFrameNow only if it's marked deprecated and we use it as a signal that we need to actually port everything over to mandatory timestamps (like the PR title btw).

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.

Done in f024ebb: writeFrame(data, timestamp?) — timestamp second and optional, defaulting to performance.now(), in Milli (not Micro). I removed writeFrameNow entirely (the optional default replaces it), so the deprecation note above is moot.

Left the proper Timestamp type (value + scale, like Rust) as a follow-up per your call — agreed it's the real fix for the alias footgun and what unlocks per-track timescales.

(Written by Claude)


Generated by Claude Code

Comment thread rs/moq-net/src/model/track.rs Outdated
/// reported in TRACK_INFO and the publisher zigzag-delta encodes per-frame
/// timestamps at this scale on the wire. Protocols whose wire can't carry it
/// (pre-Lite05 moq-lite, IETF moq-transport) fall back to wall-clock milliseconds.
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "is_default_timescale"))]

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.

remove skip_serializing_if

claude added 5 commits June 24, 2026 05:59
…tional arg

Rust:
- track.rs: drop `skip_serializing_if` on TrackInfo.timescale (always serialize).

JS (js/net and consumers), per review:
- Group.readFrame now returns the Frame {timestamp, data} directly; readFrameTimed
  is gone (callers use .data).
- writeFrame(data, timestamp?) takes the timestamp second and optional, defaulting
  to performance.now(); writeFrameNow is removed.
- Frame timestamps are milliseconds, not microseconds (transport timing doesn't
  warrant finer units), and the lite publisher advertises a millisecond timescale.
- hang/loc/publish convert their µs container timestamps to ms for the net frame;
  json and the IETF subscriber use the wall-clock default.

A proper JS Timestamp type (value + scale, mirroring Rust) and true per-track
timescale are a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Group/TrackProducer.writeFrame now accept a `Frame` ({ data, timestamp? }) instead
of positional args, mirroring readFrame's return type. The timestamp stays optional
and defaults to performance.now() (ms), so timeless call sites stay terse. Updated
hang/loc/publish/json/subscriber callers and tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
Add a `Timestamp` (value + scale) and `Timescale` to @moq/net, mirroring the Rust
types: `Timestamp.now()` is wall-clock milliseconds (performance.now()), and a
Timestamp carries its own scale so conversions can't silently mix units.

The frame timestamp is now required (no optional default): `Frame.timestamp` is a
`Timestamp`, and `writeFrame`/`TrackProducer.writeFrame` take a `Frame`. Callers
with no real presentation time pass `Timestamp.now()` explicitly — the visible
"port this to real timestamps" signal. The lite subscriber reconstructs a Timestamp
at the track's advertised scale (preserving per-track timescale on receive); the
publisher emits at milliseconds. hang/loc/publish pass Timestamp.fromMicros.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
JS: the model TrackInfo gains a `timescale` (default ms). The lite publisher
advertises it in TRACK_INFO and converts each frame to it, instead of hardcoding
milliseconds; the subscriber carries the wire timescale into the accepted track so a
relay re-publishes at the same scale. Producers can now pick a finer scale per track.

Rust: Timestamp's wall-clock anchor is now 2020-01-01 rather than the Unix epoch. A
Timestamp isn't a real clock (it just needs to be non-negative and roughly monotonic),
so dropping 50 years of magnitude trims a byte or two off the first frame's varint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
The From<Instant> doc linked `[ANCHOR_EPOCH_SECS]`, a private const, which
`-D rustdoc::private-intra-doc-links` rejects. Make it a plain mention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMGeN9op7nFEJ4KGJu1x4C
@kixelated kixelated enabled auto-merge (squash) June 24, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants