One container, one ffmpeg engine, two surfaces: batch (VOD) and live (trickle). Driven by go-livepeer's native profile contract, so it is a drop-in for the native transcoding pipeline.
| App id | transcode/ffmpeg |
| Surfaces | POST /transcode (batch/VOD) · POST /transcode/live (trickle) |
| Config | {"profiles": [<JsonProfile>, …]} (go-livepeer native) |
| Registration | dynamic (default) or static (runners.json) |
| Runner mode | single-shot (batch) · persistent (live) |
| Transport | HTTP (batch) · trickle (live) |
| Pricing | per pixel (--price per --pixels-per-unit) |
| Port | 8990 |
Note
Registration defaults to dynamic (self-register + heartbeat) — the right fit for an elastic transcode fleet with self-reported capacity. It also supports static: run with --no-register and point the orchestrator at the included runners.json (it health-polls GET /healthz). Static suits a fixed, centrally-managed set of nodes; the live runner docs cover both.
Both surfaces take the same ladder — go-livepeer's ffmpeg.JsonProfile:
{ "name": "720p", "width": 0, "height": 720, "bitrate": 0, "fps": 0, "fpsDen": 1,
"profile": "H264High", "gop": "", "encoder": "H264", "quality": 0,
"colorDepth": 8, "chromaFormat": 0 }encoder:H264(default) ·H265/HEVC·VP8·VP9·AV1.width/height: 0 on an axis keeps aspect.- rate control:
bitrateif set, elsequality(CRF), else a sane per-codec default. gop:"intra"(all-intra) or seconds between keyframes;profile,colorDepth,chromaFormatmap to the ffmpeg equivalents.
profiles.py is the single mapping to ffmpeg, shared by both surfaces — same config in, same encode out.
docker compose up -d --build
uv syncFile in, one rendition per profile out. Base64 for small test clips; URL / object storage at scale (bytes never touch the control plane).
# base64 (small clips): two renditions, written to out-<name>.<ext>
uv run client.py clip.mp4 --heights 720,360
uv run client.py clip.mp4 --profiles '[{"name":"720p","height":720,"bitrate":3000000,"profile":"H264High"}]'
# object storage (scale): runner fetches input_url, PUTs each rendition
uv run client.py --input-url https://.../src.mp4 \
--profiles '[{"name":"720p","height":720}]' \
--output-urls '{"720p":"https://.../720p.mp4?sig=..."}'Real-time rendition ladder over trickle (same as the old transcode-live):
uv run client.py clip.mp4 --live --heights 720,360 # writes out-<name>.ts- AV1 is just
encoder: "AV1"→libsvtav1(CPU) orav1_nvenc/av1_vaapi(GPU viaTRANSCODE_AV1_ENCODER— NVENC needs a CUDA base, VA-API usesDockerfile.intel). H.264/H.265 have the same override (TRANSCODE_H264_ENCODER/TRANSCODE_H265_ENCODER→h264_nvenc/hevc_nvencorh264_vaapi/hevc_vaapi); H.264 CPU (libx264) remains the default and needs no GPU. VP8/VP9 stay CPU-only — ffmpeg has no GPU encoder for either. - GPU backends: NVIDIA NVENC builds from
Dockerfile.gpu(CUDA base,--gpus all); Intel VA-API (Arc / iGPU) builds fromDockerfile.inteland runs viacompose.intel.ymlwith--device /dev/dri. The Intel image uses jellyfin-ffmpeg — distro ffmpeg'sscale_vaapi/av1_qsvare broken on Arc.HWACCEL_DECODE=1(default there) keeps decode + scale + encode all on the GPU;RENDER_DEVICEpicks the node (Arc dGPU is oftenrenderD129). - Multi-GPU: batch jobs round-robin across every GPU the container can see (
nvidia-smi -Lat startup,-gpu <index>passed to any*_nvencencoder) —count: allin the GPU reservation puts the whole box to work instead of pinning every job to device 0. - Batch honors the full profile (bitrate, profile, gop, pix fmt). Live honors
height/fps/encoder/bitrate/profilevia the SDK's per-track encoder-options support (livepeer-python-gateway#35, pinned inpyproject.toml/Dockerfile); onlygopis still segment-driven on live. - The 54 TB AV1 archival job is the batch surface with
input_url/output_urls+ a fan-out driver over the clip list.
--capacity / TRANSCODE_CAPACITY (default 4) is the max concurrent jobs the runner advertises; the orchestrator won't schedule beyond it — the live-runner equivalent of the old pipeline's -maxSessions. It's a static number you set: there is no auto-detection. The right value depends on your hardware and the profile ladder (resolution, codec, number of renditions, CPU vs GPU), so a single number can't be universal — benchmark for your workload and set it.
The limiting signal differs by surface:
- Batch (VOD) is throughput-bound: run one representative job, then run
Nin parallel and find the highestNwhere per-job time stays acceptable and CPU/GPU/VRAM aren't saturated. - Live (trickle) must sustain real time: capacity can't exceed the encode's real-time factor.
Measure the real-time factor with ffmpeg on your ladder — the speed=Nx it prints is the realtime multiple:
# one rendition; repeat / chain filters for your full ladder (one decode -> N encodes = one session)
ffmpeg -benchmark -i sample.mp4 -vf scale=-2:720 -c:v libx264 -preset superfast -f null - 2>&1 | grep speed=
# speed=8.2x -> ~8 concurrent realtime 720p streams on this box (per encoder), before headroomThen validate by actually running N concurrent sessions of your real workload and watching:
- CPU with
htop— target ~70–80%, leave headroom. - GPU with
nvidia-smi dmon -s u— SM %, encoder % (enc), and VRAM. Also note your GPU's NVENC session cap (consumer cards historically ~3–8; L4/L40/datacenter much higher) — GPU capacity can be limited by that, not just compute. - Live keeping up: the SDK's per-track publish stats expose
time_debt_sandframes_dropped_debt— if debt grows or debt-drops appear, that session isn't sustaining real time, i.e. you're over capacity.
Rule of thumb: set capacity to the largest N that holds with ~20–30% headroom, and re-benchmark whenever the hardware or the profile ladder changes. (Automating this — a startup benchmark + NVENC-limit clamp behind --auto-capacity — is a natural production follow-up; the reference example keeps it manual.)
On-chain: layer compose.onchain.yml and pass --signer http://localhost:7936 (batch).
CI publishes the image to ghcr.io/rickstaa/transcode-livepeer-runner on main and v* tags. Tags: latest (current main), stable (latest v* release), 1.2 / 1.2.3, sha-<short>. The package is public, so pulling needs no account and no login.
docker compose up always builds from source. To run the published image instead:
docker compose up -d --pull alwaysThe published image is the CPU one. GPU operators build from Dockerfile.gpu (NVENC) or Dockerfile.intel (VA-API), since each needs a different base.
uvx pre-commit install # format on commit
uvx pre-commit run --all-files
uv run pytest test_profiles.pyCI runs the same hooks, checks the compose files parse, and builds the image.
This repo is an example of how to run transcoding on the live runner, not a production-ready pipeline. The code here is MIT.
The image is ffmpeg plus the Livepeer gateway SDK. ffmpeg is LGPL-2.1 as built here; the Intel image uses jellyfin-ffmpeg (GPL-3.0), because distro ffmpeg's scale_vaapi and av1_qsv are broken on Arc. If you redistribute the Intel image, that is the licence that governs it.
The SDK is pinned to a commit of rs/media-publish-encoder-opts, which carries encoder options that released livepeer-gateway does not yet have. When that lands on PyPI, this should move to a version range.
clip.mp4 is an ffmpeg-generated test pattern, so it carries no third-party rights.
Start from template-livepeer-runner, then list yours in runner-app-examples. The live runner docs are the reference.