Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions transcode/Dockerfile.gpu
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# NVIDIA NVENC build of the transcode app: CUDA runtime base + Ubuntu ffmpeg with
# av1_nvenc/h264_nvenc/hevc_nvenc built in. Requires the nvidia container runtime
# (--gpus all) at run time; set --h264/h265/av1-encoder to the *_nvenc variants.
FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04

RUN apt-get update \
&& apt-get install -y --no-install-recommends git ffmpeg python3 python3-pip python-is-python3 \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir --break-system-packages \
"av" \
"livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@rs/media-publish-encoder-opts"

WORKDIR /app
COPY runner.py profiles.py engine.py ./

EXPOSE 8990

CMD ["python3", "runner.py"]
35 changes: 35 additions & 0 deletions transcode/Dockerfile.intel
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Intel VA-API build of the transcode app: jellyfin-ffmpeg7 ships the patched
# Intel media stack (iHD + oneVPL) that makes scale_vaapi + av1_vaapi work on Arc,
# where distro ffmpeg's are broken. Full-GPU (decode + scale + encode) via
# --device /dev/dri; set --h264/h265/av1-encoder to the *_vaapi variants (see
# docker-compose.intel.yml).
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl ca-certificates gnupg git python3 python3-pip python-is-python3 \
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/ubuntu noble main" \
> /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends jellyfin-ffmpeg7 \
&& rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir --break-system-packages \
"av" \
"livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@rs/media-publish-encoder-opts"

# Point the engine at the jellyfin ffmpeg (patched Intel stack) and default to the
# full-GPU path (decode + scale + encode on the GPU).
ENV FFMPEG_BIN=/usr/lib/jellyfin-ffmpeg/ffmpeg \
HWACCEL_DECODE=1

WORKDIR /app
COPY runner.py profiles.py engine.py ./

EXPOSE 8990

CMD ["python3", "runner.py"]
3 changes: 2 additions & 1 deletion transcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ uv run client.py clip.mp4 --live --heights 720,360 # writes out-<name>.ts

## Notes

- **AV1** is just `encoder: "AV1"` → `libsvtav1` (CPU) or `av1_nvenc` (GPU via `TRANSCODE_AV1_ENCODER` + a CUDA base). H.264/H.265 have the same override (`TRANSCODE_H264_ENCODER`/`TRANSCODE_H265_ENCODER` → `h264_nvenc`/`hevc_nvenc`); H.264 CPU (`libx264`) remains the default and needs no GPU. VP8/VP9 stay CPU-only — ffmpeg has no GPU encoder for either.
- **AV1** is just `encoder: "AV1"` → `libsvtav1` (CPU) or `av1_nvenc`/`av1_vaapi` (GPU via `TRANSCODE_AV1_ENCODER` — NVENC needs a CUDA base, VA-API uses `Dockerfile.intel`). H.264/H.265 have the same override (`TRANSCODE_H264_ENCODER`/`TRANSCODE_H265_ENCODER` → `h264_nvenc`/`hevc_nvenc` or `h264_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 from `Dockerfile.intel` and runs via `docker-compose.intel.yml` with `--device /dev/dri`. The Intel image uses **jellyfin-ffmpeg** — distro ffmpeg's `scale_vaapi`/`av1_qsv` are broken on Arc. `HWACCEL_DECODE=1` (default there) keeps decode + scale + encode all on the GPU; `RENDER_DEVICE` picks the node (Arc dGPU is often `renderD129`).
- **Multi-GPU**: batch jobs round-robin across every GPU the container can see (`nvidia-smi -L` at startup, `-gpu <index>` passed to any `*_nvenc` encoder) — `count: all` in 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`/`profile`** via the SDK's per-track encoder-options support ([livepeer-python-gateway#35](https://github.com/livepeer/livepeer-python-gateway/pull/35), pinned in `pyproject.toml`/`Dockerfile`); only `gop` is 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.
Expand Down
41 changes: 41 additions & 0 deletions transcode/docker-compose.intel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Offchain demo on Intel VA-API (Arc / iGPU): orchestrator + the transcode app
# built from Dockerfile.intel, full-GPU (decode + scale + encode) on /dev/dri.
#
# docker compose -f docker-compose.intel.yml up -d --build
# uv run client.py clip.mp4 --heights 720,360
#
# RENDER_DEVICE picks the VA-API node (Arc dGPU is often renderD129, an iGPU
# renderD128). RUNNER_GPU_* (optional) advertise the GPU to orchestrator discovery.
services:
orchestrator:
extends:
file: ../compose.orchestrator.yml
service: orchestrator

app:
build:
context: .
dockerfile: Dockerfile.intel
container_name: example_apps_transcode
depends_on:
orchestrator:
condition: service_healthy
devices:
- /dev/dri:/dev/dri
environment:
- RENDER_DEVICE=${RENDER_DEVICE:-/dev/dri/renderD128}
- HWACCEL_DECODE=1
# Optional: advertise the GPU to discovery (SDK auto-detect is NVIDIA-only).
# - RUNNER_GPU_NAME=Intel Arc A770
# - RUNNER_GPU_VRAM_MB=16384
command:
- python3
- runner.py
- --host=0.0.0.0
- --orchestrator=https://orchestrator:8935
- --orchSecret=abcdef
- --runner-url=http://app:8990
- --capacity=${TRANSCODE_CAPACITY:-4}
- --av1-encoder=${TRANSCODE_AV1_ENCODER:-av1_vaapi}
- --h264-encoder=${TRANSCODE_H264_ENCODER:-h264_vaapi}
- --h265-encoder=${TRANSCODE_H265_ENCODER:-hevc_vaapi}
28 changes: 26 additions & 2 deletions transcode/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@

import profiles as prof

# ffmpeg binary. Intel VA-API AV1 on Arc needs the patched media stack that
# jellyfin-ffmpeg ships (distro ffmpeg's scale_vaapi/av1_qsv are broken there), so
# Dockerfile.intel points FFMPEG_BIN at it; NVENC/CPU builds use plain ffmpeg.
FFMPEG = os.environ.get("FFMPEG_BIN", "ffmpeg")

# VA-API render node (Intel). Override per host: an Arc dGPU is often renderD129,
# an integrated GPU renderD128.
RENDER_DEVICE = os.environ.get("RENDER_DEVICE", "/dev/dri/renderD128")

# VA-API only: decode + scale on the GPU too (not just encode), so the whole
# pipeline stays on VA surfaces and the CPU is left near-idle. Off = sw decode +
# GPU encode, which is more forgiving on odd/corrupt inputs. Dockerfile.intel
# defaults it on.
HWACCEL_DECODE = os.environ.get("HWACCEL_DECODE", "0") == "1"


def transcode_file(input_path: str, raw_profiles: list[dict], out_dir: str,
av1_encoder: str = "libsvtav1", h264_encoder: str = "libx264",
Expand All @@ -28,13 +43,22 @@ def transcode_file(input_path: str, raw_profiles: list[dict], out_dir: str,
for raw in raw_profiles:
p = prof.normalize(raw, av1_encoder=av1_encoder, h264_encoder=h264_encoder, h265_encoder=h265_encoder)
out_path = os.path.join(out_dir, f"{p['name']}.{p['ext']}")
cmd = ["ffmpeg", "-y"]
cmd = [FFMPEG, "-y"]
if "nvenc" in p["encoder"]:
cmd += ["-hwaccel", "cuda"]
if gpu_index is not None:
cmd += ["-hwaccel_device", str(gpu_index)]
elif "vaapi" in p["encoder"]:
if HWACCEL_DECODE:
# decode + scale + encode all on the GPU (VA surfaces end to end).
cmd += ["-hwaccel", "vaapi", "-hwaccel_device", RENDER_DEVICE,
"-hwaccel_output_format", "vaapi"]
else:
# sw decode, GPU encode (video_args uploads the frame to a VA surface).
cmd += ["-vaapi_device", RENDER_DEVICE]
cmd += ["-i", input_path]
cmd += prof.video_args(p, gpu_index=gpu_index)
cmd += prof.video_args(p, gpu_index=gpu_index,
hw_decode=(HWACCEL_DECODE and "vaapi" in p["encoder"]))
cmd += prof.audio_args(p["ext"])
if p["ext"] == "mp4":
cmd += ["-movflags", "+faststart"]
Expand Down
34 changes: 28 additions & 6 deletions transcode/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"hevc": ("libx265", "mp4"),
"vp8": ("libvpx", "webm"),
"vp9": ("libvpx-vp9", "webm"),
"av1": ("libsvtav1", "mkv"), # GPU: override encoder=av1_nvenc via --av1-encoder
"av1": ("libsvtav1", "mkv"), # GPU: override encoder=av1_nvenc/av1_vaapi via --av1-encoder
}

# native H.264 profile name -> ffmpeg -profile:v value.
Expand Down Expand Up @@ -88,14 +88,27 @@ def _pix_fmt(color_depth: int, chroma: int) -> str | None:
return f"yuv{sub}p" + (f"{depth}le" if depth > 8 else "")


def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]:
"""ffmpeg video flags for one normalized profile (scale + codec + rate control + gop)."""
def video_args(p: dict[str, Any], gpu_index: int | None = None,
hw_decode: bool = False) -> list[str]:
"""ffmpeg video flags for one normalized profile (scale + codec + rate control + gop).

hw_decode: VA-API only — when the input was hwaccel-decoded to a VA surface,
scale on the GPU (scale_vaapi) instead of the sw scale + upload.
"""
args: list[str] = []

# resolution — 0 on either axis keeps aspect (-2 = even, preserve aspect).
w = p["width"] or -2
h = p["height"] or -2
args += ["-vf", f"scale={w}:{h}"]
if "vaapi" in p["encoder"] and hw_decode:
# frames are already VA surfaces (hwaccel decode): scale on the GPU.
# scale_vaapi wants concrete/-1 dims, so map the 0/-2 "keep aspect" to -1.
args += ["-vf", f"scale_vaapi=w={p['width'] or -1}:h={p['height'] or -1}"]
elif "vaapi" in p["encoder"]:
# sw scale, then upload nv12 to a VA surface for GPU encode.
args += ["-vf", f"scale={w}:{h},format=nv12,hwupload"]
else:
args += ["-vf", f"scale={w}:{h}"]

args += ["-c:v", p["encoder"]]
if gpu_index is not None and "nvenc" in p["encoder"]:
Expand All @@ -114,6 +127,8 @@ def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]:
crf = p["quality"] or _default_crf(p["encoder_key"])
if "nvenc" in p["encoder"]:
args += ["-cq", str(crf)]
elif "vaapi" in p["encoder"]:
args += ["-rc_mode", "CQP", "-qp", str(crf)] # VA-API constant-quality
elif p["encoder_key"] == "av1":
args += ["-crf", str(crf), "-preset", "8"] # SVT-AV1 preset (speed/size)
elif p["encoder_key"] in ("vp8", "vp9"):
Expand All @@ -136,8 +151,15 @@ def video_args(p: dict[str, Any], gpu_index: int | None = None) -> list[str]:
except ValueError:
pass

pix = _pix_fmt(p["colorDepth"], p["chromaFormat"])
if pix:
chroma = p["chromaFormat"]
if ("nvenc" in p["encoder"] or "vaapi" in p["encoder"]) and chroma != 0:
# NVENC/VA-API (h264/hevc/av1) on this fleet don't support 4:2:2/4:4:4
# chroma; clamp to 4:2:0, keep requested bit depth.
chroma = 0
pix = _pix_fmt(p["colorDepth"], chroma)
if pix and "vaapi" not in p["encoder"]:
# VA-API pins its pixel format via the VA surface (nv12); a second
# -pix_fmt would clash with the encoder's surface input.
args += ["-pix_fmt", pix]

return args
Expand Down
19 changes: 15 additions & 4 deletions transcode/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
import profiles as prof
from aiohttp import ClientSession, ClientTimeout, web

from livepeer_gateway.live_runner import create_trickle_channels, register_runner
from livepeer_gateway.live_runner import LiveRunnerGPU, create_trickle_channels, register_runner
from livepeer_gateway.media_output import MediaOutput
from livepeer_gateway.media_publish import MediaPublish, MediaPublishConfig, VideoOutputConfig

Expand Down Expand Up @@ -76,9 +76,9 @@ def _parse_args() -> argparse.Namespace:
p.add_argument("--runner-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}")
p.add_argument("--host", default=DEFAULT_HOST, help="Bind address (use 0.0.0.0 in containers).")
p.add_argument("--capacity", type=int, default=4, help="Max concurrent jobs.")
p.add_argument("--av1-encoder", default="libsvtav1", help="Encoder for encoder=AV1 (libsvtav1 cpu, av1_nvenc gpu).")
p.add_argument("--h264-encoder", default="libx264", help="Encoder for encoder=H264/default (libx264 cpu, h264_nvenc gpu).")
p.add_argument("--h265-encoder", default="libx265", help="Encoder for encoder=H265/HEVC (libx265 cpu, hevc_nvenc gpu).")
p.add_argument("--av1-encoder", default="libsvtav1", help="Encoder for encoder=AV1 (libsvtav1 cpu, av1_nvenc/av1_vaapi gpu).")
p.add_argument("--h264-encoder", default="libx264", help="Encoder for encoder=H264/default (libx264 cpu, h264_nvenc/h264_vaapi gpu).")
p.add_argument("--h265-encoder", default="libx265", help="Encoder for encoder=H265/HEVC (libx265 cpu, hevc_nvenc/hevc_vaapi gpu).")
p.add_argument("--price", type=int, default=0, help="Price in USD per pixels-per-unit (0 = free).")
p.add_argument("--pixels-per-unit", type=int, default=1, help="Scale factor for the price.")
p.add_argument("--no-register", action="store_true",
Expand Down Expand Up @@ -259,9 +259,20 @@ async def _on_startup(app: web.Application) -> None:
if args.no_register:
log.info("static mode: not self-registering; attach via runners.json (health_url=/healthz)")
return
# register_runner auto-detects only NVIDIA GPUs (pynvml/torch/nvidia-smi),
# so a non-NVIDIA GPU (e.g. Intel Arc on the VA-API path) is advertised to
# discovery via env instead — otherwise the runner shows with no GPU.
gpu = None
if os.environ.get("RUNNER_GPU_NAME"):
gpu = LiveRunnerGPU(
id=os.environ.get("RUNNER_GPU_ID", ""),
name=os.environ["RUNNER_GPU_NAME"],
vram_mb=int(os.environ.get("RUNNER_GPU_VRAM_MB", "0") or 0),
)
app["registration"] = await register_runner(
args.orchestrator, secret=args.orchSecret, runner_url=args.runner_url, app=APP_ID,
capacity=args.capacity, price_per_unit=args.price, pixels_per_unit=args.pixels_per_unit,
gpu=gpu, auto_detect_gpu=(gpu is None),
)
log.info("registered runner_id=%s app=%s capacity=%d", app["registration"].runner_id, APP_ID, args.capacity)

Expand Down