* 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>
612 lines
20 KiB
Go
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)
|
|
}
|
|
}
|