Skip to content

Commit 071db8b

Browse files
committed
Add VAAPI hardware decode support
1 parent 9c59ae5 commit 071db8b

10 files changed

Lines changed: 112 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Edit `docker/config/config.local.json` before starting:
3939
- set `upstream.url` to your Emby or Jellyfin server
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
42+
- set `transcode.hardware_decode` to `vaapi` on Linux hosts with Intel or AMD `/dev/dri` hardware decode support
4243

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

@@ -86,6 +87,8 @@ Copy `config.example.json` and change the upstream URL:
8687
"enabled": true,
8788
"ffmpeg_path": "/usr/bin/ffmpeg",
8889
"temp_dir": "/var/lib/emby-transcoder/transcode",
90+
"hardware_decode": "",
91+
"hardware_device": "/dev/dri/renderD128",
8992
"max_sessions": 2,
9093
"buffer_pause_seconds": 300,
9194
"buffer_resume_seconds": 120,
@@ -96,6 +99,7 @@ Copy `config.example.json` and change the upstream URL:
9699

97100
Leave `public_url` empty when clients connect directly to EmbyTranscoder. Set it when EmbyTranscoder sits behind another reverse proxy.
98101
Leave `debug` as `false` for concise action-level logs. Set it to `true` when you want detailed `TRACE_SWITCH` and request-level diagnostics.
102+
Set `hardware_decode` to `vaapi` to enable VAAPI hardware decode. The default `hardware_device` is `/dev/dri/renderD128`.
99103

100104
## Transcode Lifecycle
101105

config.example.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"enabled": true,
1212
"ffmpeg_path": "/usr/bin/ffmpeg",
1313
"temp_dir": "/var/lib/emby-transcoder/transcode",
14+
"hardware_decode": "",
15+
"hardware_device": "/dev/dri/renderD128",
1416
"max_sessions": 2,
1517
"buffer_pause_seconds": 300,
1618
"buffer_resume_seconds": 120,

docker/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Edit `config/config.json` before startup, or copy it to `config/config.local.jso
2323
- set `upstream.url` to your Emby or Jellyfin server
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
26+
- set `transcode.hardware_decode` to `vaapi` to use Intel or AMD VAAPI hardware decode through `/dev/dri`
2627

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

docker/config/config.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"enabled": true,
1212
"ffmpeg_path": "/usr/bin/ffmpeg",
1313
"temp_dir": "/var/lib/emby-transcoder/transcode",
14+
"hardware_decode": "",
15+
"hardware_device": "/dev/dri/renderD128",
1416
"max_sessions": 2,
1517
"buffer_pause_seconds": 300,
1618
"buffer_resume_seconds": 120,

docker/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ services:
55
restart: unless-stopped
66
ports:
77
- "8097:8097"
8+
devices:
9+
- /dev/dri:/dev/dri
810
volumes:
911
- ./config/config.json:/app/config/config.json:ro
1012
- ./data/transcode:/var/lib/emby-transcoder/transcode

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type Transcode struct {
2929
Enabled bool `json:"enabled"`
3030
FFmpegPath string `json:"ffmpeg_path"`
3131
TempDir string `json:"temp_dir"`
32+
HardwareDecode string `json:"hardware_decode"`
33+
HardwareDevice string `json:"hardware_device"`
3234
MaxSessions int `json:"max_sessions"`
3335
BufferPauseSeconds int `json:"buffer_pause_seconds"`
3436
BufferResumeSeconds int `json:"buffer_resume_seconds"`
@@ -103,6 +105,11 @@ func normalize(cfg *Config) {
103105
if cfg.Transcode.TempDir == "" {
104106
cfg.Transcode.TempDir = "/var/lib/emby-transcoder/transcode"
105107
}
108+
cfg.Transcode.HardwareDecode = strings.ToLower(strings.TrimSpace(cfg.Transcode.HardwareDecode))
109+
cfg.Transcode.HardwareDevice = strings.TrimSpace(cfg.Transcode.HardwareDevice)
110+
if cfg.Transcode.HardwareDecode == "vaapi" && cfg.Transcode.HardwareDevice == "" {
111+
cfg.Transcode.HardwareDevice = "/dev/dri/renderD128"
112+
}
106113
if cfg.Transcode.MaxSessions <= 0 {
107114
cfg.Transcode.MaxSessions = 2
108115
}

internal/config/config_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,26 @@ func TestLoadMergesJSONOverDefaults(t *testing.T) {
7676
t.Fatalf("clients = %+v", cfg.Clients)
7777
}
7878
}
79+
80+
func TestLoadSupportsVAAPIHardwareDecode(t *testing.T) {
81+
path := filepath.Join(t.TempDir(), "config.json")
82+
err := os.WriteFile(path, []byte(`{
83+
"upstream": {"url": "http://emby.local:8096"},
84+
"transcode": {"hardware_decode": "vaapi"}
85+
}`), 0o600)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
90+
cfg, err := config.Load(path)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
95+
if cfg.Transcode.HardwareDecode != "vaapi" {
96+
t.Fatalf("hardware decode = %q", cfg.Transcode.HardwareDecode)
97+
}
98+
if cfg.Transcode.HardwareDevice != "/dev/dri/renderD128" {
99+
t.Fatalf("hardware device = %q", cfg.Transcode.HardwareDevice)
100+
}
101+
}

internal/proxy/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ func NewWithTransport(cfg config.Config, transport http.RoundTripper) (*Server,
4747
MaxSessions: cfg.Transcode.MaxSessions,
4848
TempDir: cfg.Transcode.TempDir,
4949
FFmpegPath: cfg.Transcode.FFmpegPath,
50+
HardwareDecode: cfg.Transcode.HardwareDecode,
51+
HardwareDevice: cfg.Transcode.HardwareDevice,
5052
BufferPauseThreshold: cfg.Transcode.BufferPause,
5153
BufferResumeThreshold: cfg.Transcode.BufferResume,
5254
IdleTimeout: cfg.Transcode.IdleTimeout,

internal/transcode/ffmpeg_args_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,32 @@ func TestBuildFFmpegArgsDoesNotThrottleInputWithRealtimeFlag(t *testing.T) {
7272
t.Fatalf("ffmpeg args should not include realtime throttling: %v", args)
7373
}
7474
}
75+
76+
func TestBuildFFmpegArgsAppliesVAAPIHardwareDecodeBeforeInput(t *testing.T) {
77+
session := &Session{
78+
ID: "item123",
79+
Dir: t.TempDir(),
80+
}
81+
request := Request{InputURL: "http://upstream/stream"}
82+
83+
args := buildFFmpegArgs(session, request, FFmpegOptions{
84+
HardwareDecode: "vaapi",
85+
HardwareDevice: "/dev/dri/renderD128",
86+
})
87+
88+
hwaccelIndex := slices.Index(args, "-hwaccel")
89+
if hwaccelIndex < 0 || args[hwaccelIndex+1] != "vaapi" {
90+
t.Fatalf("missing VAAPI hwaccel args: %v", args)
91+
}
92+
deviceIndex := slices.Index(args, "-hwaccel_device")
93+
if deviceIndex < 0 || args[deviceIndex+1] != "/dev/dri/renderD128" {
94+
t.Fatalf("missing VAAPI device args: %v", args)
95+
}
96+
inputIndex := slices.Index(args, "-i")
97+
if inputIndex < 0 {
98+
t.Fatalf("missing -i in args: %v", args)
99+
}
100+
if hwaccelIndex > inputIndex || deviceIndex > inputIndex {
101+
t.Fatalf("hardware decode args should be input options before -i: %v", args)
102+
}
103+
}

internal/transcode/manager.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type Options struct {
2828
MaxSessions int
2929
TempDir string
3030
FFmpegPath string
31+
HardwareDecode string
32+
HardwareDevice string
3133
BufferPauseThreshold time.Duration
3234
BufferResumeThreshold time.Duration
3335
BufferCheckInterval time.Duration
@@ -123,8 +125,16 @@ func NewManager(options Options) *Manager {
123125
if options.RestartGraceTimeout <= 0 {
124126
options.RestartGraceTimeout = defaultRestartGraceTimeout
125127
}
128+
options.HardwareDecode = strings.ToLower(strings.TrimSpace(options.HardwareDecode))
129+
options.HardwareDevice = strings.TrimSpace(options.HardwareDevice)
126130
if options.Runner == nil && options.FFmpegPath != "" {
127-
options.Runner = FFmpegRunner{Path: options.FFmpegPath}
131+
options.Runner = FFmpegRunner{
132+
Path: options.FFmpegPath,
133+
Options: FFmpegOptions{
134+
HardwareDecode: options.HardwareDecode,
135+
HardwareDevice: options.HardwareDevice,
136+
},
137+
}
128138
}
129139
return &Manager{options: options, sessions: map[string]*Session{}, media: map[string]MediaInfo{}}
130140
}
@@ -662,15 +672,21 @@ func durationToTicks(d time.Duration) int64 {
662672
}
663673

664674
type FFmpegRunner struct {
665-
Path string
675+
Path string
676+
Options FFmpegOptions
677+
}
678+
679+
type FFmpegOptions struct {
680+
HardwareDecode string
681+
HardwareDevice string
666682
}
667683

668684
func (r FFmpegRunner) Start(ctx context.Context, session *Session, request Request) (Process, error) {
669685
if r.Path == "" {
670686
return nil, errors.New("ffmpeg path is required")
671687
}
672688

673-
args := buildFFmpegArgs(session, request)
689+
args := buildFFmpegArgs(session, request, r.Options)
674690
playlist := filepath.Join(session.Dir, "master.m3u8")
675691
logPath := filepath.Join(session.Dir, "ffmpeg.log")
676692
logging.Infof("transcode start id=%s segment=%d", session.ID, session.SegmentStartIndex)
@@ -708,16 +724,21 @@ func (r FFmpegRunner) Start(ctx context.Context, session *Session, request Reque
708724
return process, nil
709725
}
710726

711-
func buildFFmpegArgs(session *Session, request Request) []string {
727+
func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions) []string {
712728
playlist := filepath.Join(session.Dir, "master.m3u8")
713729
segmentPattern := filepath.Join(session.Dir, "segment_%05d.ts")
730+
ffmpegOptions := FFmpegOptions{}
731+
if len(options) > 0 {
732+
ffmpegOptions = options[0]
733+
}
714734
args := []string{
715735
"-hide_banner",
716736
"-loglevel", "info",
717737
}
718738
if headerText := ffmpegHeaders(request.Headers); headerText != "" {
719739
args = append(args, "-headers", headerText)
720740
}
741+
args = appendHardwareDecodeArgs(args, ffmpegOptions)
721742
if session.StartTimeTicks > 0 {
722743
args = append(args, "-ss", ticksSeconds(session.StartTimeTicks))
723744
}
@@ -751,6 +772,21 @@ func buildFFmpegArgs(session *Session, request Request) []string {
751772
return args
752773
}
753774

775+
func appendHardwareDecodeArgs(args []string, options FFmpegOptions) []string {
776+
switch strings.ToLower(strings.TrimSpace(options.HardwareDecode)) {
777+
case "", "none", "off", "false":
778+
return args
779+
case "vaapi":
780+
args = append(args, "-hwaccel", "vaapi")
781+
if device := strings.TrimSpace(options.HardwareDevice); device != "" {
782+
args = append(args, "-hwaccel_device", device)
783+
}
784+
return args
785+
default:
786+
return args
787+
}
788+
}
789+
754790
func ticksSeconds(ticks int64) string {
755791
return strconv.FormatFloat(float64(ticks)/10_000_000, 'f', 6, 64)
756792
}

0 commit comments

Comments
 (0)