You have a LiveKit room — a multiplayer WebRTC session with several participants — and you want it on YouTube Live, Twitch, or any RTMP ingest. The catch: WebRTC and RTMP speak completely different protocols. You can't just "forward" the room; you have to composite the participants into one video and mix their audio into one track, then encode and push that single stream out over RTMP.
There are two familiar paths. The DIY one is a GStreamer/FFmpeg box glued to your SFU — you own GPU encoding, layout, reconnects, and scaling forever. The platform-native one is LiveKit Egress. There's a third path that's worth knowing about: a declarative, managed pipeline — you describe sources, mixers, and sinks in one JSON job, submit it to AVFlow, and the pipeline runs in the cloud.
(Adapted from the official guide: Mix LiveKit to RTMP.)
The obvious question: why not just LiveKit Egress?
Fair question — if you're fully on LiveKit Cloud, Egress is the default answer and a good one. But its trade-offs show up quickly once you self-host, want more than one output, or step outside the LiveKit ecosystem.
| Egress self-hosted | Egress on LiveKit Cloud | AVFlow pipeline | |
|---|---|---|---|
| What you operate | The egress service itself: composite workers rendering web templates, Redis, restarts, capacity | Nothing — but LiveKit-only | Nothing. An HTTP API; works with any LiveKit deployment, cloud or self-hosted |
| Outputs per request | One request = one output. Streaming and recording means separate egress instances doing the work twice | Same |
One JSON job fans out: RTMP to multiple CDN urls at once, or RTMP + HLS-to-S3 recording + more sinks in the same job |
| Beyond LiveKit | — | — | The same pipeline also accepts Agora / Daily / Jitsi rooms, plus RTSP, SRT, WHEP, and file sources |
| Layout | Preset picked at request start; deeper customization via web templates | Same | Grid / speaker / custom regions as plain config — and switchable mid-stream via a job update |
| Processing | — | — | Pipeline nodes: e.g. ASR captions burned into the same stream, or translate |
| Billing | Your infra + your ops time | Per-minute on Cloud | Per-minute of runtime, no monthly seats |
Three of those rows deserve a closer look:
Fan-out in one job. Egress is one output per request, so "stream to YouTube and record an HLS archive to S3" is two egress requests and two composites. In the pipeline model, one job wires the same mixers into both an rtmp_push sink (with an array of CDN URLs) and a segment sink for the archive. One composite, every output.
No egress infrastructure, even for self-hosted LiveKit. If you run your own LiveKit server, the Egress route means deploying and babysitting another stateful service. AVFlow never touches your deployment — it joins as a subscriber with a subscribe token, exactly like any other client. Your SFU stays untouched.
It's a pipeline, not a recorder. Because the job is a DAG, you can insert nodes between the room and the output: wire an asr node into the same RTMP stream and your YouTube broadcast carries live captions; add translate for multilingual subtitles. And if you later move a project from LiveKit to Agora or Daily, the job shape stays the same — you swap the source type, not the architecture.
Layouts that change mid-broadcast. Egress picks its layout preset when the request starts; changing it means tearing that egress down and starting another. A running AVFlow job is updatable in place: POST /v1/jobs again with the same name, and the diff applies without restarting the pipeline. Switch the video_mixer from grid to speaker the moment your host starts presenting, or wire a second room into the mix — while the RTMP stream stays up.
The short version: Egress is the right default when you're all-in on LiveKit Cloud and need a single output. A managed pipeline wins when you want zero egress ops, one job that streams + records + captions simultaneously, or one API across multiple RTC platforms.
Why a room needs a mixer, not a relay
An RTC room is many independent tracks: one video track per publisher, one audio track per speaker. An RTMP ingest expects exactly one video stream and one audio stream. So the pipeline must:
- Subscribe to every track in the room (a subscribe token, not a publish token).
- Compose the videos into a single canvas — a grid layout is the common choice.
- Mix all audio into a single track.
- Encode H.264 + AAC and push to the RTMP endpoint.
Skipping the mixers and wiring the room straight into an RTMP sink is rejected at submit time, not silently at runtime — which is much nicer to debug.
Step 1 — Create a subscribe token
Generate a token with subscribe grants for the source room on the LiveKit project side. The LiveKit token docs cover this for every server SDK. You'll also want your RTMP ingest URL ready — for YouTube that looks like rtmp://a.rtmp.youtube.com/live2/xxxx.
Step 2 — Submit one JSON job
A complete job looks like this:
{
"name": "livekit-mix",
"sources": [{
"name": "room_src",
"type": "livekit",
"config": {
"serverUrl": "wss://your-project.livekit.cloud",
"token": "<subscribe-token>"
}
}],
"nodes": [{
"name": "mix_video",
"type": "video_mixer",
"inputs": ["room_src"],
"config": {
"canvas": { "width": 1280, "height": 720, "fps": 30 },
"layout": { "mode": "grid", "grid": { "maxColumns": 3 } }
}
}, {
"name": "mix_audio",
"type": "audio_mixer",
"inputs": ["room_src"]
}],
"sinks": [{
"name": "rtmp_out",
"type": "rtmp_push",
"inputs": ["mix_video", "mix_audio"],
"config": {
"urls": ["rtmp://a.rtmp.youtube.com/live2/xxxx"],
"encoding": { "videoCodec": "h264", "audioCodec": "aac" }
}
}]
}
Things worth noticing:
-
The graph is explicit.
room_srcfeeds both mixers; the RTMP sink consumes mixer outputs, not the raw source. This is what makes it a mix instead of a relay. -
Layout is config, not code — and it isn't frozen at submit time. A 1280×720@30 canvas with a grid capped at 3 columns; participants tile in automatically. Want 1080p? Change the canvas. Want a speaker layout mid-event? Re-submit the job with the same
name; the pipeline updates without a restart. -
Fan-out is just more sinks. Add a second URL to
urlsfor Twitch. Add asegmentsink with the same mixer inputs and the same job streams and records. No duplicate composites. - Encoding lives on the sink. H.264 + AAC is what YouTube and Twitch expect.
Submit it with one call:
curl -X POST "https://api.avflow.dev/v1/jobs" \
-H "Authorization: Bearer ${AVFLOW_API_KEY}" \
-H "Content-Type: application/json" \
-d @livekit-mix.json
Step 3 — Monitor, then stop
# status
curl "https://api.avflow.dev/v1/jobs/livekit-mix" \
-H "Authorization: Bearer ${AVFLOW_API_KEY}"
# stop
curl -X DELETE "https://api.avflow.dev/v1/jobs/livekit-mix" \
-H "Authorization: Bearer ${AVFLOW_API_KEY}"
Wait for status: "running", then confirm the ingest on the YouTube/Twitch side. When the event ends, stop the job — billing is per-minute of runtime, not a monthly seat.
What this replaces
The same job done by hand means: an SFU subscriber, an FFmpeg process with xstack filters for grid layout, an audio amix graph, a hardware encoder you hope doesn't stall, and a supervisor to restart all of it. The self-hosted Egress route replaces FFmpeg but hands you a new service to run. The JSON above is the whole pipeline — stateless to run, trivially reproducible for the next event, and resizable by editing one number.
Learn more
- Full guide with cost breakdown: Mix LiveKit to RTMP
- Grid recording to S3: Record a room
- Video mixer node reference
Top comments (0)