Skip to main content
The video relay takes an RTSP stream (from a camera or from mediamtx on the agent) and re-wraps it into fragmented MP4 (fMP4) over a WebSocket. A browser plays it through Media Source Extensions (MSE). ffmpeg copies the H.264 stream (-c:v copy) and only changes the container, so the relay adds little load or latency. It lives in ADOSMissionControl under tools/video-relay/, GPL-3.0.

How it works

  • Each device gets one ffmpeg process, started on the first viewer and stopped when the last viewer leaves. An idle relay runs no ffmpeg.
  • The relay caches the stream’s init segment and replays it to a viewer that joins a running session.
  • A viewer whose send backlog exceeds VIDEO_RELAY_MAX_BACKLOG is closed with code 1013 and reconnects.
  • The GCS player reads the codec from the stream’s init segment, so H.264 profiles other than High play without changes.

Viewer tokens

Every WebSocket upgrade needs a token scoped to the device in the path. Without a valid one the relay answers 401 before any ffmpeg process starts.
  • Convex mints tokens in the cmdVideoRelayTokens.mint action. It checks that the signed-in operator owns the paired device, then signs a token with VIDEO_RELAY_SECRET.
  • A token has the form <exp>.<mac>: the expiry in Unix seconds and a hex HMAC-SHA256 of <deviceId>|<exp>. It lasts five minutes. The relay refuses tokens that are expired, dated more than ten minutes ahead, or scoped to another device.
  • The token is checked once at upgrade. An open stream keeps running after the token expires.
  • Pass it as ?token=<token> (a browser WebSocket cannot set headers) or as Authorization: Bearer <token>.
The relay and Convex must hold the same VIDEO_RELAY_SECRET.

Configuration

string
required
The shared secret viewer tokens are verified with. Generate with openssl rand -hex 32 and set the same value on Convex with npx convex env set VIDEO_RELAY_SECRET "<value>" --url <client-api-origin> --admin-key <admin-key>. The relay exits at start while it is empty.
number
default:"3001"
The HTTP and WebSocket listen port.
string
default:"rtsp://localhost:8554/{deviceId}"
The RTSP source template. {deviceId} is replaced with the device ID from the WebSocket path. The default works when mediamtx runs on the same host. The compose files default to rtsp://host.docker.internal:8554/{deviceId} so a container can reach an RTSP server on the Docker host.
number
default:"4194304"
Bytes queued for one slow viewer before it is dropped.

Endpoints

  • ws://<host>:3001/ws/stream/{deviceId}?token=<viewer token> opens a stream. The device ID must match [a-zA-Z0-9_-]+; anything else gets 400.
  • A plain GET on any path is a liveness probe and returns only:
The probe carries no session data. Session starts, viewer counts and ffmpeg exits appear in the relay’s stdout log (docker compose logs video-relay).

Run locally

Deploy with Docker

The all-in-one stack includes the relay. To run it alone, use the compose file in tools/video-relay/deploy/.
1

Copy the env template

2

Fill in the env file

Compose refuses to start while VIDEO_RELAY_SECRET is empty.
3

Bring it up

4

Tell Convex where the relay is

The compose file runs two containers:
  1. video-relay: the Node.js relay with ffmpeg.
  2. cloudflared: a tunnel that exposes the relay on a public hostname. WebSocket upgrades pass through it with no extra configuration.
A GCS served over HTTPS needs the relay on wss://. The tunnel is one way to get that; any HTTPS reverse proxy that forwards WebSocket upgrades works too.

Browser client

A minimal MSE viewer. Fetch a viewer token from Convex first, then open the stream:
mint returns { status: "ok", token, expiresAt }, or { status: "not-configured" } when Convex has no VIDEO_RELAY_SECRET. In production, queue frames and append only while the buffer is idle, and prune the buffer as it grows. The GCS player in src/lib/video/mse-player.ts (codec parsing in src/lib/video/fmp4-codec.ts) is a full reference with queueing, reconnect and buffer management.

Next steps

Ports and troubleshooting

The port table and the common failure modes.

All-in-one stack

Run the relay with the rest of the stack on one host.
Last modified on October 11, 2026