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:
parent
325c11c1eb
commit
005a4380dd
5 changed files with 90 additions and 11 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue