Skip to content
pzanellaPublic

About

Local Media over QUIC (MoQ) streaming sandbox with SSAI, CSAI, and SGAI ad-insertion modes.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

moq-lab

CI License: MIT

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/).

Contents

  1. Project layout
  2. How it works
  3. Requirements
  4. Add your video files
  5. Run it
  6. What you get
  7. SSAI: ad insertion and impression tracking
  8. CSAI: SCTE-35 signaling
  9. SGAI: Event Timeline signaling
  10. Relay HTTP API
  11. Dashboard: real-time monitoring
  12. Troubleshooting
  13. Contributing
  14. Acknowledgments

Project layout

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.


How it works

  • Containerfile builds an image with ffmpeg, moq (the moq-cli publisher/subscriber binary), and moq-relay pre-installed — as prebuilt Linux binaries downloaded from moq-dev/moq's GitHub releases, not compiled from source, so the build is fast.
  • run-stream.sh runs inside the container: it transcodes your video, starts the relay, and feeds the stream into moq as the publisher.
  • stream.sh is 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.


1. Requirements

  • 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 a podman machine — stream.sh runs podman machine init/start for 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.sh are 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-mode only: Node.js >=20 (its host-side signaling script runs outside Podman). stream.sh runs pnpm install for you automatically when you pass --sgai-mode (a no-op in well under a second once already up to date) — this also applies patches/@moq__msf.patch, a one-line fix for a missing .js extension in @moq/msf's own ESM export map (an upstream packaging bug, not something this repo's code needs). pnpm itself ships with Node.js >=16.9 via corepack, which stream.sh also enables automatically if needed.

2. Add your video files

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.


3. Run it

All commands below are run from the repo root.

Basic stream (no ads)

# 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 4444

Stream from a URL (MP4, HLS, DASH)

Instead 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-ladder

With --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-ladder to 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:// and https:// URLs are accepted, and ffmpeg may only use network protocols (http, https, tcp, tls, crypto for 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.

SSAI: Server-Side Ad Insertion

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 45

CSAI: SCTE-35 signaling

No 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-ladder

SGAI: Event Timeline signaling

Requires 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-ladder

To 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.

Access control (JWT)

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.jwk

At 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.sh also 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 token command 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

All flags at a glance

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)

npm/pnpm shortcut

pnpm stream                  # same as: ./stream.sh bbb

4. What you get

Stream URL

https://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).

Broadcast names

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.


5. SSAI: ad insertion and impression tracking

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.


6. CSAI: SCTE-35 signaling

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 as ssai/impression-tracker.mjs's tfdt reads), it splices in a real, CRC-valid SCTE-35 splice_info_section — a time_signal command with a segmentation_descriptor for Break Start (0x22) or Break End (0x23) — wrapped in a PES packet, on that PID. The descriptor signals segmentation_duration (--ad-break-length) on both cues, so a subscriber acting only on Break Start already knows the avail's length. The encoder lives in csai/scte35.mjs. Because this reads real timestamps rather than the wall clock, cues stay accurate even if ffmpeg falls behind real-time under load (e.g. a heavy --abr-ladder encode).
  • 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 ts builds 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

Regional blackout

--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)

MoQ-native CSAI (architectural idea, not implemented)

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.

A note on SCTE 35-2

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.


7. SGAI: Event Timeline signaling

--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.

What runs where

  • 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.mjs connects to the relay as a third, independent MoQ publisher and emits org.scte.scte35.v1-shaped Event Timeline JSON records (built by sgai/event-timeline.mjs) on a broadcast named <name>-events, on a --ad-break-every-cadence schedule (ad break length is always the real duration of assets/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's catalog track is a real @moq/msf (draft-ietf-moq-msf-01) catalog with two tracks: events (packaging: "eventtimeline") and mediatime (packaging: "mediatimeline", see below) — not hand-rolled JSON. events declares depends: ["mediatime"], a real co-published track. At the start of every ad break, this same script also podman execs a fresh, single-shot ffmpeg | moq import fmp4 of 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's segmentation_upid_uri field. sgai/transport.mjs holds the Node-specific relay connection workarounds (no native WebTransport, self-signed cert, Promise.withResolvers polyfill) 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.

Media Timeline

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.

Regional blackout

--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 15

MOQ URL format

Every 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=...).

Ad personalization (%token% substitution)

--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.

Inspect it in isolation

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.hang

The 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.


8. Relay HTTP API

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 | jq

The /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>"

9. Dashboard: real-time monitoring

--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.

Step by step

  1. Start any stream with --dashboard:

    ./stream.sh bbb --dashboard

    stream.sh prints the address before the container starts:

    Dashboard: http://localhost:8080 (live once the container is up)
    
  2. Open http://localhost:8080 in 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.

  3. 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).

  4. Press Ctrl+C in 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 9000

With --auth or --auth-key, the dashboard gets the subscribe-only viewer token from stream.sh and uses it for you.

What you see

  • 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-ladder stream, a Rendition selector switches which rung the dashboard subscribes to (it starts on the highest).
  • Container logs: everything run-stream.sh and 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.

Where the numbers come from

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-ladder encode 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.

How it fits together

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 --dashboard passes MOQ_LAB_DASHBOARD_PORT (and, with auth, MOQ_LAB_VIEWER_JWT) into the container and publishes the port.
  • When run-stream.sh sees that variable, it tees its output to a log file, starts dashboard/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/ and csai/. 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.

Working on the UI

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:5173

pnpm 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.

Limitations

  • 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's PROBE estimate.
  • Each open dashboard tab is one more subscriber on the relay.

10. Troubleshooting

"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.


Contributing

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.


Acknowledgments

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.

About

Local Media over QUIC (MoQ) streaming sandbox with SSAI, CSAI, and SGAI ad-insertion modes.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages