A local sandbox for spinning up MoQ (Media over QUIC) streams: single rendition or a full ABR ladder, with or without SSAI, CSAI, or SGAI ad-insertion — no tools needed beyond Podman (and Node.js, for SGAI only).
This repo is self-contained: it has no dependency on anything outside it
(own package.json, own pnpm-workspace.yaml, own assets/).
- Project layout
- How it works
- Requirements
- Add your video files
- Run it
- What you get
- SSAI: ad insertion and impression tracking
- CSAI: SCTE-35 signaling
- SGAI: Event Timeline signaling
- Relay HTTP API
- Dashboard: real-time monitoring
- Troubleshooting
- Contributing
- Acknowledgments
moq-lab/
├── stream.sh ← you run this
├── run-stream.sh ← runs inside the container
├── Containerfile
├── package.json ← host-side Node deps, used only by sgai/
├── pnpm-workspace.yaml ← marks this repo as its own pnpm project
├── assets/ ← your local test videos (gitignored)
├── lib/ ← shared helpers (logger, CLI arg parsing, fMP4 box parsing, MOQ URLs, --source probing)
├── ssai/ ← Server-Side Ad Insertion (in-container proxy)
├── csai/ ← CSAI SCTE-35 signaling (in-container proxy)
├── sgai/ ← Server-Guided Ad Insertion (host-side publisher)
└── dashboard/ ← optional monitoring web UI (React + Vite), built into the image
ssai/ and csai/ run inside the Podman image (copied in by the
Containerfile) — they're pure Node with no npm dependencies. sgai/ runs on
your host, outside Podman, and is the only part of this sandbox with
external dependencies (@moq/net, @moq/msf, ws, zod — installed via
this repo's own package.json).
dashboard/ is its own pnpm project with its own package.json. You never
need to install it yourself: the Containerfile builds it in a separate
stage, and only the static output plus a small dependency-free Node server
end up in the image. See section 9.
Containerfilebuilds an image withffmpeg,moq(themoq-clipublisher/subscriber binary), andmoq-relaypre-installed — as prebuilt Linux binaries downloaded from moq-dev/moq's GitHub releases, not compiled from source, so the build is fast.run-stream.shruns inside the container: it transcodes your video, starts the relay, and feeds the stream intomoqas the publisher.stream.shis what you actually call. It builds the Podman image and starts the container for you.
This sandbox has two independent dimensions, which you combine with flags:
| Dimension | Flag | What it changes |
|---|---|---|
| Rendition ladder | --abr-ladder |
One stream vs. a 5-rendition ABR ladder (240p–1080p) |
| Ad insertion mode | --ssai-mode / --csai-mode / --sgai-mode |
Off by default; the three are mutually exclusive |
That's 2 × 4 (no ad mode, SSAI, CSAI, SGAI) = 8 runnable combinations, all described below.
The content comes from a file, assets/<name>.mp4, or from a URL with
--source (an MP4, HLS, or DASH stream; see Stream from a
URL).
SSAI (--ssai-mode, Server-Side Ad Insertion) splices real ad video into
the content stream server-side, in a continuous loop, as one already-stitched
track — the client has no visibility into where the ad break is. See section
5.
CSAI (--csai-mode, client-side ad insertion signaling) leaves the
content stream untouched and adds a real SCTE-35 Break Start/Break End track
alongside it, for a client to subscribe to and act on itself — no server-side
switching. See section 6.
SGAI (--sgai-mode, Server-Guided Ad Insertion) is in between: content,
ad, and ad-decisioning signaling are published as three independent MoQ
broadcasts, and a subscriber is expected to consume the signaling track to
decide when to switch between the content and ad broadcasts itself — the
server signals the opportunity, but doesn't splice anything. See section
7.
- Podman installed — use the official
installer, not Homebrew: Podman's own docs advise
against
brew install podman, since Homebrew is community-maintained and they can't guarantee its stability. On macOS this needs apodman machine—stream.shrunspodman machine init/startfor you on first use if one isn't ready yet, so there's nothing to set up by hand beyond installing Podman itself. Linux and macOS only —stream.sh/run-stream.share bash scripts, so there's no native Windows support; on Windows, run everything inside WSL2, where Podman and this repo both behave exactly as they do on Linux (untested by this repo's CI, which only runs on Linux). - For
--sgai-modeonly: Node.js >=20 (its host-side signaling script runs outside Podman).stream.shrunspnpm installfor you automatically when you pass--sgai-mode(a no-op in well under a second once already up to date) — this also appliespatches/@moq__msf.patch, a one-line fix for a missing.jsextension in@moq/msf's own ESM export map (an upstream packaging bug, not something this repo's code needs).pnpmitself ships with Node.js >=16.9 viacorepack, whichstream.shalso enables automatically if needed.
Place .mp4 files in assets/:
moq-lab/
├── assets/
│ ├── bbb.mp4 ← your content video (any name)
│ └── ad.mp4 ← your ad video (must be named exactly ad.mp4)
├── stream.sh
└── ...
Content video (assets/<name>.mp4): any H.264/AAC MP4. You pass <name>
on the command line. The file is mounted read-only inside the container.
Ad video (assets/ad.mp4): only needed for --ssai-mode and
--sgai-mode. The name is fixed — it must be ad.mp4. The server normalises
its resolution, frame rate, and sample rate automatically to match the
content, so the source file can have different encoding parameters.
assets/*.mp4 is gitignored (see assets/.gitignore), so your videos stay
local.
No local file? You can also stream straight from a URL — an MP4, HLS, or DASH stream. See Stream from a URL.
All commands below are run from the repo root.
# Default: uses assets/bbb.mp4, single rendition, port 4443
./stream.sh bbb
# Different content file (assets/<name>.mp4)
./stream.sh tos
# 5-rendition ABR ladder (240p / 360p / 480p / 720p / 1080p)
./stream.sh bbb --abr-ladder
# Custom port
./stream.sh bbb --port 4444Instead of a file in assets/, pass --source URL to publish an existing
stream over MoQ. The URL can point to:
- an MP4 file over HTTP(S),
- an HLS playlist (
.m3u8), on demand or live, - a DASH manifest (
.mpd), on demand or live.
# HLS (the broadcast is called live.hang)
./stream.sh --source https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8
# DASH, with your own broadcast name (bbb.hang)
./stream.sh bbb --source https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd
# An MP4 file, as a 5-rendition ABR ladder
./stream.sh trailer --source https://media.w3.org/2010/05/sintel/trailer.mp4 --abr-ladderWith --source, <name> only names the broadcast (<name>.hang). It
defaults to live. No file in assets/ is needed.
How it works:
-
Before the relay starts, the container reads the URL with
ffprobe(lib/probe-source.mjs). If the URL can't be read, or has no video and audio, the run stops with the reason:cannot read the source: https://example.com/live.m3u8: Server returned 404 Not Found -
HLS and DASH streams often have several variants (a ladder). Only one is published: the one with the highest resolution, with its own audio track. ffmpeg only downloads that variant. Add
--abr-ladderto make the usual 5 renditions from it. -
H.264 video with AAC audio is copied as-is. Anything else (for example HEVC or AC-3) is re-encoded to H.264/AAC, so every browser can play it.
-
The startup log shows what was picked:
Source: hls (on demand, 634.6s); video stream #9 (h264), audio stream #8 (aac) -
The source plays once. A live stream goes on until you press
Ctrl+C. An on-demand stream (MP4, HLS, or DASH VOD) ends the run when it ends (Source ended.) — unlike a local file, it isn't looped. -
Only
http://andhttps://URLs are accepted, and ffmpeg may only use network protocols (http,https,tcp,tls,cryptofor AES-128 HLS). A playlist can't make it read a local file. If a request to the source gets no answer for 15 seconds, the run ends.
--source works with --abr-ladder, --csai-mode, --sgai-mode, --auth,
and --dashboard. It doesn't work with --ssai-mode: SSAI cuts the content
into parts at fixed times, which needs a local file.
Requires assets/ad.mp4 to exist.
# SSAI with default settings (ad break every 30 seconds)
./stream.sh bbb --ssai-mode
# Custom ad break interval (e.g. every 60 seconds)
./stream.sh bbb --ssai-mode --ad-break-every 60
# SSAI + ABR ladder
./stream.sh bbb --ssai-mode --abr-ladder
# SSAI + ABR ladder + custom port
./stream.sh bbb --ssai-mode --abr-ladder --port 4444 --ad-break-every 45No assets/ad.mp4 needed — no ad video is interleaved. The content stream
plays continuously (single rendition or --abr-ladder); a SCTE-35 Break
Start/Break End track is added alongside it for a client to subscribe to and
act on. See section 6 for details.
# CSAI with default settings (a break every 30s, 6s between Start and End)
./stream.sh bbb --csai-mode
# Custom cadence: a break every 60s, lasting 10s
./stream.sh bbb --csai-mode --ad-break-every 60 --ad-break-length 10
# CSAI + ABR ladder
./stream.sh bbb --csai-mode --abr-ladderRequires assets/ad.mp4 to exist. stream.sh runs pnpm install for you
automatically the first time you pass --sgai-mode — see section
7 for details.
# SGAI with default settings (a break every 30s; ad break length is
# assets/ad.mp4's own duration, auto-detected)
./stream.sh bbb --sgai-mode
# Custom cadence
./stream.sh bbb --sgai-mode --ad-break-every 60
# SGAI + ABR ladder
./stream.sh bbb --sgai-mode --abr-ladderTo test a different ad break length, use a different assets/ad.mp4 — SGAI
always uses the file's real duration (see section
7).
--ssai-mode, --csai-mode, and --sgai-mode are mutually exclusive — each
uses a different pipeline.
By default the stream is public: anyone who can reach the port can watch it,
and also publish to the relay. Add --auth or --auth-key FILE to accept
only connections that carry a valid JWT. Both
work with every mode above.
# A new signing key for this run only. Old tokens stop working on the next run.
./stream.sh bbb --auth
# Your own signing key, so the tokens you give out stay valid across runs.
# Create the key once (the moq-lab image exists after the first ./stream.sh run):
podman run --rm --entrypoint moq moq-lab token generate --out - > my-key.jwk
./stream.sh bbb --auth-key my-key.jwkAt startup, stream.sh prints a viewer token and the relay URL with the
token already in it:
Auth enabled: the relay only accepts connections with a valid JWT.
Viewer token (subscribe only, valid for 24 hours):
eyJ0eXAiOiJKV1Qi...
Relay URL for the player: https://localhost:4443/?jwt=eyJ0eXAiOiJKV1Qi...
How it works:
- The relay checks every connection, and every call to its HTTP API
(section 8), against the signing key. A request without a token, or with a
token signed by another key, is refused (HTTP
401). - The viewer token can only subscribe. It cannot publish.
stream.shalso signs a publish token for its own publishers (the content stream, the ads, and the SGAI signaling). You don't need to do anything for these.- The key and tokens are made with the
moq tokencommand inside the Podman image, so you don't need any other tool on your machine.
Keep my-key.jwk private: anyone who has it can sign their own tokens. To
make more viewer tokens with your key (for example, with a different expiry
time), use moq token sign:
podman run --rm -i --entrypoint moq moq-lab token sign --key - --subscribe "" \
--expires "$(( $(date +%s) + 3600 ))" < my-key.jwk| Flag | Default | Description |
|---|---|---|
<name> |
bbb |
Content file to stream (assets/<name>.mp4). With --source, only the broadcast name (default live) |
--source URL |
off | Stream from an MP4, HLS, or DASH URL instead of assets/<name>.mp4. Not with --ssai-mode |
--abr-ladder |
off | Encode a 5-rendition ABR ladder instead of a single stream |
--port N |
4443 |
Port for both the QUIC relay and the HTTP API |
--ssai-mode |
off | Interleave assets/ad.mp4 as a recurring, server-stitched ad break |
--ad-break-every N |
30 |
Seconds of content between ad breaks. Shared by all three ad modes |
--csai-mode |
off | Add a Break Start/Break End SCTE-35 track; no ad video is interleaved |
--ad-break-length N |
6 |
CSAI only: seconds between Break Start and Break End |
--sgai-mode |
off | Publish content, ad, and Event Timeline signaling as independent broadcasts |
--blackout-at N |
off | CSAI or SGAI: fire a one-shot Program Blackout Override N seconds in |
--blackout-length N |
10 |
CSAI or SGAI: seconds until the blackout restores |
--personalized-ads |
off | SGAI only: template ad upids with a %token% placeholder |
--auth |
off | Only accept connections with a JWT; a new key is made for this run |
--auth-key FILE |
off | Like --auth, but sign with the key in FILE |
--dashboard |
off | Serve the real-time monitoring web UI on http://localhost:8080 |
--dashboard-port N |
8080 |
Like --dashboard, on port N (must differ from --port) |
pnpm stream # same as: ./stream.sh bbbhttps://localhost:4443
The relay uses a self-signed TLS certificate (generated at startup). Your browser will warn about it; accept it once and the player will work.
With --auth or --auth-key, use the URL that stream.sh prints instead.
It has the viewer token in it: https://localhost:4443/?jwt=<token> (see
Access control).
| Mode | Broadcast name(s) |
|---|---|
| Single rendition | <name>.hang |
--abr-ladder |
<name>.multi.hang |
--sgai-mode, additionally |
<name>-events (signaling), <name>-ad-N.hang (one per ad break, N incrementing) |
Point the player's moq config at the URL and broadcast name:
moq: {
url: "https://localhost:4443",
namespace: "bbb.hang",
// ... other player settings
}Press Ctrl+C to stop the stream.
Architecture note — shared/linear, not per-session. This sandbox's SSAI splices one ad break into one continuously-looping stream, shared by every viewer who subscribes to the broadcast — the same model as a linear TV channel's server-side ad insertion. Every viewer sees the same ad at the same point, and there is no per-viewer ad selection. This is not the "per-session" model most production SSAI (Yospace, FreeWheel/GAM DAI-style) uses today, where each viewer session gets its own manifest/stream stitched with ads selected for that specific viewer (targeting, frequency capping, personalized creative). Trade-offs:
| Shared/linear (this repo) | Per-session (industry standard) | |
|---|---|---|
| Ad selection | One ad for all viewers | Selected per viewer/session |
| Server cost | One encode/stitch, fans out to N viewers | One stitched output per session |
| Targeting, frequency capping | Not possible | Core use case |
| Complexity | Low — matches this sandbox's scope | Requires per-session ad decisioning + stitching at scale |
Extending this to per-session SSAI is a real architectural change (session
state, per-viewer stitching or manifest manipulation), not a flag — it's not
implemented here. SGAI's --personalized-ads/%token% mechanism (section 7)
addresses personalization for the signaling path, not for SSAI's spliced
stream.
When you pass --ssai-mode, the pipeline works like this:
ffmpeg (concat filter) → ssai/impression-tracker.mjs → moq import fmp4 → relay → player
What ffmpeg does: it builds a loop of content segments interleaved with the normalised ad. Each cycle looks like:
[content segment — N seconds] [ad video — auto-detected duration]
The cycle repeats continuously. The ad duration is read automatically from
the ad file with ffprobe — you do not need to pass it manually.
What the impression tracker does: ssai/impression-tracker.mjs is a
transparent byte-stream proxy. Every byte that comes from ffmpeg is passed
unchanged to moq. At the same time, the proxy reads the fMP4 box structure
from the stream and watches the video timestamps (tfdt values inside
moof boxes). When a timestamp crosses a quartile threshold within the ad
segment, the proxy logs it to stderr:
[SSAI] 2026-06-23T10:00:00.000Z adBreakEvery=30 adBreakLength=28.421
[SSAI] 2026-06-23T10:00:30.012Z break=0 | start | 0% | streamSecs=30.000
[SSAI] 2026-06-23T10:00:37.112Z break=0 | first_quartile | 25% | streamSecs=37.105
[SSAI] 2026-06-23T10:00:44.212Z break=0 | midpoint | 50% | streamSecs=44.210
[SSAI] 2026-06-23T10:00:51.312Z break=0 | third_quartile | 75% | streamSecs=51.315
[SSAI] 2026-06-23T10:00:58.421Z break=0 | complete | 100% | nominalSecs=58.421
No HTTP requests are made. No timers are used. The timing comes entirely from the fMP4 stream PTS, so it is accurate to the frame. This logging is local to the sandbox for debugging — it is not delivered to the client over any MoQ track. The client receives a single, already-stitched stream and has no visibility into where the ad break is; that is the defining trait of SSAI.
Note on timing: the proxy fires events when the frame data enters the publishing pipeline. Because the player buffers a few seconds of video, the viewer will see each event a short time after it is logged. This is expected for server-side tracking.
Current limitation — slow startup: the concat filtergraph opens up to
N_CYCLES × 2 (up to 40) separate ffmpeg inputs upfront — a content segment
and the ad, per cycle — each doing its own accurate seek (-ss) into the same
source file. Building and seeking into all of them before the first frame
reaches the encoder measurably takes on the order of ~15 seconds on ordinary
hardware, independent of --abr-ladder (measured nearly identical with and
without it). Once running, timing is frame-accurate with no ongoing drift —
the delay is a one-time startup cost, not a precision problem — but it means
the first ad break lands ~15s later in wall-clock time than
--ad-break-every's value would suggest if you're timing from when you ran
stream.sh rather than from the first frame the player receives. Fixing this
properly needs a different input architecture (e.g. one continuous input with
trim/atrim instead of N separately-seeked inputs) and hasn't been done yet.
When you pass --csai-mode, the pipeline works like this:
ffmpeg (remux to MPEG-TS) → csai/ts-injector.mjs → moq import ts → relay → player
Why MPEG-TS: moq import ts demuxes an incoming MPEG-TS stream and
auto-generates a catalog with a video/audio section (same as the fmp4
pipeline) plus an mpegts.tracks section describing every PID it saw. Any PID
it can't decode as a known audio/video codec — like a private SCTE-35
stream — is exposed as an opaque "verbatim" track instead of being dropped.
This is the same mechanism a real SCTE-35-aware backend uses, so the catalog
shape here matches production:
"mpegts": {
"tracks": {
"0.avc3": { "pid": 256 },
"0.aac": { "pid": 257 },
"0.ts": {
"pid": 496,
"descriptors": [{ "tag": 5, "data": "Q1VFSQ==" }],
"verbatim": { "streamType": 6, "framing": "pes", "streamId": 252 }
}
}
}"0.ts" is the SCTE-35 track — streamId: 252 (0xFC) is the PES stream ID
SCTE-35 reserves for splice_info_sections, and the descriptors entry is the
standard CUEI registration descriptor (base64 for "CUEI") that marks the
PID as SCTE-35 in the PMT. A client subscribes to that track name directly —
there is no separate broadcast or side channel. --abr-ladder works the same
way here as everywhere else: the extra video renditions and the SCTE-35 track
all live in the same catalog.
What ts-injector.mjs does: like ssai/impression-tracker.mjs, it's a
transparent byte-stream proxy sitting between ffmpeg and moq. Every TS
packet is passed through unchanged, except:
- The PMT, which it rewrites (once per repetition, recomputing the CRC) to add the SCTE-35 elementary stream on a synthesized free PID.
- On a schedule driven by the video elementary stream's own PES decode
timestamps (
--ad-break-every/--ad-break-length, read against the video PID's DTS — falling back to PTS for frames without B-reordering, same idea asssai/impression-tracker.mjs'stfdtreads), it splices in a real, CRC-valid SCTE-35splice_info_section— atime_signalcommand with asegmentation_descriptorfor Break Start (0x22) or Break End (0x23) — wrapped in a PES packet, on that PID. The descriptor signalssegmentation_duration(--ad-break-length) on both cues, so a subscriber acting only on Break Start already knows the avail's length. The encoder lives incsai/scte35.mjs. Because this reads real timestamps rather than the wall clock, cues stay accurate even ifffmpegfalls behind real-time under load (e.g. a heavy--abr-ladderencode). - Optionally (
--blackout-at), a one-shot Program Blackout Override (0x18) cue — see "Regional blackout" below. - A
splice_null()heartbeat on the SCTE-35 PID every 2 seconds, starting immediately at stream start (not just when a real cue fires).moq import tsbuilds its catalog from the PIDs it has actually seen traffic on, so without a heartbeat the PID stays absent from the catalog until the first real Break Start/End — which can be well after a player has already fetched a catalog that doesn't mention it, leaving the track undiscoverable for the rest of that session.
[CSAI] 2026-07-15T14:03:44.722Z Break Start (event_id=0x3e8, pts=2702250, pid=496)
[CSAI] 2026-07-15T14:03:47.722Z Break End (event_id=0x3e8, pts=3242250, pid=496)
You can inspect the raw SCTE-35 track directly over HTTP:
# raw SCTE-35 bytes as they arrive on the wire
curl -s http://localhost:4443/fetch/bbb.hang/0.ts | xxd--blackout-at N (CSAI or SGAI) fires a one-shot Program Blackout
Override cue N seconds into the run, followed --blackout-length seconds
later (default 10) by a restore cue. In CSAI this is the same
segmentation_type_id: 0x18 descriptor as SGAI's Event Timeline record (section
7's "Regional blackout") — same delivery/blackout flags
(no_regional_blackout_flag/web_delivery_allowed_flag/archive_allowed_flag/device_restrictions)
and the same alternate-content URI carried as a 0x0F-typed
segmentation_upid — but encoded as a real, CRC-valid splice_info_section
on the SCTE-35 PID (csai/scte35.mjs's buildProgramBlackoutOverrideSection)
instead of published as JSON. There's no separate signaling broadcast to
subscribe to: a real affiliate headend already parsing the Break Start/End
track for splice points sees the same 0x18 type ID show up in-band and
switches to alternate content on the first cue, back to the normal feed on
the second.
./stream.sh bbb --csai-mode --blackout-at 20 --blackout-length 10[CSAI] 2026-07-15T14:03:49.011Z Program Blackout Override -- ENFORCE (event_id=0x1388, pts=1802250, alt=moqt://localhost?ns=blackout-alt-content.hang, pid=496)
[CSAI] 2026-07-15T14:03:59.014Z Program Blackout Override -- RESTORE (event_id=0x1388, pts=2702475, pid=496)
Everything above still round-trips SCTE-35 through the same binary
splice_info_section encoding a legacy MPEG-TS/broadcast pipeline expects —
that's the point of CSAI (section 6's whole premise is decisioning happens
client-side, but the signal stays in the format an existing SCTE-35-aware
client already parses). A MoQ-native variant of CSAI is a different idea,
not built here: reuse SGAI's transport — the same org.scte.scte35.v1 JSON
Event Timeline track, @moq/msf catalog, and Media Timeline
(section 7) — but leave the ad-break/blackout decision to the client
instead of an affiliate headend acting on it server-side. That would collapse
CSAI and SGAI into one signaling transport differing only in who
decides (client vs. server-guided), at the cost of losing CSAI's actual
purpose here: exercising the legacy binary SCTE-35 path end to end. Noted as
a possible future direction, not a gap in the current sandbox.
Everything in this repo (CSAI's binary encoder, SGAI's org.scte.scte35.v1
JSON records) implements SCTE 35-1 ("Legacy Splice-Based and Time-Based
Signaling", the April 2026 renumbering of the original SCTE-35). SCTE has
since published SCTE 35-2 ("Event-Based Signaling"), a
EventDescriptor/PTP-based-TimeDescriptor model positioned as SCTE 35-1's
replacement rather than a companion spec. It isn't implemented here: the spec
sits behind SCTE's member paywall, so there's no bit-level definition to
conform to. Noted as a known gap, not a decision — worth revisiting if the
spec becomes accessible.
--ssai-mode (section 5) splices the ad into the content's media stream —
server-side, one track. --sgai-mode is a different mode that instead
publishes content, ad, and ad-decisioning signaling as three independent
MoQ broadcasts: content plays unmodified and continuously, the ad is
published only on demand for the duration of each break, and a signaling
track carries org.scte.scte35.v1-shaped Event Timeline records for a
subscriber to act on — no server-side splicing, no MPEG-TS/SCTE-35 binary
encoding (contrast with CSAI, section 6): the records are already
decoded JSON.
This is the only mode with npm dependencies — declared in this repo's own
package.json (see "Project layout" above), even though only sgai/*.mjs
imports any of it today. stream.sh runs pnpm install automatically the
first time you pass --sgai-mode; nothing to do by hand.
- Inside Podman (
run-stream.sh, same container as always): content is published continuously to<name>.hang/<name>.multi.hang, exactly like the base pipeline. The ad is not published continuously — only the ad file is normalized to the content's profile and left on disk (/tmp/ad_normalized.mp4) for the Ad Decisioning Publisher below to trigger on demand. - On your host (not in Podman):
sgai/ad-decisioning-publisher.mjsconnects to the relay as a third, independent MoQ publisher and emitsorg.scte.scte35.v1-shaped Event Timeline JSON records (built bysgai/event-timeline.mjs) on a broadcast named<name>-events, on a--ad-break-every-cadence schedule (ad break length is always the real duration ofassets/ad.mp4— to test a different length, use a different ad file) — the ad-break schedule itself does not parse media timestamps (the Media Timeline below is a separate concern that does). The broadcast'scatalogtrack is a real@moq/msf(draft-ietf-moq-msf-01) catalog with two tracks:events(packaging: "eventtimeline") andmediatime(packaging: "mediatimeline", see below) — not hand-rolled JSON.eventsdeclaresdepends: ["mediatime"], a real co-published track. At the start of every ad break, this same script alsopodman execs a fresh, single-shotffmpeg | moq import fmp4of the normalized ad into the sandbox container, from the ad's own frame 0 — there is no continuously-looping ad stream to land mid-file on. Each ad break publishes under its own unique broadcast name (<name>-ad-0.hang,<name>-ad-1.hang, ...) rather than reusing one name across a kill+restart, since a relay may not clean up a killed producer's registration promptly enough for a same-named replacement to be routed to correctly. The name is carried to a subscriber in the record'ssegmentation_upid_urifield.sgai/transport.mjsholds the Node-specific relay connection workarounds (no native WebTransport, self-signed cert,Promise.withResolverspolyfill) shared by this script and the debug subscriber below.
The four records emitted per ad break, in order: Ad Start (carries the
per-break ad broadcast name), Placement Opportunity Start, Ad End,
Placement Opportunity End. A real subscriber is expected to UNSUBSCRIBE
from content on Placement Opportunity Start, FETCH the ad broadcast named
in Ad Start, and RESUBSCRIBE to content on Placement Opportunity End.
sgai/ad-decisioning-publisher.mjs also subscribes to the content
broadcast's own video track — never affecting its playback — purely to
observe it, and publishes a real mediatime track (packaging: "mediatimeline", entries [mediaTimeMs, [groupId, objectId], wallClockMs],
per draft-ietf-moq-msf's explicit-entry format). groupId/objectId are
read straight off the wire (the relay's own Group/object sequence numbers),
not derived or guessed; mediaTimeMs comes from parsing the real tfdt box
in each object's fMP4 fragment (sgai/media-timeline.mjs, sharing its box
parser with ssai/impression-tracker.mjs via lib/fmp4.mjs). One entry is
emitted per Group (i.e. per keyframe/GOP boundary), matching the spec's own
illustrative sampling cadence.
Simplification: this track lives on the events broadcast (co-published
alongside events), not on the Media Publisher's own broadcast as the
architecture slides show — this sandbox's content publish is moq import fmp4, an off-the-shelf binary this repo doesn't control and can't attach an
extra track to directly.
--blackout-at N (SGAI only) fires a one-shot Program Blackout Override
record (segmentation_type_id: "0x18") N seconds into the run, per the
SGAI-over-MOQ spec's regional-blackout example: no_regional_blackout_flag: false plus a segmentation_upid_uri pointing at alternate content, followed
--blackout-length seconds later (default 10) by a restore record
(no_regional_blackout_flag: true, same segmentation_event_id). Override
the alternate-content URI with --blackout-alt-upid. A real affiliate
headend is expected to switch to the alternate content on the first record
and back to the normal feed on the second.
./stream.sh bbb --sgai-mode --blackout-at 45 --blackout-length 15Every segmentation_upid_uri this repo builds (ad upids, blackout alt-content
upids, in both CSAI and SGAI) is a real moqt:// URL built by
lib/msf-uri.mjs's buildUri()/parseUri(), following the ns=/t= query
convention shown in the architecture slides (e.g.
moqt://example.com/relay-app/relayID?ns=customerID/broadcastID&t=video) —
e.g. moqt://localhost?ns=bbb-ad-0.hang. ns= carries the broadcast path;
t= (track) is included only when the target track is known in advance —
which it usually isn't for an ad broadcast, since moq-cli only assigns its
actual track name once it starts publishing. This convention is shown only as
an informal example in the slides (draft-ietf-moq-msf's own ABNF leaves the
query's internal structure unspecified), and is adapted here to this
sandbox's flat broadcast naming rather than the slides' hierarchical
customerID/broadcastID pair, which this sandbox has no equivalent of.
sgai/debug-subscriber.mjs parses any upid it receives back into
{ endpoint, namespace, track } and logs it (-- ns=... t=...).
--personalized-ads (SGAI only) templates each ad's segmentation_upid_uri
with a %token% placeholder in the query string (e.g.
moqt://localhost?ns=bbb-ad-0.hang&tok=%token%), per draft-ietf-moq-msf's
Variable Substitution mechanism: a subscriber resolves %varname%
placeholders client-side, from the fragment (#...) of the URI it
connected with — never from a query parameter — using whatever token its own
session carries (see the draft's own example:
moqt://relay.example.com/live#namespace--name&token=XYZ789 resolves a
%token% field to XYZ789). sgai/debug-subscriber.mjs simulates that: a
fragment on its own --url (e.g. --url "http://localhost:4443#token=XYZ789")
is parsed the same way and resolved against any record it receives
(lib/msf-uri.mjs). This is orthogonal to the per-break unique broadcast
naming above, which solves a different problem (safe reuse across
kill+restart, not personalization) — the two combine freely.
No browser or player needed — three terminals:
# 1. Confirm the content and events broadcasts are announced (the ad broadcast only
# appears once the first ad break actually starts, under its own per-break name --
# see "What runs where" above)
curl http://localhost:4443/announced
# 2. Sanity-check the events catalog (a real @moq/msf catalog; two tracks,
# "events" and "mediatime" -- see "Media Timeline" above)
curl -s http://localhost:4443/fetch/bbb-events/catalog | jq
# 3. Watch the Event Timeline and Media Timeline live, with simulated subscriber
# actions. The #token=... fragment on --url is optional -- pass it to see
# %token% resolution in action (see "Ad personalization" above; only does
# anything with --personalized-ads).
# Note: http://, not https:// -- see the callout below.
node sgai/debug-subscriber.mjs \
--url "http://localhost:4443#token=XYZ789" --events-broadcast bbb-events \
--content-broadcast bbb.hang --ad-broadcast bbb-ad.hangThe subscriber script prints each SCTE-35 record (Placement Opportunity
Start/End, Ad Start/End, Program Blackout Override) and each Media Timeline
entry as they arrive, plus the action a real subscriber would take
(UNSUBSCRIBE content / FETCH ad / RESUBSCRIBE content / enforce or
restore a blackout) — logged only, no playback is actually driven.
Why http://... and not https:// for these two scripts: Node has no
native WebTransport, so @moq/net (used by both ad-decisioning-publisher.mjs
and debug-subscriber.mjs) falls back to a WebSocket transport. The relay's
web.http listener (the same one serving /announced and /fetch) is
plain HTTP — TLS (tls.generate in run-stream.sh's relay.toml) only
applies to its separate QUIC/WebTransport listener on the UDP side, which is
what the browser player uses. Passing https:// here gets converted to
wss:// and fails the TLS handshake against a plain-HTTP port ("wrong
version number"). This only affects the two Node scripts in sgai/ — the
player's moq.url config in section 4 correctly uses https://.
With --auth or --auth-key: add ?jwt=<viewer token> (printed by
stream.sh at startup) to the curl URLs and to --url. Put it before the
#token=... fragment:
--url "http://localhost:4443/?jwt=<viewer token>#token=XYZ789".
Current limitation: this sandbox only covers the server/host delivery side. A player that actually acts on this signaling — subscribing to the events broadcast and switching between the content and ad tracks — is a separate piece of work, not included here.
The relay exposes a plain HTTP API on the same port (TCP) alongside the QUIC stream (UDP). You can use it to inspect what is being served.
# List all active broadcasts
curl http://localhost:4443/announced
# Fetch the catalog for a single-rendition stream
curl -s http://localhost:4443/fetch/bbb.hang/catalog.json | jq
# Fetch the catalog for an --abr-ladder stream
curl -s http://localhost:4443/fetch/bbb.multi.hang/catalog.json | jq
# Fetch the SGAI events catalog (see section 7)
curl -s http://localhost:4443/fetch/bbb-events/catalog | jqThe /announced endpoint is also used by the container startup script to
know when the relay is ready before publishing starts.
With --auth or --auth-key, every call needs a token, or the relay answers
401. Add the viewer token that stream.sh prints:
curl "http://localhost:4443/announced?jwt=<viewer token>"--dashboard serves a web page from the same container as the stream. It
shows live transport and media metrics for the broadcast, plus the
container's own logs. It works with every mode and flag above.
-
Start any stream with
--dashboard:./stream.sh bbb --dashboard
stream.shprints the address before the container starts:Dashboard: http://localhost:8080 (live once the container is up) -
Open
http://localhost:8080in a browser. Use a Chromium-based browser (Chrome, Edge) for the full set of metrics; see "Where the numbers come from" below. There is no certificate warning to click through. -
The status badge goes from Connecting to Waiting for broadcast to Live within a few seconds. Metrics start filling in on the next video group (about 2 seconds).
-
Press
Ctrl+Cin the terminal to stop the stream. The dashboard stops with the container.
Use another port if 8080 is taken:
./stream.sh bbb --abr-ladder --dashboard-port 9000With --auth or --auth-key, the dashboard gets the subscribe-only viewer
token from stream.sh and uses it for you.
- Transport & QUIC: connection state, RTT, packet loss, jitter, and the received bitrate, with charts of bitrate and RTT over the last 2 minutes.
- Media stream: resolution, frame rate, end-to-end latency, and codecs,
with charts of latency and frame rate. On an
--abr-ladderstream, a Rendition selector switches which rung the dashboard subscribes to (it starts on the highest). - Container logs: everything
run-stream.shand the processes it starts print (relay,moq, SSAI/CSAI proxies), live. Scroll up to pause, Follow to resume, and type in Filter to show matching lines only.
Every value has a green (Healthy), amber (Warning), or red (Critical) status. The status is always shown as text too, not only as a color.
The page is a real MoQ subscriber: it opens its own WebTransport session to the relay, as a player would, and measures that session. Nothing is simulated.
| Metric | Source |
|---|---|
| Connection | The session itself: transport (WebTransport, or the WebSocket fallback) and the negotiated protocol version |
| RTT | The browser's QUIC RTT if it reports one; otherwise the RTT the relay measures and sends over the MoQ PROBE stream |
| Packet loss | WebTransport getStats(): packets this browser sent that QUIC declared lost. Shows "—" when the browser has no getStats() |
| Jitter | RFC 3550 inter-arrival jitter over the video frames |
| Bitrate | QUIC bytes received if the browser reports them; otherwise the MoQ payload bytes the page read |
| Resolution, codecs | The broadcast's catalog |
| Frame rate | Video frames received per second. The nominal rate comes from the frame timestamps |
| End-to-end latency | See below |
End-to-end latency is the time from when a frame was due at the source
until it arrives in the browser. The source is ffmpeg -re, which reads the
file at wall-clock pace. A small tap sits just before moq import. For SSAI
and CSAI that's the existing proxies; for the base and SGAI pipelines it's
dashboard/server/ingest-tap.mjs. The tap records that pace, and the browser
compares it with each frame's arrival time. The browser also corrects for
the clock difference between your machine and the container: on macOS, the
podman VM's clock can be hundreds of milliseconds off. The figure includes
muxer buffering, moq import, the relay, and the network. It does not
include decoding or rendering.
Some patterns are expected:
- The base pipeline sits around 300 ms. ffmpeg's mp4 muxer hands frames over in bursts of about half a second.
- An
--abr-ladderencode that is too heavy for your CPU or podman VM shows up as a latency that keeps growing and a frame rate below the nominal one. The stream really is falling behind live; the dashboard just makes it visible.
browser ──WebTransport (UDP 4443)──▶ moq-relay (metrics: the page's own MoQ session)
browser ──HTTP (TCP 8080)──────────▶ dashboard/server (UI, config, logs, ingest clock)
inside the container, when --dashboard is on:
run-stream.sh output ──tee──▶ /tmp/moq-lab.log ──▶ server ──SSE──▶ log view
ffmpeg ─▶ tap/proxy ─▶ moq import (the tap writes /tmp/moq-lab-ingest.json)
stream.sh --dashboardpassesMOQ_LAB_DASHBOARD_PORT(and, with auth,MOQ_LAB_VIEWER_JWT) into the container and publishes the port.- When
run-stream.shsees that variable, it tees its output to a log file, startsdashboard/server/server.mjs, and puts the ingest tap in the pipe. Without--dashboard, none of this runs: the pipeline is exactly what it was before. - The server has no npm dependencies, the same as
ssai/andcsai/. It serves the built UI and three endpoints:/api/config,/api/time(for the clock correction), and/api/events(Server-Sent Events with log lines and ingest-clock samples).
The Containerfile builds the UI in its own stage, so Vite, React, and
node_modules never reach the runtime image:
FROM node:24-slim AS dashboard
WORKDIR /dashboard
COPY dashboard/package.json dashboard/pnpm-lock.yaml dashboard/pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY dashboard/ ./
RUN pnpm build
FROM debian:bookworm-slim
# ...
COPY dashboard/server/ /usr/local/bin/dashboard/server/
COPY --from=dashboard /dashboard/dist/ /usr/local/bin/dashboard/dist/The UI is always built into the image. --dashboard only decides whether it
is served.
Stack: React, Vite, Tailwind CSS, and Chart.js. There is no icon library: states are text labels and colored dots. To get hot reload against a live stream, without rebuilding the image:
./stream.sh bbb --dashboard # terminal 1: a stream and its dashboard server
cd dashboard
pnpm install # once; needs Node.js ^20.19 or >=22.12
pnpm dev # terminal 2: http://localhost:5173pnpm dev sends /api requests to the dashboard server on port 8080. Set
DASHBOARD_API=http://localhost:9000 if you used --dashboard-port 9000.
Changes to the UI reach ./stream.sh --dashboard the next time the image
builds, which stream.sh does on every run.
- With
--sgai-mode, the Ad Decisioning Publisher runs on your host, not in the container. Its logs stay in your terminal, not in the dashboard's log view. - Browsers differ in what WebTransport reports. Packet loss and the
QUIC-level bitrate need
getStats(); without it they show "—" or fall back to the payload bitrate. The tile says which source it is using. RTT falls back to the relay'sPROBEestimate. - Each open dashboard tab is one more subscriber on the relay.
"Podman is not ready"
stream.sh tries to set up the podman machine for you on macOS on first
use — if it still can't get ready (e.g. the machine failed to start), run
podman machine init && podman machine start by hand and check the output.
On Linux, make sure the podman package itself is installed. You can check
readiness at any time with podman info.
"Missing assets/<name>.mp4"
The content file is not where the script expects it. Check that the file is
in assets/ and that the name matches what you typed on the command line (no
.mp4 extension in the command).
"cannot read the source: ..." (with --source)
The container couldn't read the URL. The rest of the message is ffmpeg's
reason, for example Server returned 404 Not Found (wrong URL), no answer within 60s (the server is too slow or blocks the request), or Invalid data found (not an MP4, HLS, or DASH stream). The URL is read from inside the
container, so localhost there is the container itself, not your machine.
Some HLS streams fail with detected format eac3 extension eac3 mismatches allowed extensions. The ffmpeg version in the image (Debian bookworm, 5.1)
refuses segment files with an .ec3 extension, for example in Apple's
bipbop examples. Use another stream.
"the URL contains backslashes" (with --source)
When you paste a URL, zsh can add a \ before ?, =, &, ~, and *.
Inside quotes, the \ stays in the URL. On a signed URL (a CDN token like
?hdnts=...), this makes the server answer 403 Forbidden. Remove the
backslashes and put the URL in single quotes:
./stream.sh --source 'https://example.com/master.m3u8?token=abc&x=1'"has no variant with both a video and an audio track" (with --source)
The stream has no audio, or no video. --source needs both, like a file in
assets/.
The stream stops after a while (with --source)
An on-demand source plays once, then the run ends with Source ended.. A
live source ends the run when the server ends the stream, or when a request
gets no answer for 15 seconds; ffmpeg's reason is printed just before.
"Ad normalization failed; falling back to content-only stream"
The ad file (assets/ad.mp4) exists but ffmpeg could not transcode it.
Check that it is a valid MP4 file with at least one video and one audio
stream. Run ffprobe assets/ad.mp4 locally to inspect it.
The relay starts but the player does not connect
Your browser blocks self-signed certificates on QUIC by default. Open
https://localhost:4443 directly in the browser, accept the certificate
warning, then reload the player page.
401 Unauthorized, or the player can't connect (with --auth/--auth-key)
The request has no token, or the token is wrong. Check that the URL has
?jwt=<token> and that the token is the one stream.sh printed in this
run: with --auth, every run makes a new key, so old tokens stop working.
Viewer tokens also expire after 24 hours. A viewer token can't publish.
connection closed err=authentication is disabled in the log (without --auth)
The player connects with a token (?jwt=... in its URL, often left over from
an earlier --auth run), but this run's relay is public. A public relay
refuses every request that carries a token. The player keeps reconnecting,
so the line repeats. Remove ?jwt=... from the player's URL, or start the
stream with --auth/--auth-key and use the URL that stream.sh prints.
The stream freezes at the ad→content transition (SSAI)
This can happen if the ad file has a very different frame rate or resolution
and the normalization step did not run successfully. Check the Podman logs for
SSAI: normalizing ad... and for any ffmpeg error messages.
--sgai-mode fails with a module-not-found error
stream.sh runs pnpm install for you automatically, but if you're invoking
a script under sgai/ directly (bypassing stream.sh), run pnpm install
yourself first — sgai/*.mjs depends on @moq/net, @moq/msf, ws, and
zod, declared in this repo's own package.json.
Port already in use
Another process is using port 4443. Pass a different port with --port N,
and update the player's moq.url to match.
The dashboard page doesn't load, or shows "Could not reach the dashboard server"
Check that you passed --dashboard and that the port isn't already in use
on your machine (--dashboard-port N picks another one). The server logs
[dashboard] ... serving on port N when it starts. If the page loads but
stays on Connecting, the browser can't reach the relay on UDP/TCP
--port: check that nothing else holds that port.
The first build takes too long
moq and moq-relay are downloaded as prebuilt binaries, not compiled, so
this should only take a few seconds beyond the base image pull. If a build is
taking minutes, check your network connection to GitHub releases rather than
assuming a source compile is happening. Subsequent builds use Podman's
layer cache and are faster still. To force a fresh build: podman rmi moq-lab.
The first build also pulls node:24-slim and installs the dashboard's UI
dependencies (about a minute). After that, this step re-runs only when
something under dashboard/ changes.
Bug reports, ideas, and pull requests are welcome — see CONTRIBUTING.md for how to set up the repo locally, coding conventions, and what to include in a PR.
This sandbox is built entirely on top of
moq-dev/moq — the MoQ (Media over QUIC)
relay and publisher/subscriber CLI (moq-relay, moq-cli, downloaded as
prebuilt binaries in the Containerfile) and the JS libraries (@moq/net,
@moq/msf) sgai/ depends on. moq-lab doesn't reimplement any of the
protocol itself; it wraps that project in a repeatable Podman sandbox and
adds the SSAI/CSAI/SGAI ad-insertion handling around it.
Versions are pinned deliberately, not left floating — both sides move fast
enough (weekly-ish point releases, occasional breaking changes) that an
unpinned binary download or npm install can silently pick up different
behavior on a rebuild. Currently: moq-cli/moq-relay 0.10.0/0.14.5
(Containerfile), @moq/net 0.3.5/@moq/msf 0.2.2
(package.json). Bump the Rust and JS sides together and
re-test rather than upgrading one at a time — see
CONTRIBUTING.md.