How the media engines work
This is what the code does today. Why it is shaped this way -- the
decisions, the alternatives and the measurements -- is
docs/design/engines-and-players.md,
which was written before the work and is not rewritten after it. Read this
first; go there when a decision looks wrong.
One engine per deployment, chosen at startup
The app is a shell around a media engine, and it has three:
| Engine | Media is carried by | Image |
|---|---|---|
builtin |
this app: its own RTMP listener, ffmpeg, and optionally its own WebRTC server | all-in-one-ffmpeg |
ome |
OvenMediaEngine, supervised beside the app in one container | all-in-one (the unqualified tag) |
livekit |
LiveKit server, Ingress and Egress | all-in-one-livekit |
VIBE_LIVE_ENGINE names it, or engines.Choose
(internal/engines/registry.go) infers it from what is configured: the
first delegate in the registry's order that is configured (LiveKit, then
OvenMediaEngine), else the built-in engine when ffmpeg is on the PATH,
else OvenMediaEngine, which every deployment was before there was a
choice. Explicit refuses a second engine configured beside it.
Each delegate joins by its own registration (engineapi.Registration,
F78): its repository says which variables configure it, which it requires,
how it loads and refuses them, the stages it fills, and its subcommands
(vibe-live ome-vhost is OvenMediaEngine's), and its WebRTC transport:
the name a viewer is handed, and how a peer's descriptor for it is vetted.
The registry is the list of them; cmd/vibe-live reports what the chosen one requires and refuses in
the start report, loads it, and hands the server its definition. An engine
with no HLS of its own is handed another's -- LiveKit without Egress has
always delivered OvenMediaEngine's.
An engine tests its seam in its own repository, against the app version
it requires: enginetest starts the real app with the engine in it, and
drives it over HTTP -- its events, its playback, force-stop, the sweep, an
encoder's publish through the app's ingest. Nothing else in this module
imports an engine; internal/engines' boundary test fails the build when
something does. Swapping engines is
a restart, never a switch under a live stream: the engine holds the
publisher's connection and every viewer's session.
An engine is four strategies
The rest of the server reaches an engine only through mediaEngine
(internal/server/engine.go), and every engine is the same struct,
composedEngine (engine_compose.go), around an engineapi.Definition
holding one strategy per stage:
flowchart LR
subgraph composedEngine
O[origin<br/>is it live? end it. its webhooks]
B[browser ingress<br/>how a browser publishes]
H[HLS delivery<br/>HLS, Cast, thumbnails]
W[WebRTC delivery<br/>sub-second playback]
end
S[rest of internal/server] --> composedEngine
The three definitions say which strategy fills each stage. The
strategies' interfaces are public, in engineapi (F68,
docs/design/engines-as-modules.md).
engineapi and the two delegate engines are Go modules of their own.
Each delegate lives in its own repository:
vibe-live-engine-ome
and vibe-live-engine-livekit.
Each carries its config, its s6 services and the image tiers the
all-in-one images build on, is required by version, and is held to the
same gates in its own CI. The Dockerfile's OME_BASE and LIVEKIT_BASE
name the same versions as go.mod (engine_tier_test.go). engineapi/
is in this repository, replaced by its directory, and tagged
engineapi/vX.Y.Z for the engines to require. Because ./... never crosses into a nested
module, scripts/go-all.sh runs build, vet, test, lint and govulncheck
in every one.
That package also holds what a definition may ask of the app: Host (its
logger, public URL, the WHIP credential, viewer counting, directory media,
the renditions selection) and Lifecycle (admission's lookups and the
live/ended transitions, handed to the origin's internal routes). The two
delegates are written against it alone, in their own packages, so the
compiler keeps them off the server. The built-in engine is the app's own
and stays in internal/server:
| Stage | ome.Definition (vibe-live-engine-ome) |
livekit.Definition (vibe-live-engine-livekit) |
builtinDefinition (engine_builtin.go) |
|---|---|---|---|
| origin | OME's stream: LL-HLS manifest probe, REST force-stop, admission webhook | the LiveKit room: its webhooks and RoomService | the app's own fan-out, in process |
| browser ingress | OME's WHIP, through /app/ |
a LiveKit room token | the app's WHIP (internal/sfu), or none |
| HLS delivery | OME's LL-HLS, proxied at /hls/, /cast-hls/, /thumb/ |
the media directory Egress writes, or OME's borrowed | the media directory ffmpeg writes |
| WebRTC delivery | OME's signalling, proxied at /app/ |
the room, signalling proxied at /rtc |
the app's WHEP (internal/sfu), or none |
Which engine runs is the registry's (internal/engines, F68 step 2a).
cmd/vibe-live reads each delegate's configuration into engines.Config,
and engines.Select returns the one function the server is handed,
Config.Delegate, which makes a definition from the server's Host. The
server's own code imports no delegate and not the registry, and
TestOnlyTheSeamImportsEnginePackages says so. It chooses between the
built-in engine, which is its own, and that function. LiveKit's HLS stage
is composed in the registry too: engineapi.DirectoryHLS when Egress
writes the media directory, else OvenMediaEngine's, borrowed. So neither
engine package imports the other.
TestTheDefinitionsTableMatchesTheCode holds the design document's copy
of this table to the code.
Every browser-facing URL is the app's. Whatever the engine, a browser
fetches /hls/, /cast-hls/, /thumb/, /app/ and /rtc on the site's
own origin; the app serves them from files or proxies them to the engine
on loopback. So the frontend, Chromecast and the dashboard never learn
which engine runs, and no engine port needs a hostname. What cannot be
proxied is WebRTC media, which is not HTTP: every engine uses
10000/udp for it and 3478/tcp for networks that block UDP, so the
five ports an operator forwards are the same for all three (80, 443, 1936
and those two).
Below the seam: workflows the app owns, adapters the engine supplies
Rule 4 of the design: a missing capability is filled by one workflow in
the app with a narrow adapter beneath it, never an engine-specific path.
EngineConfig (engine.go) is where a definition plugs its adapters in,
from the engineapi.Stages it returns:
| Slot | Workflow | Filled by |
|---|---|---|
IngestSink |
internal/ingest -- RTMP on 1936, the stream key authorised, the channel claimed |
builtin: ffmpeg. LiveKit: a relay to Ingress (internal/handoff). OME: nil, it takes 1936 itself |
RenditionProducer |
internal/media -- a rendition runs while somebody watches it |
builtin: ffmpeg. LiveKit: Egress. OME: its own config (omeconf) |
ThumbnailProducer |
internal/media -- the always-on thumbnail of a live channel |
LiveKit: image-only Egress. Others make thumbnails unasked |
MediaDir |
internal/media.Dir -- serves the files at the URLs above |
builtin and LiveKit Egress write it; OME serves its own |
SourceRecorder |
internal/media.Recordings -- one recording per live stream, made by whichever replica holds the channel's recording claim; it is what viewers seek through while live (the whole stream, or a capped window (dvr.m3u8), and a kept broadcast is the whole of it (docs/design/recording.md) |
builtin: a stream copy off the publish's fan-out. LiveKit: a participant Egress whose EVENT playlist is the recording (SourceEgress, one encode). OME: media.HLSCollector copying its plain HLS's best variant (ome.SourceRecorder), for keeping only -- OME's window is native |
RelayOutput |
internal/server/relay.go -- a channel pushed to a peer that asked to relay it (F86): one stream per peer, started on whichever replica holds the publish |
builtin: a stream copy off the publish's fan-out (ffmpeg.RelayPush), pushed again after 5 s (doubling to a minute) when it stops while the stream is live. OME: its push publishing through the REST API, where that is configured (vibe-live-engine-ome, relay.go), one push per stream and destination whichever replica asks. LiveKit: a participant Egress with an RTMP stream output, where Egress is configured (vibe-live-engine-livekit, relayegress.go) -- one encode, at the size a recording is. Where none is set, a peer asking is told 501. A multistream push (F91) is the same call, aimed at the app's own forwarder (internal/server/forward.go, rtmp://127.0.0.1:1937/forward/<key> by default), which re-publishes it to the platform over RTMPS and reports whether the platform took it -- OME has no RTMPS, so every engine goes through it; the built-in engine pushes an rtmps:// relay with the app's RTMP client (tlspush.go) where one is aimed straight at a platform, its ffmpeg having no TLS |
FFmpeg |
each kept broadcast's poster, taken when it closes (F76): one frame of one replay segment (media.PosterFrame, Recordings.Poster) |
every image: the app's own ffmpeg, the same on every engine; empty takes none |
DVRWindow |
an optional cap on seeking through a live stream (VIBE_LIVE_DVR_WINDOW); the descriptor's seek is offered for a stream whose recording writes its seek playlist (Recordings.Seeking), at /vod/_live/<channel>/dvr.m3u8, on every engine |
UseBuiltin and UseLiveKit (livekit.Stages) fill the slots; main.go only reads the
environment. The boundary is tested mechanically: internal/ingest,
internal/media and internal/handoff must never import an engine
(TestIngestNeverImportsAnEngine, TestMediaNeverImportsAnEngine,
TestNoGenericWorkflowImportsAnEngine), and inside internal/server only
the seam files may (TestOnlyTheSeamImportsEnginePackages).
The built-in engine's pieces, bottom up: internal/rtmp (the listener and
the relay client), internal/ffmpeg (the one place ffmpeg is started and
supervised), internal/feed (a stream as WebRTC tracks) and internal/sfu
(WHEP and WHIP on one UDP and one TCP port).
Going live, per engine
"Live" means media is flowing, not that a key was accepted (B17).
- OvenMediaEngine asks the app first: its admission webhook, HMAC-signed,
on the internal listener (8081, never public). The app answers with the
channel id in place of the stream key, so a key never becomes a public
path (
streamkey). The channel is confirmed live when OME's LL-HLS manifest appears. - LiveKit: the app's RTMP listener authorises the key and relays to an
ingress made for that one publish. The channel is live on the room's
track_publishedwebhook. - Built-in: the app is the listener. Live when the thumbnail process has decoded a keyframe.
A sweep then asks the origin (PublishingState) whether each live channel
still is. "Could not ask" is bounded in time rather than read as either
answer (B10).
What a viewer is handed
GET /api/v1/channels/{id}/playback answers a descriptor: the stream
list (auto, each rendition, audio, WebRTC) and, for WebRTC, a transport --
ome, livekit (with a URL and a ten-minute subscribe-only token), whep,
or none. The frontend's player (web/packages/player) builds a source
from that transport and never branches on an engine name: each engine's
browser code (its WebRTC transport and, for LiveKit, its browser publish
session) is a package from that engine's repository, required by the same
version as its Go module and listed in the frontend's two registries,
web/packages/player/src/transports.ts and web/src/publish-transports.ts.
internal/engines' frontend tests hold those to the backend registry, and
fail on an engine name spelled anywhere else in the frontend. The contract's
enums of transports and engines are generated from the registry
(enum:"@transports", enum:"@engines"), so a new engine's name reaches
the TypeScript types without an edit to payloads.go. For a stream on
another instance, /api/v1/remote/{peer}/{channel}/playback asks that peer
the same question server to server; see federation.md.
Where to change what
| To change | Start at |
|---|---|
| what an engine is made of | its definition in internal/server/engine_<name>.go |
| a question every engine answers | mediaEngine in engine.go, then each strategy |
| publishing over RTMP | internal/ingest, then the engine's IngestSink |
| renditions or thumbnails | internal/media, then the producer adapter |
| the built-in WebRTC server | internal/sfu, internal/feed |
| OvenMediaEngine's own config | vibe-live-engine-ome/deploy/ome/, vibe-live-engine-ome/omeconf |
Diagrams drawn by Mermaid (MIT license).