feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable, HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on the prod iGPU at ~4.2x realtime, ~1 CPU core. - stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable source picks the tonemap graph when the OpenCL runtime is present, else drops to software. `_opencl_present` gates on a registered ICD. `_venc_opts` / `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices, `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept as the hw-half wrapper. The runtime HW→SW fallback still backstops a session that emits no segment. - Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5; the .deb runs on the trixie base — verified live). Harmless without /dev/dri. - route + FE: thread `tonemap` through the session response; the stats overlay mode row reads "transcode · GPU · HDR→SDR" for the tonemap path. - tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on × 8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
This commit is contained in:
+13
@@ -60,6 +60,19 @@ RUN apt-get update \
|
||||
&& rm /tmp/deno.zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
|
||||
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
|
||||
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
|
||||
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
|
||||
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
|
||||
# source is added only for this one install and removed after, so it can't drift the rest of the image.
|
||||
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
|
||||
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
|
||||
&& rm /etc/apt/sources.list.d/bookworm.sources \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
||||
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||
|
||||
+114
-33
@@ -96,12 +96,14 @@ class HlsSession:
|
||||
entry: str,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
):
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
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.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).
|
||||
@@ -153,21 +155,26 @@ _HLS_TAIL = [
|
||||
# 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.
|
||||
_VAAPI_DEVICE = "/dev/dri/renderD128"
|
||||
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
|
||||
# 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 this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
|
||||
# when the render node is present, because the GPU can't decode it to feed its own encoder.
|
||||
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
|
||||
# Color-transfer values that mark an HDR source (PQ / HLG). Even a HW-decodable HEVC that is HDR must
|
||||
# take the software path — see _probe_video_hwsafe.
|
||||
# Color-transfer values that mark an HDR source (PQ / HLG). A HW-decodable HDR/10-bit source can't take
|
||||
# the plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. See _probe_video_hwsafe.
|
||||
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||
|
||||
|
||||
def _probe_video_hwsafe(src: Path) -> bool:
|
||||
"""Whether the source is safe for the VAAPI HW transcode pipeline — i.e. 8-bit SDR. A 10-bit (p010)
|
||||
or HDR source breaks `scale_vaapi=format=nv12` on Gen 9.5 VPP (`VAProfile not supported` → ffmpeg
|
||||
emits zero segments and the item never plays), so it must take the software libx264 path, which
|
||||
CPU-decodes and downconverts to 8-bit yuv420p cleanly. Ground truth from ffprobe (bit depth via
|
||||
pix_fmt / bits_per_raw_sample, HDR via color_transfer); the Plex mirror doesn't store these.
|
||||
"""Whether the source is safe for the PLAIN VAAPI transcode pipeline — i.e. 8-bit SDR. A 10-bit
|
||||
(p010) or HDR source breaks `scale_vaapi=format=nv12` on Gen 9.5 VPP (`VAProfile not supported` →
|
||||
ffmpeg emits zero segments and the item never plays), so it takes the OpenCL tonemap graph instead
|
||||
(when the OpenCL runtime is present) or software libx264 (which CPU-decodes and downconverts to 8-bit
|
||||
yuv420p cleanly). Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via
|
||||
color_transfer); the Plex mirror doesn't store these.
|
||||
|
||||
Returns True (assume HW-safe) on any probe failure: the runtime HW→SW fallback in start_session is
|
||||
the backstop for a HW session that still produces nothing, so a probe miss only costs the rare
|
||||
@@ -213,18 +220,48 @@ def _probe_video_hwsafe(src: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool) -> list[str]:
|
||||
def _opencl_present() -> bool:
|
||||
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
|
||||
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
|
||||
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
|
||||
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
|
||||
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
||||
try:
|
||||
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool, tonemap: 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."""
|
||||
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`."""
|
||||
if not hw:
|
||||
return []
|
||||
if tonemap:
|
||||
return [
|
||||
"-init_hw_device",
|
||||
f"vaapi=va:{_VAAPI_DEVICE}",
|
||||
"-init_hw_device",
|
||||
"opencl=ocl@va",
|
||||
"-hwaccel",
|
||||
"vaapi",
|
||||
"-hwaccel_output_format",
|
||||
"vaapi",
|
||||
"-hwaccel_device",
|
||||
"va",
|
||||
]
|
||||
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||
|
||||
|
||||
def _venc_opts(
|
||||
transcode: bool,
|
||||
hw: bool,
|
||||
tonemap: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
@@ -237,6 +274,23 @@ def _venc_opts(
|
||||
if not transcode:
|
||||
return ["-c:v", "copy"]
|
||||
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
|
||||
if hw and tonemap:
|
||||
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
|
||||
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
|
||||
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
|
||||
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
|
||||
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
|
||||
return [
|
||||
"-filter_hw_device",
|
||||
"ocl",
|
||||
"-vf",
|
||||
"hwmap=derive_device=opencl,"
|
||||
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
|
||||
"hwmap=derive_device=vaapi:reverse=1",
|
||||
"-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.
|
||||
@@ -274,23 +328,44 @@ def _render_node_present() -> bool:
|
||||
return Path(_VAAPI_DEVICE).exists()
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
|
||||
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
|
||||
(trusting the admin, skipping the source gates — the runtime HW→SW fallback still backstops a
|
||||
session that produces nothing); "auto" (default) → VAAPI only when the render node is present AND
|
||||
the iGPU can hardware-decode the source AND the source is 8-bit SDR (a 10-bit/HDR source breaks
|
||||
scale_vaapi=format=nv12 on Gen 9.5 → software libx264, which downconverts cleanly)."""
|
||||
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 10-bit/HDR source, when the
|
||||
OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
|
||||
|
||||
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
|
||||
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
|
||||
backstops a session that produces nothing — but still selecting the tonemap graph for a 10-bit/HDR
|
||||
source 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 for a 10-bit/HDR source that
|
||||
has an OpenCL runtime (else that source drops to software)."""
|
||||
if not transcode:
|
||||
return False
|
||||
return (False, False)
|
||||
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||
if mode == "off" or not _render_node_present():
|
||||
return False
|
||||
return (False, False)
|
||||
if mode == "on":
|
||||
return True
|
||||
# A 10-bit/HDR source still needs the tonemap graph (scale_vaapi can't handle it); use it when
|
||||
# OpenCL is present, else plain HW and let the runtime backstop catch a miss. The probe is only
|
||||
# consulted when OpenCL exists (short-circuit), so "on" stays probe-free where it can't matter.
|
||||
tonemap = _opencl_present() and not _probe_video_hwsafe(src)
|
||||
return (True, tonemap)
|
||||
# auto
|
||||
if (item.codec_video or "").lower() not in _HW_DECODABLE:
|
||||
return False
|
||||
return _probe_video_hwsafe(src)
|
||||
return (False, False)
|
||||
if _probe_video_hwsafe(src):
|
||||
return (True, False) # 8-bit SDR → full-GPU scale path
|
||||
# 10-bit / HDR, HW-decodable: HW OpenCL tonemap when the runtime is present, else software.
|
||||
return (True, True) if _opencl_present() else (False, False)
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||
"""The HW gate on its own (the `hw` half of `_select_encode`). Kept as a thin wrapper for the
|
||||
callers/tests that only care whether a session runs on the GPU at all."""
|
||||
return _select_encode(db, item, transcode, src)[0]
|
||||
|
||||
|
||||
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
|
||||
@@ -345,15 +420,17 @@ def _ffmpeg_cmd(
|
||||
audio_langs: list[str] | None = None,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> 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.
|
||||
venc = _venc_opts(transcode, hw, preset, crf, max_height)
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
|
||||
# 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)]
|
||||
# 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
|
||||
@@ -491,7 +568,7 @@ def start_session(
|
||||
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
||||
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
||||
|
||||
def _spawn(hw: bool) -> HlsSession:
|
||||
def _spawn(hw: bool, tonemap: bool = False) -> HlsSession:
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
|
||||
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
@@ -519,6 +596,7 @@ def start_session(
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
@@ -535,6 +613,7 @@ def start_session(
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
)
|
||||
_sessions[key] = s
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
@@ -542,20 +621,21 @@ def start_session(
|
||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||
return s
|
||||
|
||||
s = _spawn(_want_hw(db, item, transcode, src))
|
||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
|
||||
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if not ready and s.hw:
|
||||
# Runtime HW→SW fallback: the VAAPI session produced no segments (a VAProfile/init failure the
|
||||
# _want_hw gates didn't catch — e.g. an admin-forced "on" on a 10-bit source, or an unforeseen
|
||||
# driver refusal). Respawn on libx264 so a failed GPU session never strands playback; the
|
||||
# software path CPU-decodes and downconverts every source. s.hw is now False, so the route
|
||||
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
||||
# Runtime HW→SW fallback: the VAAPI session produced no segments (a VAProfile/init/tonemap
|
||||
# failure the _select_encode gates didn't catch — e.g. an admin-forced "on" on a source the GPU
|
||||
# can't decode, or an unforeseen driver refusal). Respawn on libx264 (no HW, no tonemap) so a
|
||||
# failed GPU session never strands playback; the software path CPU-decodes and downconverts every
|
||||
# source. s.hw is now False, so the route honestly reports "CPU" and the player's Preparing-stream
|
||||
# state stays correct.
|
||||
log.warning(
|
||||
"plex transcode: HW session produced no segments — falling back to software key=%s", key
|
||||
)
|
||||
s = _spawn(False)
|
||||
s = _spawn(False, False)
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if ready:
|
||||
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
||||
@@ -572,7 +652,7 @@ 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",
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
|
||||
key,
|
||||
start_s,
|
||||
s.media_start_s,
|
||||
@@ -580,6 +660,7 @@ def start_session(
|
||||
audio_ord,
|
||||
transcode,
|
||||
s.hw,
|
||||
s.tonemap,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
@@ -554,14 +554,16 @@ def stream_session(
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
|
||||
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
|
||||
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||
# segment streams are otherwise identical.
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
"tonemap": s.tonemap,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
|
||||
@@ -267,10 +267,36 @@ class TestTranscodeCommand:
|
||||
)
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
|
||||
|
||||
def test_hdr_tonemap_uses_the_opencl_graph(self):
|
||||
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
|
||||
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
|
||||
)
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
|
||||
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
|
||||
assert "opencl=ocl@va" in cmd
|
||||
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
|
||||
assert cmd.index("-init_hw_device") < cmd.index("-i")
|
||||
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
|
||||
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||
vf = cmd[cmd.index("-vf") + 1]
|
||||
assert "hwmap=derive_device=opencl" in vf
|
||||
assert "tonemap_opencl=" in vf and "format=nv12" in vf
|
||||
assert "hwmap=derive_device=vaapi:reverse=1" in vf
|
||||
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
|
||||
class TestWantHw:
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable
|
||||
AND 8-bit SDR, with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top. A HW session on a 10-bit/HDR source
|
||||
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
|
||||
software — see the `_select_encode` cases below."""
|
||||
|
||||
SRC = Path("/media/movie.mkv")
|
||||
|
||||
@@ -283,6 +309,11 @@ class TestWantHw:
|
||||
# Default the bit-depth probe so the auto-decodable tests don't shell out to ffprobe.
|
||||
monkeypatch.setattr(stream, "_probe_video_hwsafe", lambda src: safe)
|
||||
|
||||
@staticmethod
|
||||
def _opencl(monkeypatch, present):
|
||||
# Pin the OpenCL-runtime probe so the 10-bit routing doesn't depend on the CI box's /etc/OpenCL.
|
||||
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
|
||||
|
||||
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
@@ -322,15 +353,38 @@ class TestWantHw:
|
||||
is False
|
||||
)
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_10bit_or_hdr_source(self, monkeypatch):
|
||||
# BUG 1: a decodable HEVC that is 10-bit/HDR still breaks scale_vaapi=format=nv12 → software.
|
||||
def test_auto_10bit_source_falls_back_to_software_without_opencl(self, monkeypatch):
|
||||
# A decodable HEVC that is 10-bit/HDR can't use scale_vaapi=format=nv12 (Gen 9.5), and with no
|
||||
# OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, False)
|
||||
self._opencl(monkeypatch, False)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (False, False)
|
||||
|
||||
def test_auto_10bit_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
|
||||
# With the OpenCL runtime present, the same 10-bit/HDR HEVC tone-maps on the GPU (VAAPI decode →
|
||||
# OpenCL tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, False)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, True)
|
||||
|
||||
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
|
||||
# OpenCL is available.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, True)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, False)
|
||||
|
||||
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||
self._policy(monkeypatch, "off")
|
||||
@@ -356,6 +410,17 @@ class TestWantHw:
|
||||
is False
|
||||
)
|
||||
|
||||
def test_on_still_picks_the_tonemap_graph_for_a_10bit_source(self, monkeypatch):
|
||||
# "on" trusts the GPU but must still route a 10-bit/HDR source through the tonemap graph (plain
|
||||
# scale_vaapi would hit the VPP wall) when OpenCL is present.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._hwsafe(monkeypatch, False)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, True)
|
||||
|
||||
|
||||
class TestColdStartWarmup:
|
||||
"""The cold-start buffer warm-up: before reporting a session ready, start_session waits for the
|
||||
@@ -450,7 +515,9 @@ class TestTranscodeStartSession:
|
||||
def test_software_transcode_threads_the_knobs_into_the_command(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: False)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
@@ -465,11 +532,13 @@ class TestTranscodeStartSession:
|
||||
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
|
||||
|
||||
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: True)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
|
||||
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
@@ -479,13 +548,31 @@ class TestTranscodeStartSession:
|
||||
) # no SW cap
|
||||
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
|
||||
|
||||
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
|
||||
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
|
||||
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
|
||||
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
|
||||
# the item still plays and the route/player honestly show CPU.
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: True)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
seen = {"n": 0}
|
||||
|
||||
def flaky_wait(path, timeout=20.0, proc=None):
|
||||
@@ -510,7 +597,9 @@ class TestTranscodeStartSession:
|
||||
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
|
||||
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
|
||||
# the preexec nice are gated behind `transcode=True`).
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: False)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
|
||||
@@ -101,6 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
|
||||
const hwRef = useRef<boolean | null>(null);
|
||||
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
|
||||
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||
const tonemapRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
@@ -234,6 +237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
|
||||
const isTranscode = sess.mode === "hls" && !!sess.transcode;
|
||||
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
|
||||
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
|
||||
setSessionIsTranscode(isTranscode);
|
||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
||||
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
|
||||
@@ -1206,6 +1210,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
hlsRef={hlsRef}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
tonemapRef={tonemapRef}
|
||||
sessionStartRef={sessionStartRef}
|
||||
absRef={absRef}
|
||||
durationRef={durationRef}
|
||||
|
||||
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
|
||||
hlsRef,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||
sessionStartRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
|
||||
buildStatRows({
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
tonemap: tonemapRef.current,
|
||||
transport: hls ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
|
||||
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -392,6 +392,7 @@ export interface PlexPlaySession {
|
||||
start_s: number;
|
||||
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
|
||||
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
|
||||
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
|
||||
}
|
||||
|
||||
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
|
||||
|
||||
@@ -118,6 +118,11 @@ describe("buildStatRows", () => {
|
||||
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
|
||||
});
|
||||
|
||||
it("tags the mode row with HDR→SDR for a HW tonemap session", () => {
|
||||
const rows = buildStatRows({ ...base, playable: "transcode", hwEncoder: true, tonemap: true });
|
||||
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode · GPU · HDR→SDR");
|
||||
});
|
||||
|
||||
it("shows an empty classification honestly as a dash", () => {
|
||||
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
|
||||
DASH,
|
||||
@@ -133,5 +138,9 @@ describe("fmtMode", () => {
|
||||
expect(fmtMode("transcode")).toBe("transcode");
|
||||
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
|
||||
expect(fmtMode("")).toBe(DASH);
|
||||
// The HDR→SDR tag rides on the GPU encoder token for a tonemap session.
|
||||
expect(fmtMode("transcode", true, true)).toBe("transcode · GPU · HDR→SDR");
|
||||
expect(fmtMode("transcode", true, false)).toBe("transcode · GPU");
|
||||
expect(fmtMode("transcode", null, true)).toBe("transcode"); // no encoder → no tag either
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface PlexStatsInput {
|
||||
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
|
||||
// vs "transcode · CPU", the visible confirmation of which S1 path engaged.
|
||||
hwEncoder?: boolean | null;
|
||||
// For a HW transcode of a 10-bit/HDR source: true = the GPU OpenCL tonemap path (HDR→SDR) engaged, so
|
||||
// the mode row can read "transcode · GPU · HDR→SDR". Absent/false for an 8-bit HW or software session.
|
||||
tonemap?: boolean | null;
|
||||
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
|
||||
videoHeight: number;
|
||||
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
|
||||
@@ -94,20 +97,30 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
|
||||
}
|
||||
|
||||
// Active mode, with the transcode encoder appended when known: "transcode · GPU" (VAAPI hardware) or
|
||||
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged. Non-
|
||||
// transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens (not i18n'd),
|
||||
// consistent with the other technical values in this overlay (e.g. transport "hls").
|
||||
export function fmtMode(playable: string, hwEncoder?: boolean | null): string {
|
||||
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged — and a
|
||||
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
|
||||
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
|
||||
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
|
||||
export function fmtMode(
|
||||
playable: string,
|
||||
hwEncoder?: boolean | null,
|
||||
tonemap?: boolean | null,
|
||||
): string {
|
||||
const mode = playable || DASH;
|
||||
if (playable !== "transcode" || hwEncoder == null) return mode;
|
||||
return `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
|
||||
const base = `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
|
||||
return tonemap ? `${base} · HDR→SDR` : base;
|
||||
}
|
||||
|
||||
// Build the ordered, i18n-keyed rows the overlay renders. Rows whose data is absent still show a dash
|
||||
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
|
||||
export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
||||
const rows: StatRow[] = [
|
||||
{ key: "mode", labelKey: "plex.player.stats.mode", value: fmtMode(s.playable, s.hwEncoder) },
|
||||
{
|
||||
key: "mode",
|
||||
labelKey: "plex.player.stats.mode",
|
||||
value: fmtMode(s.playable, s.hwEncoder, s.tonemap),
|
||||
},
|
||||
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
|
||||
{
|
||||
key: "res",
|
||||
|
||||
Reference in New Issue
Block a user