Streams a local video file to a relay as a CMSF broadcast
(draft-ietf-moq-cmsf-01:
CMAF packaging on top of MSF draft-ietf-moq-msf-01 and MoQ Transport
draft-ietf-moq-transport-20), and measures what comes back out.
It exists to answer one question about a suspected delivery fault: is the transport at fault, or the encoder feeding it? Every byte the publisher sends is known in advance, so the subscriber can say exactly what arrived, in what order and how late — and can reassemble the Objects it received into a file that either matches the source byte for byte or does not. A clean run points at the capture and encode path; a dirty one points at the transport.
video [flags] publish | subscribe
Start the relay, then the two halves in either order:
go run ./cmd/relay # :4433, self-signed cert
go run ./cmd/video -out /tmp/recv.mp4 subscribe
go run ./cmd/video -in clip.mp4 publish| Flag | Default | Mode | Description |
|---|---|---|---|
-addr |
localhost:4433 |
both | Relay address, host:port or a moqt:// URI |
-ns |
moq-example/video |
both | Track namespace |
-in |
— | publish | Video file to stream (required) |
-rate |
1 |
publish | Pacing multiplier; 0 sends as fast as the transport takes it |
-loop |
1 |
publish | Passes over the file; 0 repeats until interrupted |
-gop |
0 |
publish | Minimum Objects per Group; 0 starts a Group at every sync sample |
-delay |
2s |
publish | Pause between the catalog and the first frame |
-out |
— | subscribe | Where to reassemble the received media; - sends it to stdout |
-wait |
30s |
subscribe | How long to wait for a publisher on the namespace |
-packaging |
cmaf |
subscribe | cmaf for a broadcast this tool published, legacy for moq-lite/hang |
Any MP4 the file reader understands works — progressive or already fragmented, AVC / HEVC / AV1 / VP9. Only the first video track is published; audio, subtitle and data tracks are ignored.
Connections use InsecureSkipVerify: true so they work against the
relay's ephemeral self-signed cert. SIGINT / SIGTERM ends either side
cleanly, and the subscriber writes its report either way — a run cut short
still says what arrived before it was.
=== delivery report ===
objects 180 received, 0 missing (groups 6 received, 0 missing)
bytes 1516520 over 5.967s
order 0 objects arrived after a later one
latency p50 939µs p90 1.6ms p99 3ms max 3.7ms (180 objects)
slowest 0/25 3.7ms
slowest 5/7 3.1ms
spacing p50 33.4ms p99 36.9ms max 39.5ms (mean 33.2ms)
digest 211deb6c63d4dcaf14bfa31e5b62b6682d0c2b0b085c54253df1118cff7c96d0
MATCHES the source: every byte arrived, in order
- objects / groups — anything missing is loss, counted only between the first and last that arrived, so joining mid-broadcast is not reported as a failure.
- order — Objects that arrived after one the publisher sent later. One Group is one subgroup stream and streams are read concurrently, so a Group can overtake the tail of the one before it. That is inherent to MoQ, not a fault; it is here because a player that ignores it shows artefacts.
- latency — send to receive, per Object, from a producer-defined
Object Property the publisher stamps (type
0x3800, in the range §2.5 reserves for application-specific use, which IANA will never allocate and a relay must forward unchanged). Only meaningful with both ends on one machine, since it subtracts two clocks. Theslowestlines name the worst Objects bygroup/object, because a spike is a handful of Objects and an average hides it. - spacing — gaps between successive arrivals in send order. A stall shows here even when latency cannot be measured.
- digest — SHA-256 of the CMAF header plus every Object payload in
order, against the same value the publisher declared in the catalog.
Needs
-out, which is the only thing that retains payloads: without it a run keeps one small record per Object and not the media, which is what makes-loop 0soak testing practical.
The file -out writes is a playable fragmented MP4. If the digest matches
and it still shows artefacts, the artefacts were encoded into the source.
| CMAF | MoQ | Draft |
|---|---|---|
Init header (ftyp+moov) |
catalog initDataList, inline base64 |
CMSF §3.1 |
One chunk (moof+mdat, one frame) |
one Object | CMSF §3.3 |
| Fragment starting at a sync sample | one Group, one subgroup stream | CMSF §3.4 |
One frame per Object rather than one GOP is deliberate: an Object that spans a GOP reports one arrival time for two seconds of video and hides exactly the spikes this is looking for. It is also what a low-latency CMAF publisher emits, so nothing about the shape is diagnostic-only.
-gop N widens Groups without breaking §3.4 — a sync sample reached
before the open Group holds N Objects becomes an ordinary mid-Group
Object rather than starting a new one. Use it to compare "many short
streams" against "few long ones".
The catalog declares packaging: "cmaf" (§3.5.1) and both SAP fields
(§3.5.2) as 2. That is the only conformant value: both fields are
maxima, §3.4 pins a Group's first Object to SAP type 1 or 2 on a cmaf
track, and pkg/moqt/msf rejects anything outside that. Input that may
not satisfy §3.4 is flagged at publish time instead — see Limitations.
-packaging legacy reads the bare-bitstream tracks the moq-lite/hang stack
serves, which is how you point this at a publisher that is not moq-go:
go run ./cmd/video -addr moqt://cdn.moq.pro/demo -ns bbb.hang \
-packaging legacy -out /tmp/bbb.mp4 subscribeThat produces a playable 1280x720 file and a delivery report. Such a track's
Objects are a QUIC varint timestamp in microseconds followed by one Annex-B
access unit — no container — and its catalog declares packaging: "legacy",
which is not an MSF §5.2.4 value, so msf rejects it and this mode treats
validation as advisory. There is no initDataList entry either: the codec is
avc3, so the parameter sets are in the bitstream and the SPS/PPS opening the
first Group become the decoder configuration.
Two things are dropped on the way into the file, and neither is dropped from
the report: the frames ahead of the first keyframe, which a live join lands
in the middle of and which reference a picture that was never sent; and the
occasional access unit carrying no picture at all — one in a few hundred on
bbb.hang holds a single reserved-type NALU. Both decode as errors, which in
this tool would read as corruption.
-out - sends the media to stdout instead of to a file, so it can go
straight into a player. The report moves to stderr to keep the pipe clean:
go run ./cmd/video -addr moqt://cdn.moq.pro/demo -ns bbb.hang \
-packaging legacy -out - subscribe | mpv --vo=tct - # ASCII, in the terminal
go run ./cmd/video ... -out - subscribe | ffplay - # a windowDecoding is the player's job rather than this tool's: drawing frames would
mean carrying an H.264 decoder, and every terminal player already has one.
mpv --vo=tct and chafa both render into a terminal — brew install mpv
if neither is present.
Each segment goes out headed by a styp and written in a single call.
Neither is cosmetic: mp4ff encodes a fragment box by box, and a player
reading the other end of a pipe can wake on half a moof, at which point
ffmpeg's demuxer sanity-checks the trun against how much input it thinks
remains, gets a negative answer, and stops — which mpv reports as end of
file a few seconds in. The same bytes read from a file always parsed
perfectly, which is what made it look like a timing fault.
Fragments carry half a second of samples rather than one frame. The
Objects are still one frame each, and that is what the report measures —
this is only how they are framed on the way out. ffmpeg's demuxer
sanity-checks every trun against how much input it believes remains, and
on a growing non-seekable stream that estimate goes negative from time to
time; at twenty-four truns a second the odds catch up within half a
minute, which is what "plays about twenty-five seconds and stops" was.
It is better rather than perfect. mpv still occasionally complains, though
it now recovers instead of stopping, and ffplay fares better than mpv
because it does not attempt the backward seek mpv does on a stream that
cannot seek. If playback still stops early, -out to a file and play that
— the file is always intact, and the report says whether the transport was
at fault.
A run that receives nothing says so: no objects for a while in the
log means the publisher or relay stopped sending on the subscription.
Nothing is reported as lost, because nothing was — it was never sent. That
is a broadcast-side problem, not a delivery one, and no player can do
anything with it.
In this mode the timing figures are consumer-bound. A player that pauses
stops reading the pipe, the writer waits, and the Objects behind it are
stamped when they are read rather than when they landed — so latency and
spacing describe the player as much as the transport. That is the right
trade here: the alternative, dropping whatever the player was too slow to
take, hands it a stream with holes and playback stops. Measure with -out
to a file, or with no -out at all.
What the report loses: latency needs the publisher's send stamp and the digest comparison needs the source counts, and a foreign broadcast declares neither. Ordering, loss, spacing and the playable file all still work — and they are measured against a publisher that shares no code with this one, which is the only way the numbers mean anything beyond moq-go agreeing with itself.
Both tracks live under -ns:
| Track name | Packaging | Contents |
|---|---|---|
catalog |
(MSF catalog) | The broadcast description, plus the CMAF header and the source's digest. One Object per Group. |
video |
cmaf |
One CMAF chunk per Object, one Group per GOP. |
The catalog is published once at start (§5: a catalog Object SHOULD be published only when track availability changes), so a subscriber that connects later backfills it with a fill fetch stream (§5.1.3). The publisher closes with the §11.3 terminator catalog, which is what ends the subscriber's run and triggers its report.
- Open-GOP input is warned about, not rejected. A file whose Groups
open on an access point with leading pictures may be starting them at
SAP type 3, where CMSF §3.4 requires 1 or 2 — and it would then break up
at every Group boundary for reasons belonging to the file while
reporting a perfect digest, which is the exact false positive this tool
exists not to produce. Whether those leading pictures are RASL
(undecodable, SAP 3) or RADL (decodable, SAP 2) cannot be read from the
container at all, only from slice headers, so the publisher flags the
input rather than judging it. Re-encode with closed GOPs to rule the
encoder out; if the artefacts survive that, the result means something.
Detection reads composition times, not the
is_leadingfield ISO/IEC 14496-12 defines for it — ffmpeg writesis_leadingas 0 ("unknown") on every sample of both open- and closed-GOP output, so a check reading it never fires on the files this is pointed at. - Video only. Audio would double the tracks for no extra signal about video artefacts, and would complicate the byte comparison.
- The whole file is held in memory by the publisher, and by the
subscriber too when
-outis set. These are debug clips; streaming chunks off disk would put file I/O inside the loop being measured. - Latency needs one machine. Across two, the send stamp and the
receive stamp come from different clocks and only
spacing,orderand the loss counts mean anything. - The publisher lingers a second after its last write. Closing a QUIC connection abandons whatever it has not had acknowledged, and without the pause the terminator catalog is exactly what gets dropped.