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:
- RTMP from OBS or any encoder, to port 1936. On the built-in and
LiveKit engines the app itself listens there (
internal/ingest,internal/rtmp); with OvenMediaEngine, OME does. - WHIP -- WebRTC publishing -- from OBS 30+, or from the browser's Go Live, through the app's own origin.
- A LiveKit room token, for the browser on the LiveKit engine.
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:
- browser notifications to the channel's followers;
- the streamer's webhook (Discord and the like);
- the streamer's Fediverse post, from their linked account;
- an ActivityPub
Createof aNoteto everyone following the channel from the fediverse.
4. Making what viewers watch
From the one incoming stream the engine makes:
- WebRTC for sub-second playback in the browser;
- HLS (low-latency where the engine can) as the fallback for any network, and a plain HLS variant for Chromecast;
- the instance's renditions (
VIBE_LIVE_RENDITIONS): smaller sizes and audio only, never larger than the source. The built-in engine and LiveKit make each one only while somebody watches it; - a thumbnail for the dashboard cards, refreshed while live;
- a recording of the stream, so viewers can seek through it from
its start while it is live (on unless the admin turns it off): the
source as it arrives -- copied by ffmpeg, re-encoded by an Egress, or
OvenMediaEngine's HLS collected by the app -- served under
/vod/, with a seek playlist the app writes at/vod/_live/<channel>/dvr.m3u8. The descriptor'sseektells the player where (internal/media.Recordings,docs/design/recording.md).
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).