Compare commits

...
Author SHA1 Message Date
Quick 084a77c34f fix(playback): probe nvenc before auto-selecting 2026-06-07 20:10:40 -04:00
thezak48 b84e2cde1b feat(playback): NVENC support for transcoding 2026-06-08 00:42:41 +01:00
13 changed files with 677 additions and 31 deletions
+9
View File
@@ -1,6 +1,12 @@
# Silo Docker deployment
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
# Linux/macOS example for enabling NVIDIA override:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
# Example: MEDIA_ROOT=/srv/media
MEDIA_ROOT=/srv/media
@@ -40,6 +46,9 @@ POSTGRES_SHM_SIZE=8gb
# PROXY_PORT=8083
# TRANSCODE_PORT=8082
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
# NVIDIA_GPU_COUNT=1
# Run from source / advanced overrides
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
+23
View File
@@ -34,6 +34,29 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
### Optional NVIDIA/NVENC
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
Use the optional override file when you want NVENC:
```sh
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
```
If you want this controlled from `.env`, set `COMPOSE_FILE`:
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
NVIDIA_GPU_COUNT=1
```
Windows uses `;` instead of `:` between compose files.
Then `docker compose up -d` will include the NVIDIA override automatically.
4. **Configure through the admin UI**
Add libraries, users, metadata providers, and playback settings from the web interface.
+20
View File
@@ -0,0 +1,20 @@
services:
silo:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: ${NVIDIA_GPU_COUNT:-1}
capabilities: [gpu]
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-transcode:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: ${NVIDIA_GPU_COUNT:-1}
# capabilities: [gpu]
+4 -2
View File
@@ -17,14 +17,16 @@ import (
type SystemHandler struct {
transcodePool *nodepool.TranscodePool
jwtSecret string
ffmpegPath string
buildInfo buildinfo.Info
}
// NewSystemHandler creates a SystemHandler.
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string) *SystemHandler {
func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, ffmpegPath string) *SystemHandler {
return &SystemHandler{
transcodePool: transcodePool,
jwtSecret: jwtSecret,
ffmpegPath: ffmpegPath,
buildInfo: buildinfo.Current(),
}
}
@@ -45,7 +47,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
}
}
info := playback.DetectHWAccel()
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
writeJSON(w, http.StatusOK, info)
}
+3 -1
View File
@@ -2102,10 +2102,12 @@ func NewRouter(deps Dependencies) chi.Router {
// System inspection.
{
sysJWTSecret := ""
sysFFmpegPath := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
sysFFmpegPath = deps.Config.Playback.FFmpegPath
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath)
r.Route("/system", func(r chi.Router) {
r.Get("/build", systemHandler.HandleBuildInfo)
r.Get("/hw-accel", systemHandler.HandleHWAccel)
+1 -1
View File
@@ -32,7 +32,7 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
runExtract = runFFmpegFrameExtract
}
resolvedAccel := playback.ResolveHWAccel(opts.HWAccel)
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
resolvedDevice := opts.HWDevice
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
resolvedDevice = playback.PickRenderDevice("")
+1 -1
View File
@@ -652,7 +652,7 @@ func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekS
func (s *Service) resolveHWConfig() (string, string) {
s.hwResolveOnce.Do(func() {
s.resolvedHWAccel = playback.ResolveHWAccel(s.hwAccel)
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
s.resolvedHWDevice = s.hwDevice
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
s.resolvedHWDevice = playback.PickRenderDevice("")
+206 -19
View File
@@ -1,15 +1,38 @@
package playback
import (
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const defaultDRIDir = "/dev/dri"
var (
defaultDRIDir = "/dev/dri"
defaultNVIDIAControlDevice = "/dev/nvidiactl"
defaultNVIDIADeviceGlob = "/dev/nvidia[0-9]*"
sysClassDRMDir = "/sys/class/drm"
currentGOOS = runtime.GOOS
nvencProbeCommandTimeout = 3 * time.Second
)
type nvencProbeResult struct {
available bool
reason string
}
var nvencProbeCache = struct {
sync.Mutex
byPath map[string]nvencProbeResult
}{
byPath: make(map[string]nvencProbeResult),
}
// HWAccelInfo describes the detected hardware acceleration capability.
type HWAccelInfo struct {
@@ -22,6 +45,11 @@ type HWAccelInfo struct {
// DetectHWAccel probes this host's GPU hardware and returns structured info.
func DetectHWAccel() HWAccelInfo {
return DetectHWAccelWithFFmpeg("")
}
// DetectHWAccelWithFFmpeg probes this host's GPU hardware and configured FFmpeg.
func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
devices := listRenderDevices(defaultDRIDir)
intel := false
for _, d := range devices {
@@ -31,7 +59,7 @@ func DetectHWAccel() HWAccelInfo {
}
}
return HWAccelInfo{
Resolved: ResolveHWAccel("auto"),
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
RenderDevices: devices,
IntelDetected: intel,
Source: "local",
@@ -53,34 +81,163 @@ func PickRenderDevice(explicit string) string {
return dev
}
// ResolveHWAccel resolves "auto" into a concrete acceleration method by
// probing the system. Preference order: qsv > vaapi > none.
// Non-"auto" values are returned unchanged.
// ResolveHWAccel resolves "auto" using the default FFmpeg binary.
func ResolveHWAccel(hwAccel string) string {
return ResolveHWAccelWithFFmpeg(hwAccel, "")
}
// ResolveHWAccelWithFFmpeg resolves "auto" into a concrete acceleration method
// by probing the system and the configured FFmpeg binary.
// Preference order: nvenc > qsv > vaapi > none.
// Non-"auto" values are returned unchanged.
func ResolveHWAccelWithFFmpeg(hwAccel string, ffmpegPath string) string {
if hwAccel != "auto" {
return hwAccel
}
if runtime.GOOS != "linux" {
if currentGOOS != "linux" {
return "none"
}
devices := listRenderDevices(defaultDRIDir)
if len(devices) == 0 {
slog.Info("hw_accel=auto: no render devices found, using software encoding")
return "none"
}
// Check for Intel GPU — enables QSV (preferred).
var intelDevice string
var nvidiaDevice string
var vaapiDevice string
for _, dev := range devices {
if isIntelDevice(dev) {
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", dev)
return "qsv"
switch {
case isNVIDIADevice(dev):
if nvidiaDevice == "" {
nvidiaDevice = dev
}
case isIntelDevice(dev):
if intelDevice == "" {
intelDevice = dev
}
default:
if vaapiDevice == "" {
vaapiDevice = dev
}
}
}
// Any accessible render device supports VAAPI.
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", devices[0])
return "vaapi"
if nvidiaDevice != "" || hasNVIDIADevice() {
if ok, reason := ffmpegSupportsNVENC(ffmpegPath); ok {
if nvidiaDevice != "" {
slog.Info("hw_accel=auto: NVIDIA GPU detected, using NVENC", "device", nvidiaDevice)
} else {
slog.Info("hw_accel=auto: NVIDIA device detected, using NVENC")
}
return "nvenc"
} else {
slog.Warn("hw_accel=auto: NVIDIA device detected but FFmpeg NVENC probe failed",
"ffmpeg", normalizeFFmpegPath(ffmpegPath), "reason", reason)
}
}
if intelDevice != "" {
slog.Info("hw_accel=auto: Intel GPU detected, using QSV", "device", intelDevice)
return "qsv"
}
if vaapiDevice != "" {
slog.Info("hw_accel=auto: non-Intel GPU detected, using VAAPI", "device", vaapiDevice)
return "vaapi"
}
slog.Info("hw_accel=auto: no compatible GPU devices found, using software encoding")
return "none"
}
func ffmpegSupportsNVENC(ffmpegPath string) (bool, string) {
ffmpegPath = normalizeFFmpegPath(ffmpegPath)
nvencProbeCache.Lock()
if result, ok := nvencProbeCache.byPath[ffmpegPath]; ok {
nvencProbeCache.Unlock()
return result.available, result.reason
}
nvencProbeCache.Unlock()
result := probeFFmpegNVENC(ffmpegPath)
nvencProbeCache.Lock()
nvencProbeCache.byPath[ffmpegPath] = result
nvencProbeCache.Unlock()
return result.available, result.reason
}
func normalizeFFmpegPath(ffmpegPath string) string {
ffmpegPath = strings.TrimSpace(ffmpegPath)
if ffmpegPath == "" {
ffmpegPath = ffmpegBinary()
}
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
return filepath.Clean(ffmpegPath)
}
return ffmpegPath
}
func probeFFmpegNVENC(ffmpegPath string) nvencProbeResult {
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-hwaccels"); err != nil {
return nvencProbeResult{reason: "hwaccels probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "cuda") {
return nvencProbeResult{reason: "cuda hwaccel unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-encoders"); err != nil {
return nvencProbeResult{reason: "encoders probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "h264_nvenc") {
return nvencProbeResult{reason: "h264_nvenc encoder unavailable"}
} else if !ffmpegOutputHasToken(output, "hevc_nvenc") {
return nvencProbeResult{reason: "hevc_nvenc encoder unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath, "-hide_banner", "-filters"); err != nil {
return nvencProbeResult{reason: "filters probe failed: " + probeFailure(err, output)}
} else if !ffmpegOutputHasToken(output, "scale_cuda") {
return nvencProbeResult{reason: "scale_cuda filter unavailable"}
} else if !ffmpegOutputHasToken(output, "hwupload_cuda") {
return nvencProbeResult{reason: "hwupload_cuda filter unavailable"}
}
if output, err := runFFmpegProbe(ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-f", "lavfi",
"-i", "testsrc2=size=64x64:rate=1",
"-frames:v", "1",
"-an",
"-c:v", "h264_nvenc",
"-f", "null",
"-",
); err != nil {
return nvencProbeResult{reason: "h264_nvenc smoke encode failed: " + probeFailure(err, output)}
}
return nvencProbeResult{available: true}
}
func runFFmpegProbe(ffmpegPath string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nvencProbeCommandTimeout)
defer cancel()
return exec.CommandContext(ctx, ffmpegPath, args...).CombinedOutput()
}
func ffmpegOutputHasToken(output []byte, token string) bool {
for _, field := range strings.Fields(string(output)) {
if strings.EqualFold(field, token) {
return true
}
}
return false
}
func probeFailure(err error, output []byte) string {
message := strings.TrimSpace(err.Error())
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
if len(trimmed) > 240 {
trimmed = trimmed[:240] + "..."
}
message += ": " + trimmed
}
return message
}
// listRenderDevices returns all accessible /dev/dri/renderD* paths, sorted.
@@ -107,7 +264,7 @@ func listRenderDevices(driDir string) []string {
func isIntelDevice(renderDevPath string) bool {
// /dev/dri/renderD128 → card name "renderD128"
name := filepath.Base(renderDevPath)
vendorPath := filepath.Join("/sys/class/drm", name, "device", "vendor")
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
data, err := os.ReadFile(vendorPath)
if err != nil {
return false
@@ -115,6 +272,36 @@ func isIntelDevice(renderDevPath string) bool {
return strings.TrimSpace(string(data)) == "0x8086"
}
// isNVIDIADevice checks whether a render device belongs to an NVIDIA GPU by
// reading the PCI vendor ID from sysfs. NVIDIA vendor ID is 0x10de.
func isNVIDIADevice(renderDevPath string) bool {
name := filepath.Base(renderDevPath)
vendorPath := filepath.Join(sysClassDRMDir, name, "device", "vendor")
data, err := os.ReadFile(vendorPath)
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "0x10de"
}
func hasNVIDIADevice() bool {
if file, err := os.Open(defaultNVIDIAControlDevice); err == nil {
file.Close()
return true
}
matches, err := filepath.Glob(defaultNVIDIADeviceGlob)
if err != nil || len(matches) == 0 {
return false
}
for _, dev := range matches {
if file, err := os.Open(dev); err == nil {
file.Close()
return true
}
}
return false
}
// detectRenderDevice enumerates /dev/dri/renderD* and returns the first
// available device, or empty string if none found.
func detectRenderDevice(driDir string) string {
+304
View File
@@ -0,0 +1,304 @@
package playback
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type hwAccelTestEnv struct {
devDir string
driDir string
sysDir string
}
type fakeFFmpegProbe struct {
cuda bool
h264NVENC bool
hevcNVENC bool
scaleCUDA bool
uploadCUDA bool
smokeOK bool
hang bool
}
type fakeFFmpegBinary struct {
path string
logPath string
}
func TestResolveHWAccelWithFFmpegAutoPrefersNVENCOverIntel(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x8086")
env.addRenderDevice(t, "renderD129", "0x10de")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestResolveHWAccelWithFFmpegFallsBackToIntelWhenNVENCProbeFails(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x8086")
env.addRenderDevice(t, "renderD129", "0x10de")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "qsv" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want qsv", got)
}
}
func TestResolveHWAccelWithFFmpegFallsBackToVAAPIWhenNVENCProbeFails(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
env.addRenderDevice(t, "renderD129", "0x1002")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "vaapi" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want vaapi", got)
}
}
func TestResolveHWAccelWithFFmpegReturnsNoneWhenNVENCProbeFailsWithoutFallback(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
ffmpeg := writeFakeFFmpeg(t, fakeFFmpegProbe{})
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "none" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want none", got)
}
}
func TestResolveHWAccelWithFFmpegUsesNVIDIADeviceNodesWithoutDRM(t *testing.T) {
env := setupHWAccelTest(t)
env.addNVIDIADevice(t, "nvidia0")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestExplicitNVENCBypassesFFmpegProbe(t *testing.T) {
setupHWAccelTest(t)
if got := ResolveHWAccelWithFFmpeg("nvenc", "/does/not/exist/ffmpeg"); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() = %q, want nvenc", got)
}
}
func TestFFmpegSupportsNVENCRequiresCUDAEncodersFiltersAndSmoke(t *testing.T) {
setupHWAccelTest(t)
tests := []struct {
name string
probe fakeFFmpegProbe
}{
{
name: "missing cuda hwaccel",
probe: fakeFFmpegProbe{
h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing h264 nvenc encoder",
probe: fakeFFmpegProbe{
cuda: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing hevc nvenc encoder",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, scaleCUDA: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing scale cuda filter",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, uploadCUDA: true, smokeOK: true,
},
},
{
name: "missing hwupload cuda filter",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, smokeOK: true,
},
},
{
name: "smoke encode failure",
probe: fakeFFmpegProbe{
cuda: true, h264NVENC: true, hevcNVENC: true, scaleCUDA: true, uploadCUDA: true,
},
},
{
name: "probe timeout",
probe: fakeFFmpegProbe{hang: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetNVENCProbeCacheForTest()
ffmpeg := writeFakeFFmpeg(t, tt.probe)
if ok, reason := ffmpegSupportsNVENC(ffmpeg.path); ok {
t.Fatalf("ffmpegSupportsNVENC() = true, want false")
} else if reason == "" {
t.Fatalf("ffmpegSupportsNVENC() reason is empty")
}
})
}
}
func TestFFmpegSupportsNVENCCachesByFFmpegPath(t *testing.T) {
env := setupHWAccelTest(t)
env.addRenderDevice(t, "renderD128", "0x10de")
ffmpeg := writeFakeFFmpeg(t, successfulNVENCProbe())
for i := 0; i < 2; i++ {
if got := ResolveHWAccelWithFFmpeg("auto", ffmpeg.path); got != "nvenc" {
t.Fatalf("ResolveHWAccelWithFFmpeg() call %d = %q, want nvenc", i+1, got)
}
}
logData, err := os.ReadFile(ffmpeg.logPath)
if err != nil {
t.Fatalf("read ffmpeg probe log: %v", err)
}
if got := strings.Count(string(logData), "\n"); got != 4 {
t.Fatalf("probe command count = %d, want 4; log:\n%s", got, logData)
}
}
func successfulNVENCProbe() fakeFFmpegProbe {
return fakeFFmpegProbe{
cuda: true,
h264NVENC: true,
hevcNVENC: true,
scaleCUDA: true,
uploadCUDA: true,
smokeOK: true,
}
}
func setupHWAccelTest(t *testing.T) *hwAccelTestEnv {
t.Helper()
oldDRIDir := defaultDRIDir
oldNVIDIAControlDevice := defaultNVIDIAControlDevice
oldNVIDIADeviceGlob := defaultNVIDIADeviceGlob
oldSysClassDRMDir := sysClassDRMDir
oldGOOS := currentGOOS
oldProbeTimeout := nvencProbeCommandTimeout
resetNVENCProbeCacheForTest()
tmp := t.TempDir()
env := &hwAccelTestEnv{
devDir: filepath.Join(tmp, "dev"),
driDir: filepath.Join(tmp, "dev", "dri"),
sysDir: filepath.Join(tmp, "sys", "class", "drm"),
}
defaultDRIDir = env.driDir
defaultNVIDIAControlDevice = filepath.Join(env.devDir, "nvidiactl")
defaultNVIDIADeviceGlob = filepath.Join(env.devDir, "nvidia[0-9]*")
sysClassDRMDir = env.sysDir
currentGOOS = "linux"
nvencProbeCommandTimeout = 200 * time.Millisecond
if err := os.MkdirAll(env.driDir, 0o755); err != nil {
t.Fatalf("create test dri dir: %v", err)
}
if err := os.MkdirAll(env.devDir, 0o755); err != nil {
t.Fatalf("create test dev dir: %v", err)
}
t.Cleanup(func() {
defaultDRIDir = oldDRIDir
defaultNVIDIAControlDevice = oldNVIDIAControlDevice
defaultNVIDIADeviceGlob = oldNVIDIADeviceGlob
sysClassDRMDir = oldSysClassDRMDir
currentGOOS = oldGOOS
nvencProbeCommandTimeout = oldProbeTimeout
resetNVENCProbeCacheForTest()
})
return env
}
func (e *hwAccelTestEnv) addRenderDevice(t *testing.T, name string, vendor string) {
t.Helper()
if err := os.WriteFile(filepath.Join(e.driDir, name), []byte{}, 0o600); err != nil {
t.Fatalf("create render device: %v", err)
}
vendorPath := filepath.Join(e.sysDir, name, "device", "vendor")
if err := os.MkdirAll(filepath.Dir(vendorPath), 0o755); err != nil {
t.Fatalf("create vendor dir: %v", err)
}
if err := os.WriteFile(vendorPath, []byte(vendor+"\n"), 0o644); err != nil {
t.Fatalf("write vendor file: %v", err)
}
}
func (e *hwAccelTestEnv) addNVIDIADevice(t *testing.T, name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(e.devDir, name), []byte{}, 0o600); err != nil {
t.Fatalf("create nvidia device: %v", err)
}
}
func writeFakeFFmpeg(t *testing.T, probe fakeFFmpegProbe) fakeFFmpegBinary {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "ffmpeg")
logPath := filepath.Join(dir, "probe.log")
script := "#!/bin/sh\n"
script += fmt.Sprintf("printf '%%s\\n' \"$*\" >> %q\n", logPath)
if probe.hang {
script += "sleep 5\n"
}
script += "case \"$*\" in\n"
script += " *-hwaccels*)\n"
script += " echo 'Hardware acceleration methods:'\n"
if probe.cuda {
script += " echo 'cuda'\n"
}
script += " exit 0 ;;\n"
script += " *-encoders*)\n"
if probe.h264NVENC {
script += " echo ' V..... h264_nvenc NVIDIA NVENC H.264 encoder'\n"
}
if probe.hevcNVENC {
script += " echo ' V..... hevc_nvenc NVIDIA NVENC hevc encoder'\n"
}
script += " exit 0 ;;\n"
script += " *-filters*)\n"
if probe.scaleCUDA {
script += " echo ' ... scale_cuda V->V GPU video scaling'\n"
}
if probe.uploadCUDA {
script += " echo ' ... hwupload_cuda V->V upload CUDA frames'\n"
}
script += " exit 0 ;;\n"
script += " *)\n"
if probe.smokeOK {
script += " exit 0 ;;\n"
} else {
script += " echo 'no capable devices found' >&2\n"
script += " exit 1 ;;\n"
}
script += "esac\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
return fakeFFmpegBinary{path: path, logPath: logPath}
}
func resetNVENCProbeCacheForTest() {
nvencProbeCache.Lock()
defer nvencProbeCache.Unlock()
nvencProbeCache.byPath = make(map[string]nvencProbeResult)
}
+59 -4
View File
@@ -39,7 +39,7 @@ type TranscodeOpts struct {
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, none
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
@@ -294,6 +294,9 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
} else if opts.HWAccel == "vaapi" {
scale := vaapiScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.HWAccel == "nvenc" {
scale := nvencScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.TargetResolution != "" {
scale := resolutionToScale(opts.TargetResolution)
if scale != "" {
@@ -351,7 +354,7 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
}
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
hwAccel := ResolveHWAccel(opts.HWAccel)
hwAccel := ResolveHWAccelWithFFmpeg(opts.HWAccel, opts.FFmpegPath)
if hwAccel == "" {
return ""
}
@@ -422,12 +425,12 @@ func appendSegmentBoundaryArgs(args []string, opts TranscodeOpts) []string {
fmt.Sprintf("expr:gte(t,n_forced*%d)", opts.SegmentDuration))
}
// Hardware encoders (QSV, VAAPI) may not reliably honor
// Hardware encoders (QSV, VAAPI, NVENC) may not reliably honor
// force_key_frames expressions. Set explicit GOP size so segment
// boundaries always start with an IDR frame. We assume 30 fps as a
// safe ceiling — the GOP will be at most segmentDuration * 30 frames.
// Matches Jellyfin's approach for hardware encoders.
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" {
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" || opts.HWAccel == "nvenc" {
gopSize := fmt.Sprintf("%d", opts.SegmentDuration*30)
args = append(args, "-g", gopSize, "-keyint_min", gopSize)
}
@@ -465,6 +468,15 @@ func appendHWAccelArgs(args []string, opts TranscodeOpts) []string {
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
)
case "nvenc":
args = append(args,
"-hwaccel", "cuda",
"-hwaccel_output_format", "cuda",
"-noautorotate",
)
if hwDevice := strings.TrimSpace(opts.HWDevice); hwDevice != "" {
args = append(args, "-hwaccel_device", hwDevice)
}
}
return args
}
@@ -530,6 +542,26 @@ func appendVideoArgs(args []string, opts TranscodeOpts) []string {
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
}
case opts.HWAccel == "nvenc" && codec == "h264":
args = append(args, "-c:v", "h264_nvenc", "-rc:v", "vbr")
if hasBitrateCap {
args = append(args,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-cq:v", "23", "-b:v", "0")
}
case opts.HWAccel == "nvenc" && codec == "hevc":
args = append(args, "-c:v", "hevc_nvenc", "-rc:v", "vbr")
if hasBitrateCap {
args = append(args,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-cq:v", "28", "-b:v", "0")
}
default:
// CPU fallback — match Jellyfin's proven browser-compatible settings.
// Force yuv420p to ensure 8-bit output (10-bit sources produce High 10
@@ -609,6 +641,10 @@ func appendSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
// VAAPI-only: download, apply CPU filters, convert to nv12, upload back.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload"
args = append(args, "-vf", vf)
case "nvenc":
// NVENC/CUDA: download to CPU for subtitle rendering, then upload back.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload_cuda"
args = append(args, "-vf", vf)
default:
// CPU encoding: filters run directly on decoded frames.
args = append(args, "-vf", cpuFilters)
@@ -679,6 +715,25 @@ func vaapiScaleFilter(res string) string {
}
}
func nvencScaleFilter(res string) string {
switch res {
case "2160p":
return "scale_cuda=w=-2:h=2160:format=nv12"
case "1080p":
return "scale_cuda=w=-2:h=1080:format=nv12"
case "720p":
return "scale_cuda=w=-2:h=720:format=nv12"
case "480p":
return "scale_cuda=w=-2:h=480:format=nv12"
case "420p":
return "scale_cuda=w=-2:h=420:format=nv12"
case "328p":
return "scale_cuda=w=-2:h=328:format=nv12"
default:
return "scale_cuda=format=nv12"
}
}
// filterPathReplacer escapes special characters in file paths for ffmpeg filter syntax.
var filterPathReplacer = strings.NewReplacer(
"'", "'\\''",
+39 -2
View File
@@ -113,7 +113,7 @@ func TestBuildFFmpegArgs_CopyVideoSeekPreservesCodecCopy(t *testing.T) {
t.Fatalf("copy-mode seek should preserve -c:v copy: %s", joined)
}
// Must not contain any video encoder.
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv"} {
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc"} {
if strings.Contains(joined, enc) {
t.Fatalf("copy-mode seek should not use encoder %s: %s", enc, joined)
}
@@ -166,7 +166,7 @@ func TestBuildFFmpegArgs_MPEG2CopyVideoUsesMPEGTS(t *testing.T) {
if strings.Contains(joined, "movflags=+frag_discont") {
t.Fatalf("mpeg2 MPEG-TS copy-mode should not use fMP4 movflags: %s", joined)
}
for _, enc := range []string{"h264_qsv", "h264_vaapi", "libx264", "hevc_qsv", "libx265"} {
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc", "libx265"} {
if strings.Contains(joined, enc) {
t.Fatalf("mpeg2 copy-mode should not use encoder %s: %s", enc, joined)
}
@@ -234,6 +234,11 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
want: "none",
},
{
name: "nvenc passthrough",
opts: TranscodeOpts{HWAccel: "nvenc", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
want: "nvenc",
},
}
for _, tt := range tests {
@@ -247,6 +252,38 @@ func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
}
}
func TestBuildFFmpegArgs_NVENCH264UsesCudaPipeline(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
SessionID: "session-nvenc",
SourceVideoCodec: "h264",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
HWAccel: "nvenc",
TargetResolution: "720p",
TargetBitrateKbps: 2000,
})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-hwaccel cuda") {
t.Fatalf("nvenc args should enable cuda hwaccel: %s", joined)
}
if !strings.Contains(joined, "-c:v h264_nvenc") {
t.Fatalf("nvenc args should use h264_nvenc encoder: %s", joined)
}
if !strings.Contains(joined, "-vf scale_cuda=w=-2:h=720:format=nv12") {
t.Fatalf("nvenc args should use scale_cuda, not software scale: %s", joined)
}
if strings.Contains(joined, "-vf scale=-2:720") {
t.Fatalf("nvenc args must not use software scale on cuda frames: %s", joined)
}
if !strings.Contains(joined, "-b:v 2000k -maxrate 2000k -bufsize 4000k") {
t.Fatalf("nvenc args should include bitrate cap controls: %s", joined)
}
}
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
+5 -1
View File
@@ -112,7 +112,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
}
func (s *Server) handleHWCapabilities(w http.ResponseWriter, _ *http.Request) {
info := playback.DetectHWAccel()
ffmpegPath := ""
if cfg := s.watcher.Config(); cfg != nil {
ffmpegPath = cfg.Playback.FFmpegPath
}
info := playback.DetectHWAccelWithFFmpeg(ffmpegPath)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
@@ -58,6 +58,7 @@ export default function PlaybackSettings() {
{ value: "auto", label: "Auto" },
{ value: "qsv", label: "Intel Quick Sync (QSV)" },
{ value: "vaapi", label: "VA-API" },
{ value: "nvenc", label: "NVIDIA NVENC" },
{ value: "none", label: "Software" },
]}
value={form.getValue("playback.hw_accel")}
@@ -202,6 +203,8 @@ function formatResolved(resolved: string): string {
return "Intel Quick Sync (QSV)";
case "vaapi":
return "VA-API";
case "nvenc":
return "NVIDIA NVENC";
case "none":
return "Software";
default: