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
|
// 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue