unarr/internal/engine/tonemap_test.go
Deivid Soto 5e5a719f27 feat(stream): enable GPU libplacebo in prod image + gate to real GPU
Make libplacebo actually reachable in the shipped agent image, and refuse it
where it would be a regression.

Dockerfile (so a Vulkan-capable host can use the GPU tonemap path):
- install libvulkan1 (the Vulkan loader libplacebo links at runtime; ~150 KB)
- add 'graphics' to NVIDIA_DRIVER_CAPABILITIES so the nvidia container runtime
  mounts the Vulkan ICD (nvidia_icd.json + GLX libs) under --gpus all
Both are inert without a working Vulkan GPU — the functional probe gates use.

hls.go: gate libplacebo on a real HW encoder (HWAccel != none). A software-only
host with mesa would expose lavapipe (CPU Vulkan); the functional probe accepts
it but its tonemap is SLOWER than the zscale CPU chain, so libplacebo there is a
regression. No HW encoder -> stay on zscale.

Verified on the GPU dev box: nvenc session still picks libplacebo (-c:v
h264_nvenc -vf ...,libplacebo=...:tonemapping=bt.2390); new unit test locks the
software-encoder path onto zscale.
2026-06-03 10:42:16 +02:00

176 lines
6.4 KiB
Go

package engine
import (
"os"
"os/exec"
"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_LibplaceboPreferredOverZscale(t *testing.T) {
// HDR source + an ffmpeg with libplacebo on a REAL HW encoder (NVENC) → the
// single GPU filter replaces the whole CPU zscale chain (and the trailing
// format=/setparams it folds in). NVENC (not None) because libplacebo is
// gated on a real GPU — a software encoder stays on zscale.
cfg := HLSSessionConfig{
SessionID: "test",
SourcePath: "/movies/x.mkv",
Quality: "720p",
Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNVENC, 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_LibplaceboSkippedOnSoftwareEncoder(t *testing.T) {
// libplacebo present but no HW encoder (software libx264) → must NOT use
// libplacebo: a software host's only Vulkan would be lavapipe (CPU), slower
// than zscale. Falls back to the zscale chain.
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.Errorf("software encoder must not use libplacebo (lavapipe trap), got %q", vf)
}
if !strings.Contains(vf, "zscale=t=linear") {
t.Errorf("software encoder with HDR + zscale should fall back to the zscale chain, got %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 TestFFmpegSupportsLibplacebo_FunctionalProbe(t *testing.T) {
if FFmpegSupportsLibplacebo("") {
t.Error("empty path must be false")
}
// A bogus path can't run → false (no panic, no hang).
if FFmpegSupportsLibplacebo("/nonexistent/ffmpeg") {
t.Error("nonexistent ffmpeg must be false")
}
// With a real ffmpeg the result is environment-dependent (true only when a
// Vulkan runtime is present), so we only assert the probe completes and
// returns a bool — its whole purpose is to be honest about THIS host.
if _, err := exec.LookPath("ffmpeg"); err == nil {
_ = FFmpegSupportsLibplacebo("ffmpeg") // must not hang or panic
}
}
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")
}
}