vibe-live docs

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).

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).