From 005a4380dd318e7cefb42aec36a7cae1df363e79 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 3 Jun 2026 09:29:55 +0200 Subject: [PATCH] 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 --- internal/cmd/player_session_registry.go | 3 ++ internal/engine/hls.go | 33 ++++++++++++------- internal/engine/tonemap.go | 42 +++++++++++++++++++++++++ internal/engine/tonemap_test.go | 19 +++++++++++ internal/engine/transcode_quality.go | 4 +++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/internal/cmd/player_session_registry.go b/internal/cmd/player_session_registry.go index 0ae2906..90cdc37 100644 --- a/internal/cmd/player_session_registry.go +++ b/internal/cmd/player_session_registry.go @@ -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 // filter would error and break playback, so HDR plays untonemapped. 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), } } diff --git a/internal/engine/hls.go b/internal/engine/hls.go index f9d8808..deb54c2 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1353,26 +1353,37 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin hwUploadTail = ",hwupload" colorTail = "" } - // HDR→SDR tonemap, inserted after the scale (downscale-first = fewer pixels - // to tonemap) and before format=. Only for an HDR source on a zscale-capable - // ffmpeg; the trailing comma in hdrTonemapChain slots it in front of format=. + // HDR→SDR tonemap, after the scale (downscale-first = fewer pixels to map). + // Prefer libplacebo (GPU, ONE pass — it also emits the BT.709 colorspace + + // 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 := "" - if probe.HDR != "" && cfg.Transcode.TonemapHDR { + if probe.HDR != "" && cfg.Transcode.TonemapHDR && !useLibplacebo { tonemap = hdrTonemapChain } - // Core video chain (scale + optional tonemap + pixel format + color metadata), - // WITHOUT the optional hwUploadTail — that has to run last, after any subtitle - // overlay, so it's appended separately below. + // videoTail = everything after the scale: either libplacebo (tonemap + + // colorspace + format in one) or the (optional zscale) tonemap then the + // 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 if maxH > 0 && probe.Height > maxH { vchain = fmt.Sprintf( - "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s", - maxH, tonemap, pixFormat, colorTail, + "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%s", + maxH, videoTail, ) } else { vchain = fmt.Sprintf( - "scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s", - tonemap, pixFormat, colorTail, + "scale=trunc(iw/2)*2:trunc(ih/2)*2,%s", + videoTail, ) } if burnIdx >= 0 { diff --git a/internal/engine/tonemap.go b/internal/engine/tonemap.go index 2c80bae..b86e49f 100644 --- a/internal/engine/tonemap.go +++ b/internal/engine/tonemap.go @@ -28,11 +28,53 @@ import ( // 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," +// 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 ( zscaleCacheMu sync.Mutex 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 // the zscale filter (libzimg), required for HDR→SDR tonemapping. Cached per // path. A detection failure (binary missing, exec error) is treated as "no" so diff --git a/internal/engine/tonemap_test.go b/internal/engine/tonemap_test.go index 33efed9..a73e4f8 100644 --- a/internal/engine/tonemap_test.go +++ b/internal/engine/tonemap_test.go @@ -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) { vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone)) if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") { diff --git a/internal/engine/transcode_quality.go b/internal/engine/transcode_quality.go index 1fac2b1..40a9fb7 100644 --- a/internal/engine/transcode_quality.go +++ b/internal/engine/transcode_quality.go @@ -18,6 +18,10 @@ type TranscodeRuntime struct { // Set only when the ffmpeg build has zscale (FFmpegSupportsZscale); without // it the tonemap filter would error and break playback, so it stays off. 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)