Skip to content

Commit 69a4bf9

Browse files
committed
Probe VAAPI support on startup
1 parent 071db8b commit 69a4bf9

4 files changed

Lines changed: 135 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Edit `docker/config/config.local.json` before starting:
4040
- set `server.public_url` if clients reach the proxy through another reverse proxy
4141
- leave `server.debug` as `false` for concise logs, or set it to `true` for detailed diagnostics
4242
- set `transcode.hardware_decode` to `vaapi` on Linux hosts with Intel or AMD `/dev/dri` hardware decode support
43+
- startup will probe VAAPI availability and fall back to software decode if the device or ffmpeg support is missing
4344

4445
Update `docker/docker-compose.yml` to mount the local config file if you use `config.local.json`:
4546

@@ -100,6 +101,7 @@ Copy `config.example.json` and change the upstream URL:
100101
Leave `public_url` empty when clients connect directly to EmbyTranscoder. Set it when EmbyTranscoder sits behind another reverse proxy.
101102
Leave `debug` as `false` for concise action-level logs. Set it to `true` when you want detailed `TRACE_SWITCH` and request-level diagnostics.
102103
Set `hardware_decode` to `vaapi` to enable VAAPI hardware decode. The default `hardware_device` is `/dev/dri/renderD128`.
104+
If the device or ffmpeg probe fails, startup falls back to software decode and logs the reason.
103105

104106
## Transcode Lifecycle
105107

docker/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Edit `config/config.json` before startup, or copy it to `config/config.local.jso
2424
- set `server.public_url` if clients reach the proxy through another reverse proxy
2525
- set `server.debug` to `true` when you need detailed diagnostics
2626
- set `transcode.hardware_decode` to `vaapi` to use Intel or AMD VAAPI hardware decode through `/dev/dri`
27+
- startup probes VAAPI support and falls back to software decode when the device is unavailable or ffmpeg lacks VAAPI
2728

2829
For GitHub Actions publishing to Docker Hub, set `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` in the repository secrets.
2930

internal/transcode/ffmpeg_args_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package transcode
22

33
import (
4+
"errors"
45
"path/filepath"
56
"slices"
67
"testing"
@@ -101,3 +102,54 @@ func TestBuildFFmpegArgsAppliesVAAPIHardwareDecodeBeforeInput(t *testing.T) {
101102
t.Fatalf("hardware decode args should be input options before -i: %v", args)
102103
}
103104
}
105+
106+
func TestResolveHardwareDecodeKeepsVAAPIWhenProbePasses(t *testing.T) {
107+
options := resolveHardwareDecodeOptions("/usr/bin/ffmpeg", FFmpegOptions{
108+
HardwareDecode: "vaapi",
109+
HardwareDevice: "/dev/dri/renderD128",
110+
}, func(path string, options FFmpegOptions) error {
111+
if path != "/usr/bin/ffmpeg" {
112+
t.Fatalf("ffmpeg path = %q", path)
113+
}
114+
if options.HardwareDecode != "vaapi" {
115+
t.Fatalf("hardware decode = %q", options.HardwareDecode)
116+
}
117+
return nil
118+
})
119+
120+
if options.HardwareDecode != "vaapi" || options.HardwareDevice != "/dev/dri/renderD128" {
121+
t.Fatalf("options = %+v", options)
122+
}
123+
}
124+
125+
func TestResolveHardwareDecodeFallsBackToSoftwareWhenProbeFails(t *testing.T) {
126+
options := resolveHardwareDecodeOptions("/usr/bin/ffmpeg", FFmpegOptions{
127+
HardwareDecode: "vaapi",
128+
HardwareDevice: "/dev/dri/renderD128",
129+
}, func(string, FFmpegOptions) error {
130+
return errors.New("device not accessible")
131+
})
132+
133+
if options.HardwareDecode != "" || options.HardwareDevice != "" {
134+
t.Fatalf("expected software fallback, options = %+v", options)
135+
}
136+
}
137+
138+
func TestNewManagerFallsBackToSoftwareDecodeWhenHardwareProbeFails(t *testing.T) {
139+
manager := NewManager(Options{
140+
FFmpegPath: "/usr/bin/ffmpeg",
141+
HardwareDecode: "vaapi",
142+
HardwareDevice: "/dev/dri/renderD128",
143+
HardwareProbe: func(string, FFmpegOptions) error {
144+
return errors.New("device not accessible")
145+
},
146+
})
147+
148+
runner, ok := manager.options.Runner.(FFmpegRunner)
149+
if !ok {
150+
t.Fatalf("runner = %T", manager.options.Runner)
151+
}
152+
if runner.Options.HardwareDecode != "" || runner.Options.HardwareDevice != "" {
153+
t.Fatalf("expected runner to use software decode, options = %+v", runner.Options)
154+
}
155+
}

internal/transcode/manager.go

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Options struct {
3737
ReapInterval time.Duration
3838
RestartGraceTimeout time.Duration
3939
Runner Runner
40+
HardwareProbe HardwareProbe
4041
}
4142

4243
type Request struct {
@@ -128,12 +129,13 @@ func NewManager(options Options) *Manager {
128129
options.HardwareDecode = strings.ToLower(strings.TrimSpace(options.HardwareDecode))
129130
options.HardwareDevice = strings.TrimSpace(options.HardwareDevice)
130131
if options.Runner == nil && options.FFmpegPath != "" {
132+
ffmpegOptions := resolveHardwareDecodeOptions(options.FFmpegPath, FFmpegOptions{
133+
HardwareDecode: options.HardwareDecode,
134+
HardwareDevice: options.HardwareDevice,
135+
}, options.HardwareProbe)
131136
options.Runner = FFmpegRunner{
132-
Path: options.FFmpegPath,
133-
Options: FFmpegOptions{
134-
HardwareDecode: options.HardwareDecode,
135-
HardwareDevice: options.HardwareDevice,
136-
},
137+
Path: options.FFmpegPath,
138+
Options: ffmpegOptions,
137139
}
138140
}
139141
return &Manager{options: options, sessions: map[string]*Session{}, media: map[string]MediaInfo{}}
@@ -681,6 +683,79 @@ type FFmpegOptions struct {
681683
HardwareDevice string
682684
}
683685

686+
type HardwareProbe func(ffmpegPath string, options FFmpegOptions) error
687+
688+
func resolveHardwareDecodeOptions(ffmpegPath string, options FFmpegOptions, probe HardwareProbe) FFmpegOptions {
689+
options.HardwareDecode = strings.ToLower(strings.TrimSpace(options.HardwareDecode))
690+
options.HardwareDevice = strings.TrimSpace(options.HardwareDevice)
691+
switch options.HardwareDecode {
692+
case "", "none", "off", "false":
693+
return FFmpegOptions{}
694+
case "vaapi":
695+
if options.HardwareDevice == "" {
696+
options.HardwareDevice = "/dev/dri/renderD128"
697+
}
698+
default:
699+
logging.Infof("hardware decode unavailable mode=%s reason=unsupported fallback=software", options.HardwareDecode)
700+
return FFmpegOptions{}
701+
}
702+
703+
if probe == nil {
704+
probe = defaultHardwareProbe
705+
}
706+
if err := probe(ffmpegPath, options); err != nil {
707+
logging.Infof("hardware decode unavailable mode=%s device=%s reason=%v fallback=software", options.HardwareDecode, options.HardwareDevice, err)
708+
return FFmpegOptions{}
709+
}
710+
logging.Infof("hardware decode enabled mode=%s device=%s", options.HardwareDecode, options.HardwareDevice)
711+
return options
712+
}
713+
714+
func defaultHardwareProbe(ffmpegPath string, options FFmpegOptions) error {
715+
if strings.TrimSpace(ffmpegPath) == "" {
716+
return errors.New("ffmpeg path is required")
717+
}
718+
switch options.HardwareDecode {
719+
case "vaapi":
720+
if err := probeFFmpegHWAccel(ffmpegPath, "vaapi"); err != nil {
721+
return err
722+
}
723+
return probeDevice(options.HardwareDevice)
724+
default:
725+
return fmt.Errorf("unsupported hardware decode mode %q", options.HardwareDecode)
726+
}
727+
}
728+
729+
func probeFFmpegHWAccel(ffmpegPath, name string) error {
730+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
731+
defer cancel()
732+
733+
output, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-hwaccels").CombinedOutput()
734+
if ctx.Err() != nil {
735+
return fmt.Errorf("ffmpeg hwaccel probe timed out")
736+
}
737+
if err != nil {
738+
return fmt.Errorf("ffmpeg hwaccel probe failed: %w", err)
739+
}
740+
for _, line := range strings.Split(string(output), "\n") {
741+
if strings.EqualFold(strings.TrimSpace(line), name) {
742+
return nil
743+
}
744+
}
745+
return fmt.Errorf("ffmpeg does not list %s hwaccel", name)
746+
}
747+
748+
func probeDevice(device string) error {
749+
if strings.TrimSpace(device) == "" {
750+
return errors.New("hardware device is required")
751+
}
752+
file, err := os.OpenFile(device, os.O_RDWR, 0)
753+
if err != nil {
754+
return fmt.Errorf("open hardware device: %w", err)
755+
}
756+
return file.Close()
757+
}
758+
684759
func (r FFmpegRunner) Start(ctx context.Context, session *Session, request Request) (Process, error) {
685760
if r.Path == "" {
686761
return nil, errors.New("ffmpeg path is required")

0 commit comments

Comments
 (0)