DEV Community

XiFin WU
XiFin WU

Posted on Originally published at avflow.dev

How to Stream a LiveKit Room to YouTube or Twitch via RTMP (Without Running FFmpeg)

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:

  1. Subscribe to every track in the room (a subscribe token, not a publish token).
  2. Compose the videos into a single canvas — a grid layout is the common choice.
  3. Mix all audio into a single track.
  4. 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" }
    }
  }]
}
Enter fullscreen mode Exit fullscreen mode

Things worth noticing:

  • The graph is explicit. room_src feeds 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 urls for Twitch. Add a segment sink 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
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)