Files
silo-server/internal/chapterthumbs/extractor.go
T
567cdd1a15 feat(playback): balance transcode sessions across multiple GPUs (#425)
* feat(playback): balance transcode sessions across multiple GPUs

playback.hw_device now accepts a comma-separated render-device list (e.g.
"/dev/dri/renderD128,/dev/dri/renderD129"). Each transcode session resolves
the list to one concrete device at spawn — the present device with the
fewest active GPU sessions, ties keeping list order — and holds that device
for its whole lifetime (seek/audio restarts reuse it); the reservation
releases on session shutdown, idempotently, including early spawn-failure
paths. Software-accel sessions never reserve, so they cannot skew the
balance.

A single configured value keeps the historical pass-through contract and an
empty value still auto-detects, so existing deployments are unaffected.
PickRenderDevice is list-aware too, picking least-loaded without reserving,
which lets the non-session consumers (chapter thumbnails, download
artifacts, transcode nodes) spread load best-effort when given a list.

Motivation: hosts with two identical media GPUs (e.g. dual Arc A310)
previously pinned every session to one device while the second sat idle.

* feat(admin): GPU device picker for playback hw_device

The hw-accel detection endpoint now reports render_device_details — each
render device with a human label derived from its sysfs PCI vendor/device
ids ("Intel GPU (0x56a6)") — and the Playback settings page renders them as
per-device toggles instead of requiring a hand-typed device path. No
selection means auto (first available device); one selection pins every
session; multiple selections balance least-loaded. The stored
playback.hw_device value stays the comma-separated list, written in stable
detection order regardless of click order, and a configured-but-undetected
device stays visible so a temporarily missing GPU is not silently dropped
on save.

* fix(playback): make GPU selection and reservation atomic

Review follow-up: resolveSessionHWDevice previously selected the
least-loaded device and incremented its count in two separate critical
sections, so concurrent session starts could all pick the same device
before any reservation landed. Device presence checks now happen outside
the lock and selection + reservation share one critical section; a
concurrency test asserts an exact split across two devices for eight
simultaneous starts, which the two-step version cannot guarantee.

* refactor(playback): one typed GPU acquisition boundary, release on process exit

Replace the CSV-handling spread across resolveSessionHWDevice and
PickRenderDevice with HWDeviceSet + AcquireHWDevice in hwdevice.go: every
GPU workload resolves exactly one device immediately before spawn.
Balancing is explicitly QSV/VAAPI-only — NVENC addresses GPUs by CUDA
index/UUID, so a multi-entry list warns and uses the first entry instead
of collapsing through the path-presence filter. Sessions now release
their reservation only after ffmpeg has been reaped (shutdown waits on
done first), closing the window where a new start could pick a device
the old process still occupied. Render-device sysfs descriptions move to
gpudetect.go so the allocator file owns only selection/reservation.

* fix(downloads): prepared downloads acquire a GPU through the shared pool

PrepareFile resolves the configured hw_device list to one concrete
device via AcquireHWDevice and holds the reservation until ffmpeg exits
(Run is synchronous, so the deferred release is the process-exit
boundary). Download encodes now participate in the same active-load
accounting as streaming sessions instead of best-effort spreading.

* fix(chapterthumbs): resolve hw_device list per extraction via the shared pool

ExtractFrame acquires one concrete device from AcquireHWDevice for the
hardware attempt (released when the attempt finishes) instead of passing
the raw comma-separated value to ffmpeg as a single device path. The
service stops pre-resolving and caching a device at first use — the raw
configured value flows through and each extraction resolves it.

* fix(transcodenode): fresh starts use this node's configured hw_device

/transcode/start constructed TranscodeOpts with an empty HWDevice, so
fresh sessions auto-detected the first GPU and bypassed the configured
list while reconstructed sessions honored it. Both paths now feed the
node-local config value into StartTranscode's shared resolution.

* feat(admin): node-aware GPU inventory on /admin/system/hw-accel

playback.hw_device is one cluster-wide value consumed by every transcode
node, but the endpoint probed only whichever healthy node had the fewest
jobs — an admin could configure devices that don't exist on the other
nodes. The endpoint now probes every healthy node concurrently and
returns a nodes array (URL, name, resolved accel, devices, or probe
error) alongside the backward-compatible flat fields, and the config doc
states the homogeneous-path contract explicitly.

* feat(admin): GPU picker survives empty detection, warns on node divergence

The picker rows are now the union of detected devices and configured
entries, so configured-but-missing devices stay visible (and
deselectable) when detection returns nothing or an older node omits
render_device_details (plain render_devices paths fall back to a generic
label). Per-node inventories from the hw-accel endpoint drive two
warnings: a banner when responding nodes report different device sets,
and a per-row note listing nodes missing that device. The multi-select
is hidden for NVENC — balancing is QSV/VA-API only — with a notice when
a multi-device value is already stored.

* style: gofmt touched files

* fix(playback): release GPU reservations on process exit

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-04 11:33:15 -04:00

229 lines
6.5 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.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
// Resolve a multi-device hw_device list to one concrete GPU for this
// extraction; the reservation spans only the hardware attempt below.
resolvedDevice, releaseHWDevice := playback.AcquireHWDevice(opts.HWDevice, resolvedAccel)
defer releaseHWDevice()
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()
releaseHWDevice()
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
}