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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user