unarr/internal/streaming/hwaccel.go
Deivid Soto 75dcc0f1cb feat(streaming): ffmpeg transcoding pipeline (direct play / fMP4 / HW accel)
The browser-side WebRTC reproductor needs MP4 / H.264 / AAC / yuv420p to
keep MSE happy. This package decides per request whether to:

  • direct-play  — input already MSE-compatible, just remux to fMP4
  • transcode    — re-encode video (libx264 / NVENC / QSV / VAAPI /
                   VideoToolbox) + audio (AAC), fragment to fMP4

Pieces:

- internal/streaming/transcoder.go — AnalyzeCompatibility decides the
  recipe from a parsed mediainfo. CompatibilityReport carries the reasons
  so the player UI can show "transcoding video: HEVC → H.264".

- internal/streaming/ffmpeg_args.go — BuildFFmpegArgs assembles the argv
  for ffmpeg. Direct play uses `-c copy`; transcode uses libx264 or the
  selected HW encoder. Output is always fragmented MP4 piped to stdout
  (-movflags frag_keyframe+empty_moov+default_base_moof) so the HTTP
  handler can stream straight to the browser without disk I/O.

  Quality ladder: 480p (1.5Mb), 720p (3.5Mb), 1080p (6Mb), 2160p (25Mb).
  Default 1080p when unset / unknown. -ss seek for resume / scrubbing.

- internal/streaming/hwaccel.go — DetectHWAccel runs `ffmpeg -encoders`
  once per process and caches the best available. Order: NVENC → QSV →
  VAAPI → VideoToolbox → libx264. VAAPI is the only family that wires up
  HW decode too (`-hwaccel vaapi`); the others software-decode and HW-
  encode (works fine and avoids /dev/dri permission rabbit holes).

- internal/streaming/stream.go — Transcoder facade wires Analyze + Stream
  together for the API handler in Fase 4. Captures the last 8 KiB of
  ffmpeg stderr for diagnosable errors without unbounded memory.

Tests (20 unit, all green):
- AnalyzeCompatibility: h264+aac direct, video-only direct, HEVC →
  transcode, 10-bit HDR → transcode, EAC3 audio → transcode, nil guards
- ResolveQuality: empty + unknown fallback to 1080p, 4-step ladder
- BuildFFmpegArgs: direct play -c copy, transcode libx264 + bitrate +
  scale, NVENC swaps encoder & drops preset, VAAPI injects -hwaccel +
  scale_vaapi, -ss timestamp formatting
- HWAccel: encoder-name table, VAAPI is the only one with HW decode
- formatDuration: zero, sub-second, HH:MM:SS, negative-clamped
- cappedBuffer: tail retention through multi-write and large-write paths
- NewTranscoder: rejects empty paths
2026-05-06 11:34:57 +02:00

144 lines
4.1 KiB
Go

package streaming
import (
"context"
"os/exec"
"runtime"
"strings"
"sync"
"time"
)
// HWAccel identifies which hardware encoder family the host can use.
type HWAccel string
const (
HWAccelUnset HWAccel = ""
HWAccelNone HWAccel = "none" // explicit software libx264
HWAccelNVENC HWAccel = "nvenc" // NVIDIA GPUs
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync (Linux/Win)
HWAccelVAAPI HWAccel = "vaapi" // Intel/AMD GPUs on Linux
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS native
)
// VideoEncoder returns the ffmpeg `-c:v` argument for this accelerator.
func (h HWAccel) VideoEncoder() string {
switch h {
case HWAccelNVENC:
return "h264_nvenc"
case HWAccelQSV:
return "h264_qsv"
case HWAccelVAAPI:
return "h264_vaapi"
case HWAccelVideoToolbox:
return "h264_videotoolbox"
default:
return "libx264"
}
}
// HasDecoder reports whether the accelerator also supports HW decode.
// We always feed encoders software-decoded frames except for VAAPI where
// the GPU pipeline expects HW-decoded surfaces end-to-end.
func (h HWAccel) HasDecoder() bool {
return h == HWAccelVAAPI
}
// DecoderArgs returns the ffmpeg flags that enable HW decode for this
// accelerator. Only meaningful when HasDecoder() == true.
func (h HWAccel) DecoderArgs() []string {
if h == HWAccelVAAPI {
return []string{
"-hwaccel", "vaapi",
"-hwaccel_device", "/dev/dri/renderD128",
"-hwaccel_output_format", "vaapi",
}
}
return nil
}
// detectedHWAccel caches the result of DetectHWAccel so we don't fork
// ffmpeg on every transcode request.
var (
detectedHWAccelOnce sync.Once
detectedHWAccel HWAccel
)
// DetectHWAccel asks ffmpeg what encoders it supports and returns the
// best available. Result is cached for the process lifetime — callers
// should construct the Transcoder once and reuse it.
//
// Detection order (best perf → fallback):
// 1. NVENC (NVIDIA GPU + CUDA driver)
// 2. QSV (Intel iGPU/dGPU + libmfx/intel-media-driver)
// 3. VAAPI (Linux Intel/AMD via /dev/dri)
// 4. VideoToolbox (macOS only)
// 5. None (fallback to libx264 software)
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
detectedHWAccelOnce.Do(func() {
detectedHWAccel = doDetectHWAccel(ctx, ffmpegPath)
})
return detectedHWAccel
}
// ResetHWAccelCache forces the next DetectHWAccel call to re-probe.
// Intended for tests.
func ResetHWAccelCache() {
detectedHWAccelOnce = sync.Once{}
detectedHWAccel = HWAccelUnset
}
func doDetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
}
// macOS videotoolbox is reliable enough that we don't bother probing
// — every Apple Silicon Mac has it; Intel Macs since 10.13 do too.
if runtime.GOOS == "darwin" {
if encoderAvailable(ctx, ffmpegPath, "h264_videotoolbox") {
return HWAccelVideoToolbox
}
}
for _, candidate := range []struct {
Name HWAccel
Encoder string
}{
{HWAccelNVENC, "h264_nvenc"},
{HWAccelQSV, "h264_qsv"},
{HWAccelVAAPI, "h264_vaapi"},
} {
if encoderAvailable(ctx, ffmpegPath, candidate.Encoder) {
return candidate.Name
}
}
return HWAccelNone
}
// encoderAvailable returns true when `ffmpeg -hide_banner -encoders`
// lists the named encoder.
//
// Note: this only verifies ffmpeg was COMPILED with the encoder. It does
// NOT guarantee the host hardware works at runtime — some users will see
// libx264 fall back at the first failed encode. That's OK; the worst
// case is a one-time slow request.
func encoderAvailable(ctx context.Context, ffmpegPath, encoder string) bool {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
out, err := cmd.Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
// `-encoders` output looks like:
// V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC
fields := strings.Fields(line)
if len(fields) >= 2 && fields[1] == encoder {
return true
}
}
return false
}