vibe-live docs

The stream pipeline

One stream, from the moment an encoder connects to the moment the channel goes offline. The engine-specific parts are in The media engines; this page is the order things happen in.

sequenceDiagram
  participant E as Encoder
  participant G as Engine
  participant A as App
  participant V as Viewer
  E->>G: publish (RTMP, WHIP or a LiveKit token)
  G->>A: is this key valid? (unless the app is the listener)
  A-->>G: yes, and here is the channel id
  G->>A: media is flowing
  Note over A: live: notifications, webhook, posts queued once
  Note over G: WebRTC, HLS, renditions, thumbnail, the recording
  V->>A: GET /api/v1/channels/{id}/playback
  A-->>V: descriptor: transport and streams
  V->>G: WebRTC direct, or HLS through the app's origin
  loop while live
    A->>G: still flowing?
  end
  E-xG: disconnect or Stop Streaming
  Note over A: offline

1. Publishing

A streamer publishes one of three ways:

2. Authorising

The stream key is checked before any media is accepted, and it is turned into the channel id: a key never becomes a path anyone can see or guess (streamkey). Where the app is the listener it checks the key itself; OvenMediaEngine asks the app through a signed webhook on the internal listener; for LiveKit the app authorises and then relays the publish to an ingress created for that one publish. A browser publish uses a one-time token instead of the key.

3. Going live -- when media arrives

A channel is marked live when media is actually flowing, not when the key is accepted: the built-in engine waits for the first decoded keyframe, LiveKit for its track_published event, OvenMediaEngine for its playlist to appear. A publish that sends nothing never goes live, so a stuck encoder never notifies anybody.

Going live opens a live period, which belongs to a stream (internal/store/streams.go, F74): the channel's latest stream if it ended no longer than the stitch window ago (broadcast_stitch_minutes, 15 by default), otherwise a new one. So a drop and a reconnect within the window are one stream, and a longer stop makes the next go-live a second. The rule runs in the store's setSessionState, which every writer of live state goes through. A new stream takes the channel's title and description, and so does a continued one (they differ only when the streamer edited during the drop); between streams they are only the next one's defaults: the API shows a channel's title and description only while it is live.

Going live then fans out, once, as deferred tasks that survive a restart and run once however many replicas there are:

4. Making what viewers watch

From the one incoming stream the engine makes:

Every one of these reaches browsers on the app's own origin (/app/, /hls/, /cast-hls/, /thumb/, /rtc), served from files or proxied to the engine, so nothing in the browser knows which engine runs. Only WebRTC media goes direct, on 10000/udp (or 3478/tcp where UDP is blocked).

5. Playing

The watch page asks GET /api/v1/channels/{id}/playback for a descriptor: which WebRTC transport this instance uses and the list of streams (auto, each rendition, audio only). The player (web/packages/player) tries WebRTC first and falls back to HLS by itself. It plays WebRTC with the transport the descriptor names, from the frontend's registry (transports.ts): the app's own WHEP, or the engine's, whose code comes from that engine's repository; the stream menu switches between the listed streams. Cast hands the Chromecast the plain HLS variant.

Both players are behind one createPlayer: Video.js by default, and OvenPlayer where VIBE_LIVE_PLAYER=ovenplayer asks for it (F90). Each names its icon-only controls in the reader's language (WCAG 4.1.2; OvenPlayer gives them no names, so its adapter does, in ovenplayer-controls.ts). OvenPlayer replaces the element it is given with its own wrapper, keeping the id: anything that needs the player's <video> asks the handle (mediaElement()), never the mount, and a caller that rebuilds a player gives it a fresh mount (the replay page's quality menu does).

A stream from another instance is played the same way, with that instance's descriptor, fetched for the viewer by this instance.

6. Staying live, and ending

While live, the stream can also be pushed onward: to a peer that asked to relay it (F86a), and to the streamer's linked Twitch and YouTube (F91). Both are the engine's RelayOutput; a platform's push goes through the app's forwarder, which publishes it over RTMPS with internal/rtmp's client (DialTLS, the platform's name verified) and writes down what reached the platform for the Stream Now page.

While live, a sweep asks the engine whether media is still flowing. If the engine cannot be asked, that is treated as "unknown" and bounded in time rather than guessed either way. The channel goes offline when the publisher disconnects, when the engine reports it gone, when the streamer presses Stop Streaming, or when a force-stop ends a session left behind by a device that disappeared.

7. Keeping it: past broadcasts

Where the admin allows past broadcasts and the streamer opted in when the stream started, the recording viewers seek through is kept rather than trimmed (internal/media.Recordings, docs/design/recording.md). With several replicas, the one holding the channel's recording claim (recording_claims, renewed every 10 s, lapsing after 30) is the one that records; the others serve what it writes on the shared volume:

flowchart LR
  L["live: the engine's recorder<br/>writes source.m3u8"] --> K{"kept?<br/>admin allows and<br/>streamer opted in"}
  K -->|no| T["trimmed to the window,<br/>deleted when it ends"]
  K -->|yes| C["closed when it ends:<br/>playlist.m3u8, a VOD"]
  C --> B["broadcasts row: listed,<br/>unlisted or hidden"]
  B --> S["the sweep: retention, the disk<br/>and per-streamer limits,<br/>oldest first"]

The engine only writes the recording -- the built-in engine's stream copy, LiveKit's Egress, OvenMediaEngine's HLS collected by the app. Booking it, closing it into a replay, serving it at /vod/<id>/, hiding it from everyone but its streamer and admins, and deleting what the limits no longer allow are the app's, the same for every engine. A restart mid-stream closes what was recorded instead of losing it, and a directory nothing owns -- what a recorder wrote after it could not be told to stop, as an Egress can when livekit-server is down -- is swept once it has been quiet ten minutes (Recordings.SweepStrays, every minute). A past broadcast is a stream that was kept (F74): it names its stream, and when the stream continues across a drop (section 3), the recorder asks the store for that stream's closed broadcast and continues it as its next part (part2/..., the number read from the disk), with a discontinuity at the join -- so a restart, or another replica, between the two periods still continues it.

Each close also takes the broadcast's poster (F76): where the server has an ffmpeg (every image ships one), Recordings.Poster grabs one frame a tenth of the way in, at most a minute (media.PosterAt), scaled to 360 lines, and writes it as poster.jpg beside the replay. The frame is the first of the first segment starting at or after that moment (media.PosterFrame): a keyframe, read alone, and never past a segment's real end -- OvenMediaEngine's collected segments hold less video than the 4 s they are listed at. A continued broadcast's next close takes it again from the whole replay. The API names it in every broadcast (thumbnail); one kept before posters existed answers 404 there, and the list drops the picture (data-fallback="remove"). A failed grab is logged and the replay is ready all the same.

Where a kept broadcast goes is the admin's choice (storage.go): the host's disk, the instance's bucket, or -- with streamers allowed their own -- each streamer's bucket, uploaded by a durable task (broadcast-upload) as soon as it closes. A replay no longer on this server is served from /vod/<id>/playlist.m3u8 as its kept playlist with each segment a signed link into the private bucket (internal/s3, Signature Version 4, written here rather than an SDK). The host's limits drop only the host's copy of what a streamer's bucket holds. The poster stays on the host either way: a few dozen kilobytes every list asks for.

Diagrams drawn by Mermaid (MIT license).