feat(stream): enable GPU libplacebo in prod image + gate to real GPU

Make libplacebo actually reachable in the shipped agent image, and refuse it
where it would be a regression.

Dockerfile (so a Vulkan-capable host can use the GPU tonemap path):
- install libvulkan1 (the Vulkan loader libplacebo links at runtime; ~150 KB)
- add 'graphics' to NVIDIA_DRIVER_CAPABILITIES so the nvidia container runtime
  mounts the Vulkan ICD (nvidia_icd.json + GLX libs) under --gpus all
Both are inert without a working Vulkan GPU — the functional probe gates use.

hls.go: gate libplacebo on a real HW encoder (HWAccel != none). A software-only
host with mesa would expose lavapipe (CPU Vulkan); the functional probe accepts
it but its tonemap is SLOWER than the zscale CPU chain, so libplacebo there is a
regression. No HW encoder -> stay on zscale.

Verified on the GPU dev box: nvenc session still picks libplacebo (-c:v
h264_nvenc -vf ...,libplacebo=...:tonemapping=bt.2390); new unit test locks the
software-encoder path onto zscale.
This commit is contained in:
Deivid Soto 2026-06-03 10:42:16 +02:00
parent cfaedb7f3b
commit 5e5a719f27
3 changed files with 47 additions and 9 deletions

View file

@ -35,9 +35,15 @@ FROM debian:bookworm-slim
# 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads # 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads
# RAR5, so unrar — unavailable as a free Debian package — isn't needed). # RAR5, so unrar — unavailable as a free Debian package — isn't needed).
# tzdata/ca-certificates → TLS + correct local time for schedules/logs. # tzdata/ca-certificates → TLS + correct local time for schedules/logs.
# libvulkan1 → the Vulkan loader (libvulkan.so.1). ffmpeg's libplacebo filter
# (GPU HDR→SDR tonemap) loads Vulkan dynamically through it; without the
# loader the filter can't reach a GPU even when the NVIDIA driver mounts
# its ICD. ~150 KB. The agent only USES libplacebo after a functional
# probe (FFmpegSupportsLibplacebo) succeeds AND a real HW encoder is
# present, so this is inert on hosts without a working Vulkan GPU.
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ca-certificates tzdata wget xz-utils par2 p7zip-full && \ ca-certificates tzdata wget xz-utils par2 p7zip-full libvulkan1 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# TARGETARCH is set automatically by Docker buildx during cross-builds. # TARGETARCH is set automatically by Docker buildx during cross-builds.
@ -88,11 +94,14 @@ ENV UNARR_DOWNLOAD_DIR=/downloads
ENV XDG_DATA_HOME=/data ENV XDG_DATA_HOME=/data
# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" + # NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" +
# "compute" capabilities; nvenc needs "video". Baking these here means a plain # "compute" capabilities; nvenc needs "video", and "graphics" makes the runtime
# `docker run --gpus all` (or the compose device reservation) lights up HW # mount the NVIDIA Vulkan ICD (nvidia_icd.json + GLX libs) so ffmpeg's libplacebo
# transcode with zero extra flags. Harmless when no GPU is attached. # filter (GPU HDR tonemap, paired with libvulkan1 above) can create a Vulkan
# device. Baking these here means a plain `docker run --gpus all` (or the compose
# device reservation) lights up HW transcode + GPU tonemap with zero extra flags.
# Harmless when no GPU is attached.
ENV NVIDIA_VISIBLE_DEVICES=all ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility,graphics
VOLUME ["/config", "/downloads", "/data"] VOLUME ["/config", "/downloads", "/data"]

View file

@ -1359,7 +1359,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// CPU chain; else play untonemapped (desaturated, last resort). Skip // CPU chain; else play untonemapped (desaturated, last resort). Skip
// libplacebo on VAAPI: its Vulkan surface flow doesn't compose with our // libplacebo on VAAPI: its Vulkan surface flow doesn't compose with our
// nv12+hwupload path, so VAAPI keeps the zscale-or-none behaviour. // nv12+hwupload path, so VAAPI keeps the zscale-or-none behaviour.
useLibplacebo := probe.HDR != "" && cfg.Transcode.HasLibplacebo && codec != "h264_vaapi" //
// Gate on a real HW encoder (HWAccel != none): only then is the Vulkan
// device a genuine GPU. A software-only host with mesa would expose lavapipe
// (CPU Vulkan), which the functional probe accepts but whose tonemap is
// SLOWER than the zscale CPU chain — so on those hosts libplacebo would be a
// regression. No HW encoder ⇒ stay on zscale.
useLibplacebo := probe.HDR != "" && cfg.Transcode.HasLibplacebo &&
codec != "h264_vaapi" && cfg.Transcode.HWAccel != HWAccelNone
tonemap := "" tonemap := ""
if probe.HDR != "" && cfg.Transcode.TonemapHDR && !useLibplacebo { if probe.HDR != "" && cfg.Transcode.TonemapHDR && !useLibplacebo {
tonemap = hdrTonemapChain tonemap = hdrTonemapChain

View file

@ -68,13 +68,15 @@ func TestTonemap_AppliedInNoDownscaleBranch(t *testing.T) {
} }
func TestTonemap_LibplaceboPreferredOverZscale(t *testing.T) { func TestTonemap_LibplaceboPreferredOverZscale(t *testing.T) {
// HDR source + an ffmpeg with libplacebo → the single GPU filter replaces // HDR source + an ffmpeg with libplacebo on a REAL HW encoder (NVENC) → the
// the whole CPU zscale chain (and the trailing format=/setparams it folds in). // single GPU filter replaces the whole CPU zscale chain (and the trailing
// format=/setparams it folds in). NVENC (not None) because libplacebo is
// gated on a real GPU — a software encoder stays on zscale.
cfg := HLSSessionConfig{ cfg := HLSSessionConfig{
SessionID: "test", SessionID: "test",
SourcePath: "/movies/x.mkv", SourcePath: "/movies/x.mkv",
Quality: "720p", Quality: "720p",
Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true, HasLibplacebo: true}, Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNVENC, TonemapHDR: true, HasLibplacebo: true},
} }
probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: "HDR10", DurationSec: 100} probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: "HDR10", DurationSec: 100}
vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " ")) vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
@ -86,6 +88,26 @@ func TestTonemap_LibplaceboPreferredOverZscale(t *testing.T) {
} }
} }
func TestTonemap_LibplaceboSkippedOnSoftwareEncoder(t *testing.T) {
// libplacebo present but no HW encoder (software libx264) → must NOT use
// libplacebo: a software host's only Vulkan would be lavapipe (CPU), slower
// than zscale. Falls back to the zscale chain.
cfg := HLSSessionConfig{
SessionID: "test",
SourcePath: "/movies/x.mkv",
Quality: "720p",
Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true, HasLibplacebo: true},
}
probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: "HDR10", DurationSec: 100}
vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
if strings.Contains(vf, "libplacebo") {
t.Errorf("software encoder must not use libplacebo (lavapipe trap), got %q", vf)
}
if !strings.Contains(vf, "zscale=t=linear") {
t.Errorf("software encoder with HDR + zscale should fall back to the zscale chain, got %q", vf)
}
}
func TestTonemap_SkippedWhenFFmpegLacksZscale(t *testing.T) { func TestTonemap_SkippedWhenFFmpegLacksZscale(t *testing.T) {
vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone)) vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone))
if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") { if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") {