feat(transcode): tonemap HDR sources to SDR (zscale-gated)
HDR (HDR10/HLG/Dolby Vision) transcoded to SDR came out washed-out and desaturated because the filter chain never tonemapped. buildHLSFFmpegArgsAt now inserts a zscale linearise -> hable tonemap -> BT.709 chain after the scale and before format=, but only when the source is HDR and the ffmpeg build has zscale (FFmpegSupportsZscale, cached). Builds without zimg keep the old behaviour (plays, just desaturated) instead of erroring. It's a CPU filter, valid for every encoder here: the decode hwaccel deliberately leaves frames in system memory (no -hwaccel_output_format), so zscale runs ahead of format=/hwupload exactly like the existing scale filter. Verified on a real 4K HDR10 file — vivid colour and deep blacks vs the washed-out baseline.
This commit is contained in:
parent
445da233c0
commit
e4373454ba
6 changed files with 211 additions and 5 deletions
|
|
@ -1347,16 +1347,23 @@ 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=.
|
||||
tonemap := ""
|
||||
if probe.HDR != "" && cfg.Transcode.TonemapHDR {
|
||||
tonemap = hdrTonemapChain
|
||||
}
|
||||
var filterChain string
|
||||
if maxH > 0 && probe.Height > maxH {
|
||||
filterChain = fmt.Sprintf(
|
||||
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
|
||||
maxH, pixFormat, colorTail, hwUploadTail,
|
||||
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s",
|
||||
maxH, tonemap, pixFormat, colorTail, hwUploadTail,
|
||||
)
|
||||
} else {
|
||||
filterChain = fmt.Sprintf(
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
|
||||
pixFormat, colorTail, hwUploadTail,
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2,%sformat=%s%s%s",
|
||||
tonemap, pixFormat, colorTail, hwUploadTail,
|
||||
)
|
||||
}
|
||||
args = append(args, "-vf", filterChain)
|
||||
|
|
|
|||
68
internal/engine/tonemap.go
Normal file
68
internal/engine/tonemap.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hdrTonemapChain is the ffmpeg filter segment that maps an HDR source
|
||||
// (HDR10/HLG, or a Dolby Vision base layer) down to SDR BT.709: linearise the
|
||||
// PQ/HLG signal, tonemap the highlights (hable), then re-encode to BT.709
|
||||
// transfer/matrix/primaries in limited range. Without it an HDR source
|
||||
// transcoded to an SDR encode keeps wide-gamut/PQ data the SDR player can't
|
||||
// interpret, so colour looks washed-out / desaturated.
|
||||
//
|
||||
// Requires the zscale filter (libzimg) in the ffmpeg build — gate on
|
||||
// FFmpegSupportsZscale. Trailing comma so it slots in front of the chain's
|
||||
// `format=` stage. CPU filter: valid for every encoder here because the decode
|
||||
// hwaccel intentionally leaves frames in system memory (see buildHLSFFmpegArgsAt).
|
||||
//
|
||||
// Tuned for HDR10/PQ (npl=100) and the common DV+HDR10 case. HLG and bare-DV
|
||||
// (Profile 5, no PQ signalling) get an approximate mapping — zscale linearises
|
||||
// from whatever transfer the stream declares — but the result is still clearly
|
||||
// better than the untonemapped washed-out baseline. A per-transfer chain is a
|
||||
// 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,"
|
||||
|
||||
var (
|
||||
zscaleCacheMu sync.Mutex
|
||||
zscaleCache = map[string]bool{}
|
||||
)
|
||||
|
||||
// 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
|
||||
// tonemapping is simply skipped — the source still plays, just without it.
|
||||
func FFmpegSupportsZscale(ffmpegPath string) bool {
|
||||
if ffmpegPath == "" {
|
||||
return false
|
||||
}
|
||||
zscaleCacheMu.Lock()
|
||||
if v, ok := zscaleCache[ffmpegPath]; ok {
|
||||
zscaleCacheMu.Unlock()
|
||||
return v
|
||||
}
|
||||
zscaleCacheMu.Unlock()
|
||||
|
||||
// Probe OUTSIDE the lock: `ffmpeg -filters` can take a beat, and holding the
|
||||
// mutex across it would stall a concurrent session start. Worst case two
|
||||
// cold callers probe the same binary at once — both write the same bool.
|
||||
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("zscale"))
|
||||
|
||||
zscaleCacheMu.Lock()
|
||||
zscaleCache[ffmpegPath] = supported
|
||||
zscaleCacheMu.Unlock()
|
||||
if supported {
|
||||
log.Printf("[tonemap] ffmpeg has zscale — HDR sources will be tonemapped to SDR")
|
||||
} else {
|
||||
log.Printf("[tonemap] ffmpeg %q lacks zscale — HDR sources play without tonemapping (desaturated)", ffmpegPath)
|
||||
}
|
||||
return supported
|
||||
}
|
||||
118
internal/engine/tonemap_test.go
Normal file
118
internal/engine/tonemap_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hlsArgsFor(hdr string, tonemap bool, hw HWAccel) string {
|
||||
cfg := HLSSessionConfig{
|
||||
SessionID: "test",
|
||||
SourcePath: "/movies/x.mkv",
|
||||
Quality: "720p",
|
||||
Transcode: TranscodeRuntime{
|
||||
FFmpegPath: "/usr/bin/ffmpeg",
|
||||
FFprobePath: "/usr/bin/ffprobe",
|
||||
HWAccel: hw,
|
||||
TonemapHDR: tonemap,
|
||||
},
|
||||
}
|
||||
probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: hdr, DurationSec: 100}
|
||||
return strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " ")
|
||||
}
|
||||
|
||||
func vfChain(joined string) string {
|
||||
parts := strings.Split(joined, " ")
|
||||
for i, p := range parts {
|
||||
if p == "-vf" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTonemap_AppliedForHDRWhenSupported(t *testing.T) {
|
||||
vf := vfChain(hlsArgsFor("HDR10", true, HWAccelNone))
|
||||
if !strings.Contains(vf, "zscale=t=linear") || !strings.Contains(vf, "tonemap=tonemap=hable") {
|
||||
t.Fatalf("HDR + zscale-capable: expected tonemap in -vf, got %q", vf)
|
||||
}
|
||||
// Order: a scale filter, then tonemap (zscale), then format=.
|
||||
scaleIdx := strings.Index(vf, "scale=")
|
||||
zIdx := strings.Index(vf, "zscale=t=linear")
|
||||
fmtIdx := strings.Index(vf, "format=")
|
||||
if !(scaleIdx >= 0 && scaleIdx < zIdx && zIdx < fmtIdx) {
|
||||
t.Errorf("filter order wrong (scale < tonemap < format): %q", vf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTonemap_AppliedInNoDownscaleBranch(t *testing.T) {
|
||||
// Source already within the quality cap → no downscale; tonemap must still
|
||||
// be inserted before format=.
|
||||
cfg := HLSSessionConfig{
|
||||
SessionID: "test",
|
||||
SourcePath: "/movies/x.mkv",
|
||||
Quality: "2160p",
|
||||
Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true},
|
||||
}
|
||||
probe := &StreamProbe{Width: 3840, Height: 2160, HDR: "HDR10", DurationSec: 100}
|
||||
vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
|
||||
if !strings.Contains(vf, "tonemap=tonemap=hable") {
|
||||
t.Errorf("no-downscale branch: expected tonemap, got %q", vf)
|
||||
}
|
||||
if z, f := strings.Index(vf, "zscale=t=linear"), strings.Index(vf, "format="); !(z >= 0 && z < f) {
|
||||
t.Errorf("tonemap must precede format=: %q", vf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTonemap_SkippedWhenFFmpegLacksZscale(t *testing.T) {
|
||||
vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone))
|
||||
if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") {
|
||||
t.Errorf("ffmpeg without zscale: tonemap must be skipped, got %q", vf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTonemap_SkippedForSDR(t *testing.T) {
|
||||
vf := vfChain(hlsArgsFor("", true, HWAccelNone))
|
||||
if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") {
|
||||
t.Errorf("SDR source: no tonemap expected, got %q", vf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTonemap_VAAPIInsertsBeforeHwupload(t *testing.T) {
|
||||
vf := vfChain(hlsArgsFor("HDR10", true, HWAccelVAAPI))
|
||||
if !strings.Contains(vf, "tonemap=tonemap=hable") {
|
||||
t.Fatalf("VAAPI HDR: expected tonemap, got %q", vf)
|
||||
}
|
||||
// Tonemap is a CPU filter — must run before the GPU upload.
|
||||
if up := strings.Index(vf, "hwupload"); up >= 0 {
|
||||
if strings.Index(vf, "zscale=t=linear") > up {
|
||||
t.Errorf("tonemap must precede hwupload: %q", vf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegSupportsZscale_Stub(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
withZ := filepath.Join(dir, "ffmpeg-with.sh")
|
||||
if err := os.WriteFile(withZ, []byte("#!/bin/sh\necho ' .SC zscale V->V'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !FFmpegSupportsZscale(withZ) {
|
||||
t.Error("expected true for an ffmpeg whose -filters lists zscale")
|
||||
}
|
||||
|
||||
noZ := filepath.Join(dir, "ffmpeg-without.sh")
|
||||
if err := os.WriteFile(noZ, []byte("#!/bin/sh\necho ' ... scale V->V'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if FFmpegSupportsZscale(noZ) {
|
||||
t.Error("expected false for an ffmpeg whose -filters omits zscale")
|
||||
}
|
||||
|
||||
if FFmpegSupportsZscale("") {
|
||||
t.Error("empty path must be false")
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ type TranscodeRuntime struct {
|
|||
// browser-friendly. Useful when the user explicitly turns transcoding
|
||||
// off in config.
|
||||
Disabled bool
|
||||
// TonemapHDR enables HDR→SDR tonemapping of HDR sources during transcode.
|
||||
// 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
|
||||
}
|
||||
|
||||
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue