fix(plex): a codec the GPU cannot decode is decoded on the CPU and still encoded on the GPU

The prod iGPU has no VC-1 Advanced decode ("No support for codec vc1 profile 3"), so every VC-1
session died on the GPU and fell back to libx264, which needs ~17 s for the three warm-up segments:
a start or a seek took ~19.5 s. VC-1, XviD and DivX now take CPU decode -> hwupload -> h264_vaapi
(three segments in 5.5 s for a 1080p VC-1 remux, measured on prod hardware with this argv).

The transcode_hw facet now says whether the GPU encodes: VC-1/XviD/DivX count, and so does an HDR
film (the OpenCL tonemap runs on the GPU; the facet said software). Migration 0119 rewrites the
stored facet and re-queues 10-bit items for the HDR probe.
This commit is contained in:
2026-11-07 18:39:15 +01:00
parent 2b7620ded9
commit 9778bdf351
6 changed files with 385 additions and 87 deletions
+75 -32
View File
@@ -35,7 +35,7 @@ from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.plex.models import PlexItem
from app.plex import paths
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
from app.plex.sync import CPU_DECODE_CODECS, HDR_TRANSFERS, HW_DECODABLE_CODECS
log = logs.get_logger(__name__)
_HLS_ROOT = (
@@ -120,6 +120,7 @@ class HlsSession:
hw: bool = False,
tonemap: bool = False,
user_id: int = 0,
cpu_decode: bool = False,
):
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
# replaces the viewer's registered session for the item — and the old player's next request
@@ -135,6 +136,7 @@ class HlsSession:
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
self.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
self.cpu_decode = cpu_decode # HW encode of frames the CPU decoded (a codec the GPU can't)
self.start_s = start_s # the REQUESTED seek offset
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
@@ -188,8 +190,11 @@ _HLS_TAIL = [
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
# 1080p transcodes are fine and the API stays responsive.
# * CPU decode → VAAPI encode (`format=nv12,hwupload` + `h264_vaapi`) — a codec the GPU cannot decode
# but whose 8-bit picture it encodes (`CPU_DECODE_CODECS`: VC-1, XviD, DivX). The encode is the
# expensive half, so this still takes most of the load off the CPU.
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
# iGPU enablement) or the source fits neither GPU route (AV1, a 10-bit SDR source). CPU-bound, so
# it reserves a core via `-threads` (see encode_thread_cap).
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
@@ -198,10 +203,10 @@ _VAAPI_DEVICE = "/dev/dri/renderD128"
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
# full-GPU VAAPI pipeline. A source outside it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
# codec; aliased to the old name to keep the call sites below unchanged.
# full-GPU VAAPI pipeline. A source outside it is decoded on the CPU when its codec is in
# `CPU_DECODE_CODECS` (the GPU still encodes), else transcoded in software. THE single source of both
# sets is plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't
# disagree by codec; aliased to the old name to keep the call sites below unchanged.
_HW_DECODABLE = HW_DECODABLE_CODECS
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
@@ -302,16 +307,22 @@ def _opencl_present() -> bool:
return False
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
def _hwaccel_in_opts(hw: bool, tonemap: bool = False, cpu_decode: bool = False) -> list[str]:
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
round-trip to system memory. Empty for the software path.
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`.
The CPU-decode variant opens the VAAPI device for the FILTER graph only (`hwupload` needs one) and
asks for no `-hwaccel`: the codec is one the GPU refuses to decode. This exact form is the one
measured on prod with the HLS muxer and `-copyts` (and to the end of the file)."""
if not hw:
return []
if cpu_decode:
return ["-init_hw_device", f"vaapi=va:{_VAAPI_DEVICE}", "-filter_hw_device", "va"]
if tonemap:
return [
"-init_hw_device",
@@ -359,6 +370,7 @@ def _venc_opts(
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
cpu_decode: bool = False,
) -> list[str]:
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
@@ -385,6 +397,10 @@ def _venc_opts(
"h264_vaapi",
*force_kf,
]
if hw and cpu_decode:
# The CPU decoded the frames: convert them to the encoder's 8-bit nv12 and upload them to the
# device `-filter_hw_device` named (see _hwaccel_in_opts).
return ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", *force_kf]
if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
@@ -422,13 +438,18 @@ def _render_node_present() -> bool:
return Path(_VAAPI_DEVICE).exists()
def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
def _select_encode(
db: DbSession, item: PlexItem, transcode: bool, src: Path
) -> tuple[bool, bool, bool]:
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap, cpu_decode)`:
* `(False, False, False)` → software libx264 (the fallback: no render node, a source neither GPU
route takes, or a 10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
* `(True, False, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR
source the GPU decodes.
* `(True, True, False)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG)
source, when the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
* `(True, False, True)` → CPU decode → `hwupload` → VAAPI encode — an 8-bit SDR source in a codec
the GPU cannot decode (`CPU_DECODE_CODECS`).
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
@@ -438,31 +459,40 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) ->
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
source that has an OpenCL runtime, while a 10-bit SDR source drops to software. In both "on" and
"auto" a codec the GPU is known not to decode goes the CPU-decode route: asking the GPU to decode it
only bought the fallback's wasted second before libx264."""
sw = (False, False, False)
if not transcode:
return (False, False)
return sw
mode = sysconfig.get_str(db, "plex_transcode_hw")
if mode == "off" or not _render_node_present():
return (False, False)
return sw
codec = (item.codec_video or "").lower()
if mode == "on":
if codec in CPU_DECODE_CODECS:
return (True, False, True)
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
return (True, tonemap)
return (True, tonemap, False)
# auto
if (item.codec_video or "").lower() not in _HW_DECODABLE:
return (False, False)
if codec in CPU_DECODE_CODECS:
# The upload converts to 8-bit nv12: only an 8-bit SDR picture comes through it unchanged.
return (True, False, True) if _probe_video_kind(src) == _VK_HWSAFE else sw
if codec not in _HW_DECODABLE:
return sw
kind = _probe_video_kind(src)
if kind == _VK_HWSAFE:
return (True, False) # 8-bit SDR → full-GPU scale path
return (True, False, False) # 8-bit SDR → full-GPU scale path
if kind == _VK_HDR:
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
return (True, True) if _opencl_present() else (False, False)
return (True, True, False) if _opencl_present() else sw
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
return (False, False)
return sw
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
@@ -540,13 +570,15 @@ def _ffmpeg_cmd(
crf: int = 20,
max_height: int = 0,
loudnorm: bool = False,
cpu_decode: bool = False,
) -> list[str]:
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
# `cpu_decode` is the HW encode of CPU-decoded frames: a device for the filter graph, no -hwaccel.
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height, cpu_decode)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap, cpu_decode)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
@@ -800,7 +832,11 @@ def start_session(
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
def _spawn(
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
hw: bool,
tonemap: bool = False,
cpu_decode: bool = False,
*,
replace_only: "HlsSession | None" = None,
) -> "HlsSession | None":
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
@@ -815,10 +851,12 @@ def start_session(
preset = sysconfig.get_str(db, "plex_transcode_preset")
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
# Nice the child that works the CPU — a SOFTWARE transcode, or a CPU decode feeding the GPU
# encoder (not the full-GPU path, and not remux) — so it can't starve the co-located
# API/playback. preexec_fn runs in the forked child (POSIX); on a platform without fork (the API
# always runs in a Linux container in prod) it's simply omitted.
cpu_bound = transcode and (not hw or cpu_decode)
preexec = _sw_child_preexec if (cpu_bound and os.name == "posix") else None
doomed: list[tuple[HlsSession, Path | None]] = []
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
# was moved aside under the lock is otherwise left for a restart's sweep.
@@ -851,6 +889,7 @@ def start_session(
crf=crf,
max_height=max_height,
loudnorm=loudnorm,
cpu_decode=cpu_decode,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -866,6 +905,7 @@ def start_session(
hw=hw,
tonemap=tonemap,
user_id=user_id,
cpu_decode=cpu_decode,
)
_sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream
@@ -879,6 +919,7 @@ def start_session(
transcode=transcode,
hw=hw,
tonemap=tonemap,
cpu_decode=cpu_decode,
variants=len(variants or ()),
start_s=round(start_s, 1),
),
@@ -937,7 +978,8 @@ def start_session(
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
break
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s"
" tonemap=%s cpu_decode=%s",
key,
start_s,
s.media_start_s,
@@ -946,6 +988,7 @@ def start_session(
transcode,
s.hw,
s.tonemap,
s.cpu_decode,
)
return s