Files
silo-server/internal/chapterthumbs/extractor_test.go
d5a8cd5294 fix(chapterthumbs): add software HDR tone-map fallback (#576)
* fix(chapterthumbs): add software HDR tone-map fallback

* fix(chapterthumbs): tighten filter capability detection

* fix(chapterthumbs): harden tone-map fallback retries

* fix(chapterthumbs): coalesce filter probes

* fix(chapterthumbs): name disabled accelerator

* feat(chapterthumbs): add CPU tone-map toggle

* fix(config): reuse CPU tone-map setting key

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-11 12:13:06 -04:00

612 lines
20 KiB
Go

package chapterthumbs
import (
"context"
"errors"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSoftwareToneMapFilterResolver(t *testing.T) {
tests := []struct {
name string
output string
probeErr error
wantFilter string
wantReason string
wantError string
wantCalls int
}{
{
name: "prefers tonemapx bt2390",
output: " .S. zscale V->V\n .S. tonemap V->V\n .S. tonemapx V->V\n",
wantFilter: softwareToneMapFilterBT2390,
wantCalls: 1,
},
{
name: "falls back to standard hable",
output: " .S. zscale V->V\n .S. tonemap V->V\n",
wantFilter: softwareToneMapFilterHable,
wantCalls: 1,
},
{
name: "requires zscale",
output: " .S. tonemapx V->V\n",
wantReason: reasonToneMapUnsupported,
wantError: "lacks the required zscale filter",
wantCalls: 1,
},
{
name: "requires a tone map filter",
output: " .S. zscale V->V Description mentions tonemap but does not provide it.\n",
wantReason: reasonToneMapUnsupported,
wantError: "lacks the required tonemapx or tonemap filter",
wantCalls: 1,
},
{
name: "does not cache transient probe failure",
output: "probe stderr",
probeErr: errors.New("exit status 1"),
wantReason: reasonFFmpegProbeFailed,
wantError: "FFmpeg filter probe failed: exit status 1: probe stderr",
wantCalls: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := 0
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
calls++
if cacheKey := softwareToneMapCacheKey(ffmpegPath); cacheKey != "/test/ffmpeg" {
t.Fatalf("ffmpegPath = %q, cache key = %q, want /test/ffmpeg", ffmpegPath, cacheKey)
}
return []byte(tt.output), tt.probeErr
})
for _, ffmpegPath := range []string{"/test/ffmpeg", "/test/../test/ffmpeg"} {
filter, reason, err := resolver.resolve(ffmpegPath)
if tt.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("resolve() error = %v, want containing %q", err, tt.wantError)
}
if reason != tt.wantReason {
t.Fatalf("resolve() reason = %q, want %q", reason, tt.wantReason)
}
continue
}
if err != nil {
t.Fatalf("resolve() error = %v", err)
}
if filter != tt.wantFilter {
t.Fatalf("resolve() filter = %q, want %q", filter, tt.wantFilter)
}
if reason != "" {
t.Fatalf("resolve() reason = %q, want empty", reason)
}
}
if calls != tt.wantCalls {
t.Fatalf("probe calls = %d, want %d", calls, tt.wantCalls)
}
})
}
}
func TestSoftwareToneMapFilterResolverKeepsExecutableSeparateFromCacheKey(t *testing.T) {
workingDirectory, err := filepath.Abs(".")
if err != nil {
t.Fatalf("resolve working directory: %v", err)
}
calls := 0
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
calls++
if ffmpegPath != "./ffmpeg" {
t.Fatalf("probe path = %q, want ./ffmpeg", ffmpegPath)
}
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
})
for _, ffmpegPath := range []string{"./ffmpeg", filepath.Join(workingDirectory, "ffmpeg")} {
filter, reason, err := resolver.resolve(ffmpegPath)
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
t.Fatalf("resolve(%q) = %q, %q, %v", ffmpegPath, filter, reason, err)
}
}
if calls != 1 {
t.Fatalf("probe calls = %d, want 1 cached call", calls)
}
}
func TestSoftwareToneMapFilterResolverCoalescesTransientProbeFailures(t *testing.T) {
const callers = 8
probeStarted := make(chan struct{})
releaseProbe := make(chan struct{})
var probeCalls atomic.Int32
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
if probeCalls.Add(1) == 1 {
close(probeStarted)
<-releaseProbe
return []byte("temporary stderr"), errors.New("resource temporarily unavailable")
}
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
})
type resolveResult struct {
filter string
reason string
err error
}
results := make(chan resolveResult, callers)
start := make(chan struct{})
var callersReady sync.WaitGroup
callersReady.Add(callers)
for range callers {
go func() {
callersReady.Done()
<-start
filter, reason, err := resolver.resolve("/test/ffmpeg")
results <- resolveResult{filter: filter, reason: reason, err: err}
}()
}
callersReady.Wait()
close(start)
<-probeStarted
deadline := time.Now().Add(time.Second)
for {
resolver.mu.Lock()
call := resolver.inFlight["/test/ffmpeg"]
waiters := 0
if call != nil {
waiters = call.waiters
}
resolver.mu.Unlock()
if waiters == callers-1 {
break
}
if time.Now().After(deadline) {
t.Fatalf("coalesced waiters = %d, want %d", waiters, callers-1)
}
runtime.Gosched()
}
close(releaseProbe)
for range callers {
result := <-results
if result.filter != "" || result.reason != reasonFFmpegProbeFailed || result.err == nil {
t.Fatalf("resolve() = %q, %q, %v, want shared transient failure", result.filter, result.reason, result.err)
}
}
if calls := probeCalls.Load(); calls != 1 {
t.Fatalf("concurrent probe calls = %d, want 1", calls)
}
filter, reason, err := resolver.resolve("/test/ffmpeg")
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
t.Fatalf("retry resolve() = %q, %q, %v", filter, reason, err)
}
if calls := probeCalls.Load(); calls != 2 {
t.Fatalf("probe calls after retry = %d, want 2", calls)
}
filter, reason, err = resolver.resolve("/test/ffmpeg")
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
t.Fatalf("cached resolve() = %q, %q, %v", filter, reason, err)
}
if calls := probeCalls.Load(); calls != 2 {
t.Fatalf("probe calls after cached resolve = %d, want 2", calls)
}
}
func TestExtractFrameSoftwareHDRWithoutHardware(t *testing.T) {
resolver := resolverWithFilters(t, "zscale", "tonemapx")
var remaining time.Duration
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
FFmpegPath: "/test/ffmpeg",
HWAccel: hwAccelNone,
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("software HDR extraction has no deadline")
}
remaining = time.Until(deadline)
if !slices.Contains(args, softwareToneMapFilterBT2390) {
t.Fatalf("software extraction args missing BT.2390 filter: %#v", args)
}
return []byte("frame"), nil
},
})
if err != nil {
t.Fatalf("ExtractFrame() error = %v", err)
}
if reason != "" {
t.Fatalf("ExtractFrame() reason = %q, want empty", reason)
}
if string(data) != "frame" {
t.Fatalf("ExtractFrame() data = %q, want frame", data)
}
assertApproximateDeadline(t, remaining, cpuExtractTimeoutHDR)
}
func TestExtractFrameSoftwareHDRDisabledByDefault(t *testing.T) {
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
t.Fatal("software filter probe should not run while CPU tone mapping is disabled")
return nil, nil
})
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
FFmpegPath: "/test/ffmpeg",
HWAccel: hwAccelNone,
ToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
t.Fatal("CPU extraction should not run while CPU tone mapping is disabled")
return nil, nil
},
})
if err == nil || !strings.Contains(err.Error(), "software HDR tone mapping is disabled") {
t.Fatalf("ExtractFrame() error = %v, want disabled software tone-map error", err)
}
if reason != reasonToneMapUnsupported {
t.Fatalf("ExtractFrame() reason = %q, want %q", reason, reasonToneMapUnsupported)
}
}
func TestExtractFrameUnsupportedHardwareUsesCPU(t *testing.T) {
var args []string
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "nvenc",
RunFunc: func(_ context.Context, _ string, got []string) ([]byte, error) {
args = append([]string(nil), got...)
return []byte("frame"), nil
},
})
if err != nil || reason != "" || string(data) != "frame" {
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
}
joined := strings.Join(args, " ")
if strings.Contains(joined, "-hwaccel") || strings.Contains(joined, "nvenc") {
t.Fatalf("unsupported chapter-thumbnail accelerator used hardware args: %s", joined)
}
}
func TestExtractFrameUnsupportedHardwarePreservesSDRRetry(t *testing.T) {
calls := 0
var firstRemaining, retryRemaining time.Duration
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "nvenc",
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
calls++
deadline, ok := ctx.Deadline()
if !ok {
t.Fatalf("extraction attempt %d has no deadline", calls)
}
if strings.Contains(strings.Join(args, " "), "-hwaccel") {
t.Fatalf("unsupported accelerator used hardware args: %#v", args)
}
if calls == 1 {
firstRemaining = time.Until(deadline)
return nil, errors.New("transient software decode failure")
}
retryRemaining = time.Until(deadline)
return []byte("frame"), nil
},
})
if err != nil || reason != "" || string(data) != "frame" {
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
}
if calls != 2 {
t.Fatalf("extract calls = %d, want 2", calls)
}
assertApproximateDeadline(t, firstRemaining, hwExtractTimeoutSDR)
assertApproximateDeadline(t, retryRemaining, cpuExtractTimeoutSDR)
}
func TestExtractFrameProbesAndRunsSameRelativeFFmpeg(t *testing.T) {
var probedPath, extractPath string
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
probedPath = ffmpegPath
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
})
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
FFmpegPath: " ./ffmpeg ",
HWAccel: hwAccelNone,
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(_ context.Context, ffmpegPath string, _ []string) ([]byte, error) {
extractPath = ffmpegPath
return []byte("frame"), nil
},
})
if err != nil || reason != "" {
t.Fatalf("ExtractFrame() reason = %q, error = %v", reason, err)
}
if probedPath != "./ffmpeg" || extractPath != "./ffmpeg" {
t.Fatalf("probe path = %q, extract path = %q, want ./ffmpeg for both", probedPath, extractPath)
}
}
func TestExtractFrameRetriesTransientSoftwareProbeFailure(t *testing.T) {
probeCalls := 0
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
probeCalls++
if probeCalls == 1 {
return []byte("temporary stderr"), errors.New("resource temporarily unavailable")
}
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
})
opts := FrameExtractOptions{
InputPath: "/media/movie.mkv",
FFmpegPath: "/test/ffmpeg",
HWAccel: hwAccelNone,
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
return []byte("frame"), nil
},
}
if _, reason, err := ExtractFrame(context.Background(), opts); err == nil || reason != reasonFFmpegProbeFailed {
t.Fatalf("first ExtractFrame() reason = %q, error = %v, want retryable probe failure", reason, err)
}
data, reason, err := ExtractFrame(context.Background(), opts)
if err != nil || reason != "" || string(data) != "frame" {
t.Fatalf("second ExtractFrame() = %q, %q, %v", data, reason, err)
}
if probeCalls != 2 {
t.Fatalf("probe calls = %d, want 2", probeCalls)
}
}
func TestExtractFrameHardwareHDRSuccessSkipsSoftwareProbe(t *testing.T) {
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
t.Fatal("software filter probe should not run after hardware success")
return nil, nil
})
calls := 0
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "vaapi",
HWDevice: "/dev/dri/renderD128",
ToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(_ context.Context, _ string, args []string) ([]byte, error) {
calls++
if !strings.Contains(strings.Join(args, " "), "tonemap_vaapi") {
t.Fatalf("hardware extraction args missing VAAPI tone map: %#v", args)
}
return []byte("frame"), nil
},
})
if err != nil || reason != "" || string(data) != "frame" {
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
}
if calls != 1 {
t.Fatalf("extract calls = %d, want 1", calls)
}
}
func TestExtractFrameHardwareHDRFailureDoesNotUseSoftwareWhenDisabled(t *testing.T) {
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
t.Fatal("software filter probe should not run while CPU tone mapping is disabled")
return nil, nil
})
calls := 0
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
HWAccel: hwAccelVAAPI,
HWDevice: "/dev/dri/renderD128",
ToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
calls++
return nil, context.DeadlineExceeded
},
})
if err == nil {
t.Fatal("ExtractFrame() error = nil, want hardware failure")
}
if reason != "hw_timeout" {
t.Fatalf("ExtractFrame() reason = %q, want hw_timeout", reason)
}
if calls != 1 {
t.Fatalf("extract calls = %d, want hardware attempt only", calls)
}
}
func TestExtractFrameHardwareHDRFailureFallsBackWithFreshDeadline(t *testing.T) {
resolver := resolverWithFilters(t, "zscale", "tonemapx")
calls := 0
var hwRemaining, cpuRemaining time.Duration
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "vaapi",
HWDevice: "/dev/dri/renderD128",
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
calls++
deadline, ok := ctx.Deadline()
if !ok {
t.Fatalf("extraction attempt %d has no deadline", calls)
}
if calls == 1 {
hwRemaining = time.Until(deadline)
return nil, context.DeadlineExceeded
}
cpuRemaining = time.Until(deadline)
if !slices.Contains(args, softwareToneMapFilterBT2390) {
t.Fatalf("CPU fallback args missing BT.2390 filter: %#v", args)
}
return []byte("frame"), nil
},
})
if err != nil || reason != "" || string(data) != "frame" {
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
}
if calls != 2 {
t.Fatalf("extract calls = %d, want 2", calls)
}
assertApproximateDeadline(t, hwRemaining, hwExtractTimeoutHDR)
assertApproximateDeadline(t, cpuRemaining, cpuExtractTimeoutHDR)
}
func TestExtractFrameInvalidHardwareMediaDoesNotRetryOnCPU(t *testing.T) {
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
t.Fatal("software filter probe should not run for invalid media")
return nil, nil
})
calls := 0
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "vaapi",
HWDevice: "/dev/dri/renderD128",
ToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
calls++
return nil, errors.New("Invalid NAL unit size")
},
})
if err == nil {
t.Fatal("ExtractFrame() error = nil, want invalid-media error")
}
if reason != "decode_invalid_data" {
t.Fatalf("ExtractFrame() reason = %q, want decode_invalid_data", reason)
}
if calls != 1 {
t.Fatalf("extract calls = %d, want 1", calls)
}
}
func TestExtractFrameMissingSoftwareFiltersIsActionable(t *testing.T) {
resolver := resolverWithFilters(t, "zscale")
calls := 0
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: hwAccelNone,
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
calls++
return nil, nil
},
})
if err == nil || !strings.Contains(err.Error(), "lacks the required tonemapx or tonemap filter") {
t.Fatalf("ExtractFrame() error = %v, want actionable missing-filter error", err)
}
if reason != "tonemap_unsupported" {
t.Fatalf("ExtractFrame() reason = %q, want tonemap_unsupported", reason)
}
if calls != 0 {
t.Fatalf("extract calls = %d, want 0", calls)
}
}
func TestExtractFramePreservesHardwareAndCPUFailures(t *testing.T) {
resolver := resolverWithFilters(t, "zscale", "tonemap")
calls := 0
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
InputPath: "/media/movie.mkv",
SeekSeconds: 42.5,
HWAccel: "vaapi",
HWDevice: "/dev/dri/renderD128",
ToneMap: true,
AllowSoftwareToneMap: true,
softwareToneMapResolver: resolver,
RunFunc: func(context.Context, string, []string) ([]byte, error) {
calls++
if calls == 1 {
return nil, context.DeadlineExceeded
}
return nil, errors.New("software decode failed")
},
})
if err == nil {
t.Fatal("ExtractFrame() error = nil, want combined failure")
}
if reason != "chapter_extract_failed" {
t.Fatalf("ExtractFrame() reason = %q, want chapter_extract_failed", reason)
}
for _, want := range []string{"hardware extraction failed", "hw_timeout", "cpu fallback failed", "software decode failed"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("ExtractFrame() error = %q, want containing %q", err, want)
}
}
}
func TestRemoteExtractTimeoutBudgetsOnlyAllowedAttempts(t *testing.T) {
sdrExtractBudget := extractTimeoutForAttempt(true, false) + extractTimeoutForAttempt(false, false)
if got := remoteExtractTimeout(false, false); got <= sdrExtractBudget {
t.Fatalf("remoteExtractTimeout(SDR) = %s, want more than extract budget %s", got, sdrExtractBudget)
}
hardwareHDRBudget := extractTimeoutForAttempt(true, true)
softwareHDRBudget := extractTimeoutForAttempt(false, true)
if got := remoteExtractTimeout(true, false); got <= hardwareHDRBudget || got >= hardwareHDRBudget+softwareHDRBudget {
t.Fatalf("remoteExtractTimeout(HDR hardware only) = %s, want hardware plus overhead without CPU budget", got)
}
hdrExtractBudget := hardwareHDRBudget + softwareHDRBudget
if got := remoteExtractTimeout(true, true); got-hdrExtractBudget <= softwareToneMapProbeTimeout {
t.Fatalf(
"remoteExtractTimeout(HDR with CPU) overhead = %s, want more than probe budget %s",
got-hdrExtractBudget,
softwareToneMapProbeTimeout,
)
}
}
func TestRemoteProbeFailureAllowsPreferredLocalFallback(t *testing.T) {
if !isInfrastructureRemoteFailure(reasonFFmpegProbeFailed) {
t.Fatalf("isInfrastructureRemoteFailure(%q) = false, want true", reasonFFmpegProbeFailed)
}
}
func resolverWithFilters(t *testing.T, filters ...string) *softwareToneMapFilterResolver {
t.Helper()
lines := make([]string, 0, len(filters))
for _, filter := range filters {
lines = append(lines, " .S. "+filter+" V->V")
}
output := strings.Join(lines, "\n")
return newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
return []byte(output), nil
})
}
func assertApproximateDeadline(t *testing.T, got time.Duration, want time.Duration) {
t.Helper()
if got < want-time.Second || got > want+time.Second {
t.Fatalf("deadline remaining = %s, want about %s", got, want)
}
}