Architecture
Hosts, clients, media authentication, hooks, reconciliation, snapshots, and OBS control.
VISP is a control plane wrapped around MediaMTX. The relay moves media without transcoding it; the app decides who may connect and tracks what is live.
Hosts and clients
| Piece | Runs | Responsibility |
|---|---|---|
| Relay hosts | MediaMTX, Caddy, snapshot hook | SRT/RTMP/WebRTC ingest and read, HTTPS RTT probe, per-path JPEG snapshots |
| Managed compositor | Bun worker, Chromium, FFmpeg | Pulls the last saved Cloud Studio graph and publishes a program feed for Direct |
| App host | PostgreSQL, Elysia/tRPC API, portal, admin console | Twitch, Kick, and Google login, support operations, path and credential state, MediaMTX authentication, OBS control |
| Native app | iOS and Android | SRT camera publishing, chat, stream metadata, OBS controls, Watch companion |
| Browser app | stream.visp-stream.com | WebRTC (WHIP) camera publishing from a browser |
| OBS Remote | remote.visp-stream.com (also iOS/Android) | Dedicated stream and scene control surface using the broadcaster's VISP account |
| OBS plugin | Broadcaster's OBS Studio 31 | Device management, Media Source setup, and outbound-only remote control |
The hosts talk over Tailscale for administration and each MediaMTX Control API. Media uses the assigned relay's public SRT (UDP 8890), RTMP (TCP 1935), and WebRTC (UDP/TCP 8189) ports.
Media authentication
MediaMTX asks the app before every new connection by calling
POST /api/mediamtx/auth:
- Localhost RTSP reads are allowed without credentials — this is the snapshot hook reading its own relay.
- Everything else must present a username and password.
- Allowed combinations are SRT publish/read, RTMP publish/read, and WebRTC publish. Other protocols and actions are rejected.
- For Direct publishes, the app atomically reserves relay encoder capacity.
- The app verifies the credential against the matching Argon2id hash and answers allow or deny.
Because the app sits only on connection establishment, an app outage leaves established streams running while new publish and read attempts fail authentication. This is the accepted failure mode.
Credential model
- Each publishing device owns an independently revocable publish URL. It is stored twice: encrypted for authenticated re-reveal in the portal, and as an Argon2id hash for relay authentication. Rotating one device never interrupts the others.
- The read credential is account-wide. Like publish credentials, its secret is encrypted for authenticated re-reveal and stored as an Argon2id hash for MediaMTX authentication. Revealing it can regenerate OBS URLs and scene collections; rotating it invalidates every existing OBS Media Source.
- A path always belongs to exactly one user and one capacity-managed relay. MediaMTX accepts one publisher per path: the first connection wins until it disconnects. Per-user active-path quotas bound provisioning abuse.
Path state and reconciliation
MediaMTX reports lifecycle changes to the app through hook endpoints
(POST /api/hooks/ready, not-ready, read, unread), each authenticated
with the shared x-hook-secret. The app updates path state from these events.
Hooks can be lost, so one elected app instance reconciles against every enabled relay's MediaMTX Control API over Tailscale every 10 seconds. A failed relay is skipped without changing its stored live state; healthy relays still converge.
Snapshots
While a path is live, the relay captures a frame roughly once a minute:
- The snapshot hook asks the app for an upload target via
POST /api/hooks/snapshot-upload/:path(hook-secret protected). - The app answers with a 60-second presigned S3 PUT URL for
snapshots/{pathId}.jpg— S3 access keys never leave the app host. ffmpegreads the localhost RTSP mirror and overwrites the object.
A bucket lifecycle rule expires objects after one day, so stopped paths disappear from storage without any cleanup job. The portal's OBS remote-control card shows the tiles for live paths only.
OBS remote control
New plugin connections use Better Auth's device-authorization flow. The user approves OBS in the portal, OBS exchanges the temporary session for a scoped machine token, and the server immediately revokes that temporary session. The same machine token authenticates device listing, creation, Media Source setup, and a short-lived live-socket ticket; it does not grant general account API access.
The plugin obtains a 30-second, one-use ticket over authenticated HTTPS, then
opens outbound WSS /api/obs/live. The long-lived id.secret machine token is
never placed in the WebSocket URL. The plugin reports applied command version,
streaming state, scenes, and the active scene on changes and five-second
heartbeats. The server pushes the latest desired state immediately. OBS Remote
uses its normal VISP session to obtain a separate user ticket, receives status
over WSS, and sends commands through authenticated tRPC mutations.
A pairing counts as Connected while its last state report is under ten seconds old. Rotating the machine token invalidates existing tickets and socket writes. Both clients reconnect with bounded backoff; no inbound OBS port opens. Cross-instance OBS status and commands remain database-poll backed, so they can propagate more slowly than same-instance WebSocket updates.
Chat and metadata
Twitch chat uses the signed-in broadcaster's account. Kick chat is delivered by
Kick's chat.message.sent webhook to POST /api/webhooks/kick, verified with
Kick's RSA signature, a timestamp window, and replay detection. YouTube chat is
read from the authenticated channel's active broadcast and follows YouTube's
server-provided polling interval. All three are fanned out to browser and native
clients over the same WebSocket. The native app can edit Twitch and Kick stream
metadata separately.
Boundaries
VISP does not bill users or host OBS. Home Studio remains a bring-your-own-OBS path; VISP never runs an OBS instance for the broadcaster. It assigns paths across multiple relays but does not move an existing path automatically. For the relay-to-OBS path the assigned relay forwards the H.264/AAC (or WebRTC H.264/Opus) feed exactly as published — no transcode, no provider stream key.
VISP Direct is the one exception and the default Twitch/Kick/YouTube output. When a device owns a Direct destination, the relay runs a full distribution encode per destination (H.264 + AAC stereo, never a stream copy). At stream start it retrieves a Twitch or Kick key through OAuth consent, or creates a public YouTube broadcast and obtains its RTMPS ingest destination. The destination is passed to the forwarding process and never returned to a client app or stored as a separate database value. What OBS reads stays the contribution feed, not the platform encode.
Cloud Studio is a separate Direct production mode. Its managed compositor pulls only the last explicitly saved scene graph and publishes the composited program to the local relay. If the rollout flag, kill switch, entitlement, or compositor health check is false, Direct selects the raw contribution feed instead. Existing accounts stay in OBS mode until they opt in; the cloud default for new accounts is independently rollout-gated.