225 lines
6.2 KiB
Go
225 lines
6.2 KiB
Go
package chapterthumbs
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
)
|
|
|
|
type FrameExtractOptions struct {
|
|
InputPath string
|
|
SeekSeconds float64
|
|
FFmpegPath string
|
|
HWAccel string
|
|
HWDevice string
|
|
ToneMap bool
|
|
RunFunc func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
|
|
}
|
|
|
|
func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string, error) {
|
|
ffmpegPath := opts.FFmpegPath
|
|
if ffmpegPath == "" {
|
|
ffmpegPath = "ffmpeg"
|
|
}
|
|
runExtract := opts.RunFunc
|
|
if runExtract == nil {
|
|
runExtract = runFFmpegFrameExtract
|
|
}
|
|
|
|
resolvedAccel := playback.ResolveHWAccel(opts.HWAccel)
|
|
resolvedDevice := opts.HWDevice
|
|
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
|
|
resolvedDevice = playback.PickRenderDevice("")
|
|
}
|
|
|
|
if resolvedAccel != "none" {
|
|
args, buildErr := buildFrameExtractArgs(opts.InputPath, opts.SeekSeconds, resolvedAccel, resolvedDevice, opts.ToneMap)
|
|
if buildErr != nil {
|
|
if opts.ToneMap {
|
|
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", buildErr)
|
|
}
|
|
} else {
|
|
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(true, opts.ToneMap))
|
|
data, err := runExtract(attemptCtx, ffmpegPath, args)
|
|
cancel()
|
|
if err == nil {
|
|
return data, "", nil
|
|
}
|
|
|
|
hwReason := classifyExtractError("hw", err)
|
|
if opts.ToneMap {
|
|
return nil, hwReason, wrapReason(hwReason, err)
|
|
}
|
|
|
|
cpuData, cpuReason, cpuErr := extractFrameCPU(
|
|
ctx,
|
|
opts.InputPath,
|
|
opts.SeekSeconds,
|
|
false,
|
|
runExtract,
|
|
ffmpegPath,
|
|
)
|
|
if cpuErr == nil {
|
|
return cpuData, "", nil
|
|
}
|
|
return nil, cpuReason, fmt.Errorf("hardware extraction failed: %v; cpu fallback failed: %w", wrapReason(hwReason, err), cpuErr)
|
|
}
|
|
}
|
|
|
|
if opts.ToneMap {
|
|
err := errors.New("hardware HDR tone mapping unavailable")
|
|
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", err)
|
|
}
|
|
|
|
return extractFrameCPU(ctx, opts.InputPath, opts.SeekSeconds, opts.ToneMap, runExtract, ffmpegPath)
|
|
}
|
|
|
|
func extractFrameCPU(
|
|
ctx context.Context,
|
|
inputPath string,
|
|
seekSeconds float64,
|
|
toneMap bool,
|
|
runExtract func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error),
|
|
ffmpegPath string,
|
|
) ([]byte, string, error) {
|
|
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(false, toneMap))
|
|
defer cancel()
|
|
data, err := runExtract(attemptCtx, ffmpegPath, buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap))
|
|
if err != nil {
|
|
reason := classifyExtractError("cpu", err)
|
|
return nil, reason, wrapReason(reason, err)
|
|
}
|
|
return data, "", nil
|
|
}
|
|
|
|
func classifyExtractError(stage string, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := err.Error()
|
|
lower := strings.ToLower(message)
|
|
switch {
|
|
case strings.Contains(message, "No such filter") || strings.Contains(message, "tonemap") && strings.Contains(message, "Error"):
|
|
return "tonemap_unsupported"
|
|
case strings.Contains(lower, "invalid nal unit size"),
|
|
strings.Contains(lower, "error splitting the input into nal units"),
|
|
strings.Contains(lower, "invalid data found when processing input"),
|
|
strings.Contains(lower, "invalid as first byte of an ebml number"):
|
|
return "decode_invalid_data"
|
|
case stage == "hw" && strings.Contains(message, "signal: killed"):
|
|
return "hw_killed"
|
|
case stage == "hw" && isDeadlineError(err):
|
|
return "hw_timeout"
|
|
case stage == "cpu" && isDeadlineError(err):
|
|
return "cpu_timeout"
|
|
default:
|
|
return "chapter_extract_failed"
|
|
}
|
|
}
|
|
|
|
func isDeadlineError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), context.DeadlineExceeded.Error())
|
|
}
|
|
|
|
func extractTimeoutForAttempt(hardware bool, hdr bool) time.Duration {
|
|
if hardware {
|
|
if hdr {
|
|
return hwExtractTimeoutHDR
|
|
}
|
|
return hwExtractTimeoutSDR
|
|
}
|
|
if hdr {
|
|
return cpuExtractTimeoutHDR
|
|
}
|
|
return cpuExtractTimeoutSDR
|
|
}
|
|
|
|
func buildCPUFrameExtractArgs(inputPath string, seekSeconds float64, toneMap bool) []string {
|
|
args := []string{
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-ss", fmt.Sprintf("%.3f", seekSeconds),
|
|
"-i", inputPath,
|
|
}
|
|
if toneMap {
|
|
args = append(args, "-vf", "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=bt2390,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p")
|
|
}
|
|
args = append(args,
|
|
"-frames:v", "1",
|
|
"-f", "image2pipe",
|
|
"-vcodec", "mjpeg",
|
|
"-",
|
|
)
|
|
return args
|
|
}
|
|
|
|
func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string, hwDevice string, toneMap bool) ([]string, error) {
|
|
args := []string{
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
}
|
|
switch hwAccel {
|
|
case "qsv":
|
|
if hwDevice == "" {
|
|
return nil, fmt.Errorf("qsv requires a render device")
|
|
}
|
|
args = append(args,
|
|
"-init_hw_device", fmt.Sprintf("vaapi=va:%s,driver=iHD,kernel_driver=i915,vendor_id=0x8086", hwDevice),
|
|
"-init_hw_device", "qsv=qs@va",
|
|
"-filter_hw_device", "va",
|
|
"-hwaccel", "vaapi",
|
|
"-hwaccel_output_format", "vaapi",
|
|
)
|
|
case "vaapi":
|
|
if hwDevice == "" {
|
|
return nil, fmt.Errorf("vaapi requires a render device")
|
|
}
|
|
args = append(args,
|
|
"-init_hw_device", fmt.Sprintf("vaapi=hw:%s", hwDevice),
|
|
"-filter_hw_device", "hw",
|
|
"-hwaccel", "vaapi",
|
|
"-hwaccel_output_format", "vaapi",
|
|
)
|
|
default:
|
|
return buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap), nil
|
|
}
|
|
|
|
filter := "hwdownload,format=nv12"
|
|
if toneMap {
|
|
filter = "setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc,procamp_vaapi=b=16:c=1,tonemap_vaapi=format=nv12:p=bt709:t=bt709:m=bt709,hwdownload,format=nv12"
|
|
}
|
|
args = append(args,
|
|
"-ss", fmt.Sprintf("%.3f", seekSeconds),
|
|
"-i", inputPath,
|
|
"-vf", filter,
|
|
"-frames:v", "1",
|
|
"-f", "image2pipe",
|
|
"-vcodec", "mjpeg",
|
|
"-",
|
|
)
|
|
return args, nil
|
|
}
|
|
|
|
func runFFmpegFrameExtract(ctx context.Context, ffmpegPath string, args []string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg extract frame: %w (%s)", err, stderr.String())
|
|
}
|
|
if stdout.Len() == 0 {
|
|
return nil, fmt.Errorf("ffmpeg extract frame: empty output")
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|