Skip to content

Commit 28900d5

Browse files
committed
Enable full VAAPI transcode pipeline
1 parent e70ea1f commit 28900d5

4 files changed

Lines changed: 229 additions & 21 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ Edit `docker/config/config.local.json` before starting:
4343
- set `upstream.url` to your Emby or Jellyfin server
4444
- set `server.public_url` if clients reach the proxy through another reverse proxy
4545
- leave `server.debug` as `false` for concise logs, or set it to `true` for detailed diagnostics
46-
- set `transcode.hardware_decode` to `vaapi` on Linux hosts with Intel or AMD `/dev/dri` hardware decode support
47-
- startup will probe VAAPI availability, including device initialization, and fail startup if the device, driver, or ffmpeg support is missing
46+
- set `transcode.hardware_decode` to `vaapi` on Linux hosts with Intel or AMD `/dev/dri` VAAPI support
47+
- VAAPI mode uses hardware decode, `scale_vaapi` GPU scaling, and `h264_vaapi` hardware H.264 encoding
48+
- startup will probe VAAPI availability, including device initialization, `scale_vaapi`, and `h264_vaapi`, and fail startup if the device, driver, or ffmpeg support is missing
4849

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

@@ -104,8 +105,8 @@ Copy `config.example.json` and change the upstream URL:
104105

105106
Leave `public_url` empty when clients connect directly to EmbyTranscoder. Set it when EmbyTranscoder sits behind another reverse proxy.
106107
Leave `debug` as `false` for concise action-level logs. Set it to `true` when you want detailed `TRACE_SWITCH` and request-level diagnostics.
107-
Set `hardware_decode` to `vaapi` to enable VAAPI hardware decode. The default `hardware_device` is `/dev/dri/renderD128`.
108-
If the device, driver, or ffmpeg probe fails, startup stops with an error.
108+
Set `hardware_decode` to `vaapi` to enable the full VAAPI transcode pipeline: hardware decode, GPU scaling, and H.264 hardware encoding. The default `hardware_device` is `/dev/dri/renderD128`.
109+
If the device, driver, `scale_vaapi`, or `h264_vaapi` probe fails, startup stops with an error.
109110

110111
## Transcode Lifecycle
111112

docker/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ 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`
27-
- startup probes VAAPI support, including device initialization, and fails startup when the device, driver, or ffmpeg support is unavailable
26+
- set `transcode.hardware_decode` to `vaapi` to use Intel or AMD VAAPI through `/dev/dri`
27+
- VAAPI mode uses hardware decode, `scale_vaapi` GPU scaling, and `h264_vaapi` hardware H.264 encoding
28+
- startup probes VAAPI support, including device initialization, `scale_vaapi`, and `h264_vaapi`, and fails startup when the device, driver, or ffmpeg support is unavailable
2829
- the image includes common Intel and AMD VAAPI userspace drivers plus `vainfo`
2930
- video output is capped at 1920x1080 while preserving aspect ratio
3031
- playbackinfo rewrites prewarm the transcode session before the first playlist request

internal/transcode/ffmpeg_args_test.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,37 @@ func TestBuildFFmpegArgsAppliesVAAPIHardwareDecodeBeforeInput(t *testing.T) {
126126
}
127127
}
128128

129+
func TestBuildFFmpegArgsUsesFullVAAPITranscodePipeline(t *testing.T) {
130+
session := &Session{
131+
ID: "item123",
132+
Dir: t.TempDir(),
133+
}
134+
request := Request{InputURL: "http://upstream/stream"}
135+
136+
args := buildFFmpegArgs(session, request, FFmpegOptions{
137+
HardwareDecode: "vaapi",
138+
HardwareDevice: "/dev/dri/renderD128",
139+
})
140+
141+
outputFormatIndex := slices.Index(args, "-hwaccel_output_format")
142+
if outputFormatIndex < 0 || args[outputFormatIndex+1] != "vaapi" {
143+
t.Fatalf("missing VAAPI hardware frame output: %v", args)
144+
}
145+
vfIndex := slices.Index(args, "-vf")
146+
if vfIndex < 0 || !strings.Contains(args[vfIndex+1], "scale_vaapi=") || !strings.Contains(args[vfIndex+1], "format=nv12") {
147+
t.Fatalf("expected VAAPI scale filter, args=%v", args)
148+
}
149+
codecIndex := slices.Index(args, "-c:v")
150+
if codecIndex < 0 || args[codecIndex+1] != "h264_vaapi" {
151+
t.Fatalf("expected VAAPI H.264 encoder, args=%v", args)
152+
}
153+
for _, softwareOnly := range []string{"libx264", "-preset", "-tune"} {
154+
if slices.Contains(args, softwareOnly) {
155+
t.Fatalf("VAAPI pipeline should not include software encoder arg %q: %v", softwareOnly, args)
156+
}
157+
}
158+
}
159+
129160
func TestBuildFFmpegArgsCapsVideoOutputTo1080p(t *testing.T) {
130161
session := &Session{
131162
ID: "item123",
@@ -227,6 +258,14 @@ case " $* " in
227258
printf 'Hardware acceleration methods:\nvaapi\n'
228259
exit 0
229260
;;
261+
*" -encoders "*)
262+
printf ' V....D h264_vaapi H.264/AVC (VAAPI)\n'
263+
exit 0
264+
;;
265+
*" -filters "*)
266+
printf ' ... scale_vaapi V->V Scale to/from VAAPI surfaces.\n'
267+
exit 0
268+
;;
230269
*" -init_hw_device "*)
231270
echo 'Failed to initialise VAAPI connection' >&2
232271
exit 1
@@ -262,6 +301,57 @@ exit 0
262301
}
263302
}
264303

304+
func TestDefaultHardwareProbeRequiresFullVAAPIPipeline(t *testing.T) {
305+
tempDir := t.TempDir()
306+
ffmpegPath := filepath.Join(tempDir, "ffmpeg")
307+
callsPath := filepath.Join(tempDir, "calls")
308+
script := fmt.Sprintf(`#!/bin/sh
309+
echo "$@" >> %q
310+
case " $* " in
311+
*" -hwaccels "*)
312+
printf 'Hardware acceleration methods:\nvaapi\n'
313+
exit 0
314+
;;
315+
*" -encoders "*)
316+
printf ' V....D h264_vaapi H.264/AVC (VAAPI)\n'
317+
exit 0
318+
;;
319+
*" -filters "*)
320+
printf ' ... scale_vaapi V->V Scale to/from VAAPI surfaces.\n'
321+
exit 0
322+
;;
323+
*" -init_hw_device "*)
324+
exit 0
325+
;;
326+
esac
327+
exit 0
328+
`, callsPath)
329+
if err := os.WriteFile(ffmpegPath, []byte(script), 0o755); err != nil {
330+
t.Fatal(err)
331+
}
332+
devicePath := filepath.Join(tempDir, "renderD128")
333+
if err := os.WriteFile(devicePath, nil, 0o600); err != nil {
334+
t.Fatal(err)
335+
}
336+
337+
if err := defaultHardwareProbe(ffmpegPath, FFmpegOptions{
338+
HardwareDecode: "vaapi",
339+
HardwareDevice: devicePath,
340+
}); err != nil {
341+
t.Fatal(err)
342+
}
343+
344+
calls, err := os.ReadFile(callsPath)
345+
if err != nil {
346+
t.Fatal(err)
347+
}
348+
for _, want := range []string{"-hwaccels", "-encoders", "-filters", "-init_hw_device vaapi=probe:" + devicePath, "-filter_hw_device probe", "scale_vaapi=", "-c:v h264_vaapi"} {
349+
if !strings.Contains(string(calls), want) {
350+
t.Fatalf("expected hardware probe call containing %q, calls=%s", want, calls)
351+
}
352+
}
353+
}
354+
265355
func TestNewManagerFallsBackToSoftwareDecodeWhenHardwareProbeFails(t *testing.T) {
266356
manager := NewManager(Options{
267357
FFmpegPath: "/usr/bin/ffmpeg",
@@ -285,7 +375,7 @@ func TestFFmpegOptionsSummaryLabelsDecodeMode(t *testing.T) {
285375
if got := ffmpegOptionsSummary(FFmpegOptions{}); got != "software" {
286376
t.Fatalf("software summary = %q", got)
287377
}
288-
if got := ffmpegOptionsSummary(FFmpegOptions{HardwareDecode: "vaapi", HardwareDevice: "/dev/dri/renderD128"}); got != "vaapi:/dev/dri/renderD128" {
378+
if got := ffmpegOptionsSummary(FFmpegOptions{HardwareDecode: "vaapi", HardwareDevice: "/dev/dri/renderD128"}); got != "vaapi-full:/dev/dri/renderD128" {
289379
t.Fatalf("vaapi summary = %q", got)
290380
}
291381
}

internal/transcode/manager.go

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ func NewManagerStrict(options Options) (*Manager, error) {
128128
if err != nil {
129129
return nil, err
130130
}
131+
if ffmpegOptions.HardwareDecode != "" {
132+
logging.Infof("hardware transcode enabled pipeline=%s", ffmpegOptionsSummary(ffmpegOptions))
133+
}
131134
options.Runner = FFmpegRunner{
132135
Path: options.FFmpegPath,
133136
Options: ffmpegOptions,
@@ -745,10 +748,10 @@ type HardwareProbe func(ffmpegPath string, options FFmpegOptions) error
745748
func resolveHardwareDecodeOptions(ffmpegPath string, options FFmpegOptions, probe HardwareProbe) FFmpegOptions {
746749
resolved, err := resolveHardwareDecodeOptionsStrict(ffmpegPath, options, probe)
747750
if err != nil {
748-
logging.Infof("hardware decode unavailable mode=%s device=%s reason=%v fallback=software", options.HardwareDecode, options.HardwareDevice, err)
751+
logging.Infof("hardware transcode unavailable mode=%s device=%s reason=%v fallback=software", options.HardwareDecode, options.HardwareDevice, err)
749752
return FFmpegOptions{}
750753
}
751-
logging.Infof("hardware decode enabled mode=%s device=%s", resolved.HardwareDecode, resolved.HardwareDevice)
754+
logging.Infof("hardware transcode enabled pipeline=%s", ffmpegOptionsSummary(resolved))
752755
return resolved
753756
}
754757

@@ -787,10 +790,19 @@ func defaultHardwareProbe(ffmpegPath string, options FFmpegOptions) error {
787790
if err := probeFFmpegHWAccel(ffmpegPath, "vaapi"); err != nil {
788791
return err
789792
}
793+
if err := probeFFmpegEncoder(ffmpegPath, "h264_vaapi"); err != nil {
794+
return err
795+
}
796+
if err := probeFFmpegFilter(ffmpegPath, "scale_vaapi"); err != nil {
797+
return err
798+
}
790799
if err := probeDevice(options.HardwareDevice); err != nil {
791800
return err
792801
}
793-
return probeFFmpegVAAPIDeviceInit(ffmpegPath, options.HardwareDevice)
802+
if err := probeFFmpegVAAPIDeviceInit(ffmpegPath, options.HardwareDevice); err != nil {
803+
return err
804+
}
805+
return probeFFmpegVAAPIFullPipeline(ffmpegPath, options.HardwareDevice)
794806
default:
795807
return fmt.Errorf("unsupported hardware decode mode %q", options.HardwareDecode)
796808
}
@@ -815,6 +827,49 @@ func probeFFmpegHWAccel(ffmpegPath, name string) error {
815827
return fmt.Errorf("ffmpeg does not list %s hwaccel", name)
816828
}
817829

830+
func probeFFmpegEncoder(ffmpegPath, name string) error {
831+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
832+
defer cancel()
833+
834+
output, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders").CombinedOutput()
835+
if ctx.Err() != nil {
836+
return fmt.Errorf("ffmpeg encoder probe timed out")
837+
}
838+
if err != nil {
839+
return fmt.Errorf("ffmpeg encoder probe failed: %w", err)
840+
}
841+
if ffmpegListContains(output, name) {
842+
return nil
843+
}
844+
return fmt.Errorf("ffmpeg does not list %s encoder", name)
845+
}
846+
847+
func probeFFmpegFilter(ffmpegPath, name string) error {
848+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
849+
defer cancel()
850+
851+
output, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").CombinedOutput()
852+
if ctx.Err() != nil {
853+
return fmt.Errorf("ffmpeg filter probe timed out")
854+
}
855+
if err != nil {
856+
return fmt.Errorf("ffmpeg filter probe failed: %w", err)
857+
}
858+
if ffmpegListContains(output, name) {
859+
return nil
860+
}
861+
return fmt.Errorf("ffmpeg does not list %s filter", name)
862+
}
863+
864+
func ffmpegListContains(output []byte, name string) bool {
865+
for _, line := range strings.Split(string(output), "\n") {
866+
if strings.Contains(strings.ToLower(line), strings.ToLower(name)) {
867+
return true
868+
}
869+
}
870+
return false
871+
}
872+
818873
func probeFFmpegVAAPIDeviceInit(ffmpegPath, device string) error {
819874
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
820875
defer cancel()
@@ -841,6 +896,35 @@ func probeFFmpegVAAPIDeviceInit(ffmpegPath, device string) error {
841896
return nil
842897
}
843898

899+
func probeFFmpegVAAPIFullPipeline(ffmpegPath, device string) error {
900+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
901+
defer cancel()
902+
903+
output, err := exec.CommandContext(ctx, ffmpegPath,
904+
"-hide_banner",
905+
"-loglevel", "error",
906+
"-init_hw_device", "vaapi=probe:"+device,
907+
"-filter_hw_device", "probe",
908+
"-f", "lavfi",
909+
"-i", "nullsrc=s=64x64:d=0.1",
910+
"-vf", "format=nv12,hwupload,"+vaapiScaleFilter(64, 64),
911+
"-frames:v", "1",
912+
"-c:v", "h264_vaapi",
913+
"-f", "null",
914+
"-",
915+
).CombinedOutput()
916+
if ctx.Err() != nil {
917+
return fmt.Errorf("vaapi full pipeline probe timed out")
918+
}
919+
if err != nil {
920+
if message := strings.TrimSpace(string(output)); message != "" {
921+
return fmt.Errorf("vaapi full pipeline probe failed: %w: %s", err, message)
922+
}
923+
return fmt.Errorf("vaapi full pipeline probe failed: %w", err)
924+
}
925+
return nil
926+
}
927+
844928
func probeDevice(device string) error {
845929
if strings.TrimSpace(device) == "" {
846930
return errors.New("hardware device is required")
@@ -927,6 +1011,9 @@ func ffmpegOptionsSummary(options FFmpegOptions) string {
9271011
if mode == "" || mode == "none" || mode == "off" || mode == "false" {
9281012
return "software"
9291013
}
1014+
if mode == "vaapi" {
1015+
mode = "vaapi-full"
1016+
}
9301017
device := strings.TrimSpace(options.HardwareDevice)
9311018
if device == "" {
9321019
return mode
@@ -971,17 +1058,9 @@ func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions
9711058
"-i", request.InputURL,
9721059
"-map", "0:v:0",
9731060
"-map", audioMapArg(session, request),
974-
"-vf", fmt.Sprintf("scale=w=%d:h=%d:force_original_aspect_ratio=decrease:force_divisible_by=2", maxTranscodeWidth, maxTranscodeHeight),
975-
"-c:v", "libx264",
976-
"-preset", "veryfast",
977-
"-tune", "zerolatency",
978-
"-profile:v", "high",
979-
"-level", "4.1",
980-
"-g", strconv.Itoa(lowLatencyGOP),
981-
"-keyint_min", strconv.Itoa(lowLatencyGOP),
982-
"-sc_threshold", "0",
983-
"-bf", "0",
984-
"-pix_fmt", "yuv420p",
1061+
)
1062+
args = appendVideoTranscodeArgs(args, ffmpegOptions)
1063+
args = append(args,
9851064
"-c:a", "aac",
9861065
"-b:a", "160k",
9871066
"-ac", "2",
@@ -1003,6 +1082,38 @@ func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions
10031082
return args
10041083
}
10051084

1085+
func appendVideoTranscodeArgs(args []string, options FFmpegOptions) []string {
1086+
if isVAAPITranscode(options) {
1087+
return append(args,
1088+
"-vf", vaapiScaleFilter(maxTranscodeWidth, maxTranscodeHeight),
1089+
"-c:v", "h264_vaapi",
1090+
"-profile:v", "high",
1091+
"-level", "4.1",
1092+
"-g", strconv.Itoa(lowLatencyGOP),
1093+
"-keyint_min", strconv.Itoa(lowLatencyGOP),
1094+
"-bf", "0",
1095+
"-qp", "23",
1096+
)
1097+
}
1098+
return append(args,
1099+
"-vf", fmt.Sprintf("scale=w=%d:h=%d:force_original_aspect_ratio=decrease:force_divisible_by=2", maxTranscodeWidth, maxTranscodeHeight),
1100+
"-c:v", "libx264",
1101+
"-preset", "veryfast",
1102+
"-tune", "zerolatency",
1103+
"-profile:v", "high",
1104+
"-level", "4.1",
1105+
"-g", strconv.Itoa(lowLatencyGOP),
1106+
"-keyint_min", strconv.Itoa(lowLatencyGOP),
1107+
"-sc_threshold", "0",
1108+
"-bf", "0",
1109+
"-pix_fmt", "yuv420p",
1110+
)
1111+
}
1112+
1113+
func vaapiScaleFilter(width, height int) string {
1114+
return fmt.Sprintf("scale_vaapi=w=%d:h=%d:force_original_aspect_ratio=decrease:force_divisible_by=2:format=nv12", width, height)
1115+
}
1116+
10061117
func audioMapArg(session *Session, request Request) string {
10071118
return "0:a:0?"
10081119
}
@@ -1016,12 +1127,17 @@ func appendHardwareDecodeArgs(args []string, options FFmpegOptions) []string {
10161127
if device := strings.TrimSpace(options.HardwareDevice); device != "" {
10171128
args = append(args, "-hwaccel_device", device)
10181129
}
1130+
args = append(args, "-hwaccel_output_format", "vaapi")
10191131
return args
10201132
default:
10211133
return args
10221134
}
10231135
}
10241136

1137+
func isVAAPITranscode(options FFmpegOptions) bool {
1138+
return strings.EqualFold(strings.TrimSpace(options.HardwareDecode), "vaapi")
1139+
}
1140+
10251141
func ticksSeconds(ticks int64) string {
10261142
return strconv.FormatFloat(float64(ticks)/10_000_000, 'f', 6, 64)
10271143
}

0 commit comments

Comments
 (0)