unarr/internal/streaming/hwaccel.go

145 lines
4.1 KiB
Go
Raw Permalink Normal View History

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
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
}