feat(stream): GPU HDR tonemap via libplacebo

Prefer the single-pass Vulkan libplacebo filter over the CPU zscale chain
for HDR->SDR tonemapping when the agent ffmpeg has it. One GPU pass does
tonemap + BT.709 primaries/transfer/matrix + 8-bit yuv420p, replacing the
four-stage zscale chain and its trailing format=/setparams. Higher quality,
far cheaper than the CPU path, and present on builds that lack zscale.

- FFmpegSupportsLibplacebo probe (cached, mirrors FFmpegSupportsZscale)
- HasLibplacebo on TranscodeRuntime, wired from buildTranscodeRuntime
- hls.go: videoTail picks libplacebo when present (not h264_vaapi), else
  keeps the zscale tonemap + format chain
- test: libplacebo replaces the zscale chain, never runs alongside it
This commit is contained in:
Deivid Soto 2026-06-03 09:29:55 +02:00
parent 325c11c1eb
commit 005a4380dd
5 changed files with 90 additions and 11 deletions

View file

@ -95,5 +95,8 @@ func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.Transc
// Tonemap HDR→SDR only when this ffmpeg build has zscale; otherwise the // Tonemap HDR→SDR only when this ffmpeg build has zscale; otherwise the
// filter would error and break playback, so HDR plays untonemapped. // filter would error and break playback, so HDR plays untonemapped.
TonemapHDR: engine.FFmpegSupportsZscale(ffmpegPath), TonemapHDR: engine.FFmpegSupportsZscale(ffmpegPath),
// libplacebo (GPU) is preferred over zscale when present — checked here so
// the per-session arg builder can pick it for HDR sources.
HasLibplacebo: engine.FFmpegSupportsLibplacebo(ffmpegPath),
} }
} }

View file

@ -1353,26 +1353,37 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
hwUploadTail = ",hwupload" hwUploadTail = ",hwupload"
colorTail = "" colorTail = ""
} }
// HDR→SDR tonemap, inserted after the scale (downscale-first = fewer pixels // HDR→SDR tonemap, after the scale (downscale-first = fewer pixels to map).
// to tonemap) and before format=. Only for an HDR source on a zscale-capable // Prefer libplacebo (GPU, ONE pass — it also emits the BT.709 colorspace +
// ffmpeg; the trailing comma in hdrTonemapChain slots it in front of format=. // 8-bit format, so it REPLACES the format/setparams tail); else the zscale
// CPU chain; else play untonemapped (desaturated, last resort). Skip
// libplacebo on VAAPI: its Vulkan surface flow doesn't compose with our
// nv12+hwupload path, so VAAPI keeps the zscale-or-none behaviour.
useLibplacebo := probe.HDR != "" && cfg.Transcode.HasLibplacebo && codec != "h264_vaapi"
tonemap := "" tonemap := ""
if probe.HDR != "" && cfg.Transcode.TonemapHDR { if probe.HDR != "" && cfg.Transcode.TonemapHDR && !useLibplacebo {
tonemap = hdrTonemapChain tonemap = hdrTonemapChain
} }
// Core video chain (scale + optional tonemap + pixel format + color metadata), // videoTail = everything after the scale: either libplacebo (tonemap +
// WITHOUT the optional hwUploadTail — that has to run last, after any subtitle // colorspace + format in one) or the (optional zscale) tonemap then the
// overlay, so it's appended separately below. // format + color-metadata tail. No leading comma — the scale chain ends in one.
videoTail := tonemap + "format=" + pixFormat + colorTail
if useLibplacebo {
videoTail = libplaceboTonemapFilter
}
// Core video chain (scale + tonemap/format tail), WITHOUT the optional
// hwUploadTail — that has to run last, after any subtitle overlay, so it's
// appended separately below.
var vchain string var vchain string
if maxH > 0 && probe.Height > maxH { if maxH > 0 && probe.Height > maxH {
vchain = fmt.Sprintf( vchain = fmt.Sprintf(
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s", "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%s",
maxH, tonemap, pixFormat, colorTail, maxH, videoTail,
) )
} else { } else {
vchain = fmt.Sprintf( vchain = fmt.Sprintf(
"scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s", "scale=trunc(iw/2)*2:trunc(ih/2)*2,%s",
tonemap, pixFormat, colorTail, videoTail,
) )
} }
if burnIdx >= 0 { if burnIdx >= 0 {

View file

@ -28,11 +28,53 @@ import (
// possible follow-up if HLG/DV-only sources become common. // possible follow-up if HLG/DV-only sources become common.
const hdrTonemapChain = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv," const hdrTonemapChain = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,"
// libplaceboTonemapFilter maps an HDR source to SDR BT.709 in a SINGLE GPU pass
// (Vulkan): tone-map the HDR curve, convert primaries/transfer/matrix to BT.709
// limited range, and output 8-bit yuv420p — so it REPLACES the zscale chain AND
// the trailing `format=yuv420p,setparams=bt709` (it does both). Higher quality
// and far cheaper than the CPU zscale chain, and the agent's ffmpeg has it where
// zscale is missing. It does NOT scale here — the CPU scale chain runs first
// (it owns the even-dimension rounding libx264/nvenc require). No trailing comma:
// it's the last filter in the chain.
const libplaceboTonemapFilter = "libplacebo=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv:format=yuv420p:tonemapping=bt.2390"
var ( var (
zscaleCacheMu sync.Mutex zscaleCacheMu sync.Mutex
zscaleCache = map[string]bool{} zscaleCache = map[string]bool{}
libplaceboCacheMu sync.Mutex
libplaceboCache = map[string]bool{}
) )
// FFmpegSupportsLibplacebo reports whether the ffmpeg binary has the libplacebo
// filter (Vulkan GPU HDR tonemap + colorspace). Preferred over zscale when both
// exist. Cached per path; a probe failure is treated as "no". Mirrors
// FFmpegSupportsZscale.
func FFmpegSupportsLibplacebo(ffmpegPath string) bool {
if ffmpegPath == "" {
return false
}
libplaceboCacheMu.Lock()
if v, ok := libplaceboCache[ffmpegPath]; ok {
libplaceboCacheMu.Unlock()
return v
}
libplaceboCacheMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").Output()
supported := err == nil && bytes.Contains(out, []byte("libplacebo"))
libplaceboCacheMu.Lock()
libplaceboCache[ffmpegPath] = supported
libplaceboCacheMu.Unlock()
if supported {
log.Printf("[tonemap] ffmpeg has libplacebo — HDR sources tonemapped on the GPU (preferred)")
}
return supported
}
// FFmpegSupportsZscale reports whether the ffmpeg binary at path was built with // FFmpegSupportsZscale reports whether the ffmpeg binary at path was built with
// the zscale filter (libzimg), required for HDR→SDR tonemapping. Cached per // the zscale filter (libzimg), required for HDR→SDR tonemapping. Cached per
// path. A detection failure (binary missing, exec error) is treated as "no" so // path. A detection failure (binary missing, exec error) is treated as "no" so

View file

@ -66,6 +66,25 @@ func TestTonemap_AppliedInNoDownscaleBranch(t *testing.T) {
} }
} }
func TestTonemap_LibplaceboPreferredOverZscale(t *testing.T) {
// HDR source + an ffmpeg with libplacebo → the single GPU filter replaces
// the whole CPU zscale chain (and the trailing format=/setparams it folds in).
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.Fatalf("libplacebo-capable ffmpeg: expected libplacebo filter, got %q", vf)
}
if strings.Contains(vf, "zscale=t=linear") || strings.Contains(vf, "tonemap=tonemap=hable") {
t.Errorf("libplacebo must replace the zscale chain, not run alongside it: %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") {

View file

@ -18,6 +18,10 @@ type TranscodeRuntime struct {
// Set only when the ffmpeg build has zscale (FFmpegSupportsZscale); without // Set only when the ffmpeg build has zscale (FFmpegSupportsZscale); without
// it the tonemap filter would error and break playback, so it stays off. // it the tonemap filter would error and break playback, so it stays off.
TonemapHDR bool TonemapHDR bool
// HasLibplacebo: the ffmpeg build has the libplacebo filter (GPU HDR tonemap).
// Preferred over the zscale chain for HDR sources — one GPU pass, higher
// quality, and present where zscale is missing.
HasLibplacebo bool
} }
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate) // qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)