fix(playback): composite bitmap subtitle burn-in on the GPU for QSV/VAAPI

Bitmap subtitle burn-in (PGS/VOBSUB/DVB) ran the whole video through a
GPU->CPU->GPU roundtrip: every decoded frame was hwdownload'd to system
memory, the subtitle bitmap composited with the software overlay filter,
then hwupload'd back for the encoder. On a 1080p source that pins the
encode below realtime (~0.68x measured), so the client can never build a
buffer and rides the produced-head edge indefinitely; the same roundtrip
also intermittently crashes the QSV buffer path with SIGBUS.

Composite on the GPU instead via overlay_vaapi for QSV and VAAPI: the
decoded video never leaves its VAAPI surface and only the small,
low-frequency subtitle bitmap is uploaded. Measured ~7x realtime and
crash-free on the same file. The libass text path is unchanged (it must
stay on CPU), and NVENC/CPU keep the software overlay because overlay_cuda
is unverified on the bundled ffmpeg.

Adds QSV and NVENC bitmap burn-in tests and updates the VAAPI test to the
GPU graph.
This commit is contained in:
CoffeeKnyte
2026-07-29 09:38:46 -04:00
committed by Quick
parent 824d7bdf43
commit 7907e0c28c
2 changed files with 105 additions and 21 deletions
+35 -18
View File
@@ -711,7 +711,7 @@ func appendAudioArgs(args []string, opts TranscodeOpts) []string {
// appendBitmapSubtitleBurnInArgs adds burn-in arguments for BITMAP subtitle
// codecs (PGS/VOBSUB/DVB). libass's subtitles= filter cannot render bitmap
// tracks, so the decoded subtitle stream is composited onto the video with
// tracks, so the decoded subtitle stream is composited onto the video with an
// overlay in a -filter_complex graph (the "Plex route"). The graph's output
// pad [vout] replaces the raw video stream in stream mapping (see
// appendStreamSelectionArgs), so -vf must never be emitted alongside this.
@@ -722,32 +722,49 @@ func appendAudioArgs(args []string, opts TranscodeOpts) []string {
// eof_action=pass keeps the video flowing untouched once the subtitle stream
// ends instead of freezing the last overlay frame on screen.
//
// Hardware pipelines mirror appendSubtitleBurnInArgs: frames are downloaded
// to CPU memory for the overlay, then re-uploaded for the hardware encoder.
// QSV/VAAPI composite ON the GPU via overlay_vaapi: the decoded video never
// leaves its VAAPI surface, and only the small, low-frequency subtitle bitmap is
// uploaded. This avoids the full-frame GPU→CPU→GPU roundtrip a software overlay
// forces — that roundtrip runs below realtime on 1080p sources (~0.7x), starving
// the client, and can crash the QSV buffer path with SIGBUS. See
// appendSubtitleBurnInArgs for the TEXT path, which must stay on CPU because
// libass is a software renderer. NVENC and CPU encodes keep the software overlay:
// overlay_cuda is unverified on this build, so the CUDA path retains the safe
// (if slower) roundtrip rather than risk a broken graph.
func appendBitmapSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
// [0:s:N] indexes subtitle streams only, matching the si=N semantics of
// the text path — SubtitleTrackIndex is the embedded subtitle ordinal.
cpuFilters := fmt.Sprintf("[0:s:%d]overlay=eof_action=pass", opts.SubtitleTrackIndex)
if scale := resolutionToScale(opts.TargetResolution); scale != "" {
cpuFilters += "," + scale
}
subInput := fmt.Sprintf("[0:s:%d]", opts.SubtitleTrackIndex)
var graph string
switch opts.HWAccel {
case "qsv":
// VAAPI→QSV pipeline: download decoded frames to CPU, overlay, convert
// to nv12, upload back to VAAPI, then map to QSV for the encoder.
graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters +
",format=nv12,hwupload,hwmap=derive_device=qsv,format=qsv[vout]"
// GPU composite: upload only the subtitle bitmap, overlay it onto the
// VAAPI video surface, scale, then map to QSV for the encoder. The scale
// helper already appends the hwmap=derive_device=qsv tail.
graph = subInput + "format=bgra,hwupload[sub];" +
"[0:v:0][sub]overlay_vaapi=eof_action=pass," + qsvScaleFilter(opts.TargetResolution) + "[vout]"
case "vaapi":
graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters +
",format=nv12,hwupload[vout]"
case "nvenc":
graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters +
",format=nv12,hwupload_cuda[vout]"
// GPU composite: same as QSV but the frames stay on VAAPI through the
// encoder, so no cross-device map is needed.
graph = subInput + "format=bgra,hwupload[sub];" +
"[0:v:0][sub]overlay_vaapi=eof_action=pass," + vaapiScaleFilter(opts.TargetResolution) + "[vout]"
default:
// CPU encoding: overlay directly on decoded frames.
graph = "[0:v:0]" + cpuFilters + "[vout]"
// NVENC and CPU: software overlay on CPU frames. Build the overlay
// fragment (subtitle input + optional post-scale) once, then wire it into
// the encode-specific pipeline.
cpuFilters := subInput + "overlay=eof_action=pass"
if scale := resolutionToScale(opts.TargetResolution); scale != "" {
cpuFilters += "," + scale
}
if opts.HWAccel == "nvenc" {
// Download to CPU for the overlay, then re-upload to CUDA.
graph = "[0:v:0]hwdownload,format=yuv420p[vmain];[vmain]" + cpuFilters +
",format=nv12,hwupload_cuda[vout]"
} else {
// CPU encoding: overlay directly on decoded frames.
graph = "[0:v:0]" + cpuFilters + "[vout]"
}
}
return append(args, "-filter_complex", graph)
+70 -3
View File
@@ -307,7 +307,7 @@ func TestBuildFFmpegArgs_BitmapBurnInNoScaleKeepsNativeResolution(t *testing.T)
}
}
func TestBuildFFmpegArgs_BitmapBurnInVAAPIRoundTripsThroughCPU(t *testing.T) {
func TestBuildFFmpegArgs_BitmapBurnInVAAPICompositesOnGPU(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
@@ -324,9 +324,14 @@ func TestBuildFFmpegArgs_BitmapBurnInVAAPIRoundTripsThroughCPU(t *testing.T) {
})
joined := strings.Join(args, " ")
want := "-filter_complex [0:v:0]hwdownload,format=yuv420p[vmain];[vmain][0:s:1]overlay=eof_action=pass,scale=-2:720,format=nv12,hwupload[vout]"
// Only the subtitle bitmap is uploaded; the video stays on the VAAPI surface
// and is composited with overlay_vaapi — no full-frame hwdownload roundtrip.
want := "-filter_complex [0:s:1]format=bgra,hwupload[sub];[0:v:0][sub]overlay_vaapi=eof_action=pass,scale_vaapi=w=-2:h=720:format=nv12[vout]"
if !strings.Contains(joined, want) {
t.Fatalf("vaapi bitmap burn-in should hwdownload → overlay → hwupload %q: %s", want, joined)
t.Fatalf("vaapi bitmap burn-in should composite on GPU %q: %s", want, joined)
}
if strings.Contains(joined, "hwdownload") {
t.Fatalf("vaapi bitmap burn-in must not roundtrip the video through CPU: %s", joined)
}
if !strings.Contains(joined, "-map [vout]") {
t.Fatalf("vaapi bitmap burn-in should map the filter graph output: %s", joined)
@@ -339,6 +344,68 @@ func TestBuildFFmpegArgs_BitmapBurnInVAAPIRoundTripsThroughCPU(t *testing.T) {
}
}
func TestBuildFFmpegArgs_BitmapBurnInQSVCompositesOnGPU(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
SessionID: "session-pgs-qsv",
SourceVideoCodec: "h264",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
HWAccel: "qsv",
TargetResolution: "720p",
SubtitleTrackIndex: 1,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
})
joined := strings.Join(args, " ")
// GPU composite via overlay_vaapi, then map the VAAPI surface to QSV for the
// encoder — the video never leaves hardware memory.
want := "-filter_complex [0:s:1]format=bgra,hwupload[sub];[0:v:0][sub]overlay_vaapi=eof_action=pass,scale_vaapi=w=-2:h=720:format=nv12,hwmap=derive_device=qsv,format=qsv[vout]"
if !strings.Contains(joined, want) {
t.Fatalf("qsv bitmap burn-in should composite on GPU %q: %s", want, joined)
}
if strings.Contains(joined, "hwdownload") {
t.Fatalf("qsv bitmap burn-in must not roundtrip the video through CPU: %s", joined)
}
if strings.Contains(joined, "-vf ") {
t.Fatalf("qsv bitmap burn-in must not emit -vf: %s", joined)
}
if !strings.Contains(joined, "-c:v h264_qsv") {
t.Fatalf("qsv bitmap burn-in should keep the hardware encoder: %s", joined)
}
}
func TestBuildFFmpegArgs_BitmapBurnInNVENCStaysOnCPUOverlay(t *testing.T) {
// overlay_cuda is unverified on the bundled ffmpeg, so NVENC keeps the safe
// software roundtrip: download the frame, overlay on CPU, re-upload to CUDA.
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
OutputDir: "/tmp/out",
SessionID: "session-pgs-nvenc",
SourceVideoCodec: "h264",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
HWAccel: "nvenc",
TargetResolution: "720p",
SubtitleTrackIndex: 1,
SubtitleBurnIn: true,
SubtitleCodec: "hdmv_pgs_subtitle",
})
joined := strings.Join(args, " ")
want := "-filter_complex [0:v:0]hwdownload,format=yuv420p[vmain];[vmain][0:s:1]overlay=eof_action=pass,scale=-2:720,format=nv12,hwupload_cuda[vout]"
if !strings.Contains(joined, want) {
t.Fatalf("nvenc bitmap burn-in should keep the CPU roundtrip %q: %s", want, joined)
}
if strings.Contains(joined, "overlay_vaapi") {
t.Fatalf("nvenc bitmap burn-in must not use the VAAPI GPU overlay: %s", joined)
}
}
func TestBuildFFmpegArgs_TextBurnInStillUsesSubtitlesFilter(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",