unarr/internal/engine/hwaccel.go
Deivid Soto 3b8d77b496 feat(hls): faster first-start — probe cache + tighter encoder presets (0.9.9)
Reduces first-segment latency on cache MISS so the player doesn't sit on
"preparando sesión". Three independent levers:

1. ProbeFile memoised by (path, mtime, size) for 30 min — second play of
   the same source skips ffprobe (1-3 s on 50+ GB MKVs).
2. HLS encoder presets biased for latency over quality:
   - libx264 default veryfast → superfast (~15-20% faster, marginal
     quality loss at 5-25 Mbps target bitrates).
   - NVENC: -preset p4 -tune hq → -preset p3 -tune ll. First-segment
     ~0.8 s on RTX-class GPUs (was ~1.5 s).
   - QSV: -preset medium → -preset veryfast (keeps look_ahead=0).
   - VideoToolbox: adds -realtime 1 (was unset). Bitrate args still
     drive rate control; -q:v dropped to avoid the silent conflict
     where ffmpeg ignored it under -b:v.
3. Per-session log surfaces encoder + accel + preset so "first-start
   was slow" complaints can be triaged from the journal alone.

Diagnostic helpers (DetectHWAccelDiagnostic + HWAccelDiagnostic) added
for future wiring into daemon startup / agent register; users today can
already inspect via `unarr probe-hwaccel`.

Web: AgentsTab profile page now shows the agent's chosen encoder
(amber if software libx264, green if HW) plus the transcode-resolution
cap. Hidden for pre-0.9.9 agents that haven't reported hwAccel.
2026-05-27 10:09:42 +02:00

273 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package engine
import (
"context"
"os"
"os/exec"
"runtime"
"strings"
"sync"
)
// HWAccel identifies a hardware-accelerated ffmpeg encoder family.
type HWAccel string
const (
HWAccelNone HWAccel = "none"
HWAccelNVENC HWAccel = "nvenc" // NVIDIA — h264_nvenc / hevc_nvenc
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync — h264_qsv / hevc_qsv
HWAccelVAAPI HWAccel = "vaapi" // Linux open-source — h264_vaapi / hevc_vaapi
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS — h264_videotoolbox
)
var (
hwOnce sync.Once
hwCache HWAccel
)
// DetectHWAccel returns the most capable hardware encoder available on this
// host, or HWAccelNone if software-only. Cached after first call — adding /
// removing a GPU at runtime is rare and the cost of probing isn't free.
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
hwOnce.Do(func() {
hwCache = detectHWAccelFresh(ctx, ffmpegPath)
})
return hwCache
}
// ResetHWAccelCache clears the singleton — only used in tests.
func ResetHWAccelCache() {
hwOnce = sync.Once{}
hwCache = ""
}
func detectHWAccelFresh(ctx context.Context, ffmpegPath string) HWAccel {
if ffmpegPath == "" {
return HWAccelNone
}
encoders := listFFmpegEncoders(ctx, ffmpegPath)
if encoders == "" {
return HWAccelNone
}
// macOS — VideoToolbox is always available on Apple Silicon + recent Intel.
if runtime.GOOS == "darwin" && strings.Contains(encoders, "h264_videotoolbox") {
return HWAccelVideoToolbox
}
// NVIDIA — encoder presence + a CUDA-capable device. We rely on the
// existence of the device file rather than running nvidia-smi to keep
// startup quick on hosts without nvidia tooling.
if strings.Contains(encoders, "h264_nvenc") &&
(fileExists("/dev/nvidia0") || hasNvidiaDriver()) {
return HWAccelNVENC
}
// Intel Quick Sync — needs /dev/dri (also used by VA-API). Distinguish by
// checking whether the QSV-specific encoder is built in.
if strings.Contains(encoders, "h264_qsv") && fileExists("/dev/dri/renderD128") {
return HWAccelQSV
}
// Linux generic VA-API — works on Intel + AMD with mesa drivers.
if strings.Contains(encoders, "h264_vaapi") && fileExists("/dev/dri/renderD128") {
return HWAccelVAAPI
}
return HWAccelNone
}
func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return string(out)
}
// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
// capabilities so the daemon can log a single coherent line at startup and the
// web side can surface "this agent is software-only" without re-running probes.
type HWAccelDiagnostic struct {
Pick HWAccel // backend selected by DetectHWAccel
FFmpegPath string // resolved ffmpeg binary
FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
Devices []string // device files / drivers detected at probe time
}
// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
// Daemon startup is the natural caller; per-session lookups should keep using
// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
// explicit doctor command.
func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
if ffmpegPath == "" {
return d
}
d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
encoders := listFFmpegEncoders(ctx, ffmpegPath)
for _, name := range hwEncoderNames {
if strings.Contains(encoders, name) {
d.Encoders = append(d.Encoders, name)
}
}
// Device-file checks mirror the picks below so the log line tells the
// reader why a present encoder might still have been rejected (e.g. NVENC
// compiled in but /dev/nvidia0 missing inside a container).
if fileExists("/dev/nvidia0") {
d.Devices = append(d.Devices, "/dev/nvidia0")
}
if fileExists("/dev/dri/renderD128") {
d.Devices = append(d.Devices, "/dev/dri/renderD128")
}
if hasNvidiaDriver() {
d.Devices = append(d.Devices, "nvidia-smi")
}
d.Pick = DetectHWAccel(ctx, ffmpegPath)
return d
}
// LogLine returns a one-line human-readable summary of the diagnostic,
// suitable for daemon startup output. Format:
//
// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
func (d HWAccelDiagnostic) LogLine() string {
var b strings.Builder
b.WriteString("[transcode] ")
if d.FFmpegVersion != "" {
b.WriteString(d.FFmpegVersion)
} else {
b.WriteString("ffmpeg")
}
if d.FFmpegPath != "" {
b.WriteString(" at ")
b.WriteString(d.FFmpegPath)
}
b.WriteString(", HW=")
b.WriteString(string(d.Pick))
if d.Pick == HWAccelNone {
if len(d.Encoders) == 0 {
b.WriteString(" (software libx264) — no HW encoders compiled in")
} else {
b.WriteString(" (software libx264) — encoders found but no matching device: ")
b.WriteString(strings.Join(d.Encoders, ","))
}
} else {
b.WriteString(" (")
b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
b.WriteString(")")
if len(d.Devices) > 0 {
b.WriteString(", devices=")
b.WriteString(strings.Join(d.Devices, ","))
}
}
return b.String()
}
// hwEncoderNames lists the HW-accelerated encoders we care about for the
// startup log. Kept in lookup order so the output reads predictably across
// hosts.
var hwEncoderNames = []string{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_vaapi", "hevc_vaapi",
"h264_videotoolbox", "hevc_videotoolbox",
}
// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
// binary.
func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
out, err := cmd.CombinedOutput()
if err != nil || len(out) == 0 {
return ""
}
line, _, _ := strings.Cut(string(out), "\n")
// "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
// space after "version 6.x" to avoid spamming build flags into the log.
if idx := strings.Index(line, "Copyright"); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
return strings.TrimSpace(line)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func hasNvidiaDriver() bool {
// Cheap proxy — if the user has nvidia-smi on PATH they presumably also
// have a working driver / runtime libraries.
_, err := exec.LookPath("nvidia-smi")
return err == nil
}
// FFmpegVideoCodec returns the encoder name to pass to `-c:v` for the
// requested HW accel + target (h264 or hevc).
func (h HWAccel) FFmpegVideoCodec(target string) string {
target = strings.ToLower(target)
switch h {
case HWAccelNVENC:
if target == "hevc" {
return "hevc_nvenc"
}
return "h264_nvenc"
case HWAccelQSV:
if target == "hevc" {
return "hevc_qsv"
}
return "h264_qsv"
case HWAccelVAAPI:
if target == "hevc" {
return "hevc_vaapi"
}
return "h264_vaapi"
case HWAccelVideoToolbox:
if target == "hevc" {
return "hevc_videotoolbox"
}
return "h264_videotoolbox"
default:
// Software fallback. libx264 ships with every ffmpeg build.
return "libx264"
}
}
// H264LevelForHeight returns the lowest H.264 profile level capable of
// encoding a stream at the given output pixel height. Each tier carries
// enough macroblock headroom to handle ANAMORPHIC content (up to ~2.4:1
// cinemascope) at 30 fps — a fixed 16:9 assumption used to silently bust
// the level on a 720p movie shot in 2.4:1 (1728×720 = 4860 MBs > 3.1's
// 3600 limit; libx264 logs "frame MB size > level limit" and emits a
// corrupt stream).
func H264LevelForHeight(height int) string {
switch {
case height <= 0:
// Unknown source — pick a level that covers up to 4K so we never
// re-introduce the silent-failure mode that motivated this helper.
return "5.1"
case height <= 480:
return "3.1"
case height <= 720:
// 4.0 instead of 3.1: covers 720p anamorphic (e.g. 1728×720) +
// MB rate up to 245k/s (3.1 caps at 108k/s — broken at 24 fps).
return "4.0"
case height <= 1080:
// 4.1 instead of 4.0: covers 1080p anamorphic + 30 fps (~245k MBs/s).
return "4.1"
case height <= 1440:
return "5.0"
case height <= 2160:
return "5.1"
default:
// 4K @ 60 fps and 8K all fall under 6.x.
return "6.0"
}
}