-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_BACKLOGis closed with code1013and 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 answers401 before any ffmpeg process starts.
- Convex mints tokens in the
cmdVideoRelayTokens.mintaction. It checks that the signed-in operator owns the paired device, then signs a token withVIDEO_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 browserWebSocketcannot set headers) or asAuthorization: Bearer <token>.
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 gets400. -
A plain
GETon any path is a liveness probe and returns only:
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 intools/video-relay/deploy/.
1
Copy the env template
2
Fill in the env file
VIDEO_RELAY_SECRET is empty.3
Bring it up
4
Tell Convex where the relay is
- video-relay: the Node.js relay with ffmpeg.
- cloudflared: a tunnel that exposes the relay on a public hostname. WebSocket upgrades pass through it with no extra configuration.
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.