Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084a77c34f | ||
|
|
b84e2cde1b |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
"'", "'\\''",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user