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:
2026-08-30 06:02:45 +02:00
parent 10d69cb316
commit 4827aebb7f
10 changed files with 272 additions and 56 deletions
+13
View File
@@ -60,6 +60,19 @@ RUN apt-get update \
&& rm /tmp/deno.zip \ && rm /tmp/deno.zip \
&& rm -rf /var/lib/apt/lists/* && 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 # 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 # 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). # up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
+114 -33
View File
@@ -96,12 +96,14 @@ class HlsSession:
entry: str, entry: str,
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
tonemap: bool = False,
): ):
self.key = key self.key = key
self.dir = directory self.dir = directory
self.proc = proc self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied 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.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 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 # 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). # 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 # 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. # unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
_VAAPI_DEVICE = "/dev/dri/renderD128" _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 # 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 # 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. # 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"} _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 # Color-transfer values that mark an HDR source (PQ / HLG). A HW-decodable HDR/10-bit source can't take
# take the software path — see _probe_video_hwsafe. # 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"} _HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
def _probe_video_hwsafe(src: Path) -> bool: 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) """Whether the source is safe for the PLAIN VAAPI transcode pipeline — i.e. 8-bit SDR. A 10-bit
or HDR source breaks `scale_vaapi=format=nv12` on Gen 9.5 VPP (`VAProfile not supported` → ffmpeg (p010) or HDR source breaks `scale_vaapi=format=nv12` on Gen 9.5 VPP (`VAProfile not supported` →
emits zero segments and the item never plays), so it must take the software libx264 path, which ffmpeg emits zero segments and the item never plays), so it takes the OpenCL tonemap graph instead
CPU-decodes and downconverts to 8-bit yuv420p cleanly. Ground truth from ffprobe (bit depth via (when the OpenCL runtime is present) or software libx264 (which CPU-decodes and downconverts to 8-bit
pix_fmt / bits_per_raw_sample, HDR via color_transfer); the Plex mirror doesn't store these. 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 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 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 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. """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 `-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: if not hw:
return [] 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] return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
def _venc_opts( def _venc_opts(
transcode: bool, transcode: bool,
hw: bool, hw: bool,
tonemap: bool = False,
preset: str = "veryfast", preset: str = "veryfast",
crf: int = 20, crf: int = 20,
max_height: int = 0, max_height: int = 0,
@@ -237,6 +274,23 @@ def _venc_opts(
if not transcode: if not transcode:
return ["-c:v", "copy"] return ["-c:v", "copy"]
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"] 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: if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so # 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. # 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() return Path(_VAAPI_DEVICE).exists()
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool: def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin """Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists * `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
(trusting the admin, skipping the source gates — the runtime HW→SW fallback still backstops a 10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
session that produces nothing); "auto" (default) → VAAPI only when the render node is present AND * `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
the iGPU can hardware-decode the source AND the source is 8-bit SDR (a 10-bit/HDR source breaks * `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a 10-bit/HDR source, when the
scale_vaapi=format=nv12 on Gen 9.5 → software libx264, which downconverts cleanly).""" 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: if not transcode:
return False return (False, False)
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower() mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
if mode == "off" or not _render_node_present(): if mode == "off" or not _render_node_present():
return False return (False, False)
if mode == "on": 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: if (item.codec_video or "").lower() not in _HW_DECODABLE:
return False return (False, False)
return _probe_video_hwsafe(src) 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 # 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, audio_langs: list[str] | None = None,
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
tonemap: bool = False,
preset: str = "veryfast", preset: str = "veryfast",
crf: int = 20, crf: int = 20,
max_height: int = 0, max_height: int = 0,
) -> list[str]: ) -> list[str]:
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the # 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 # hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux. # identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
venc = _venc_opts(transcode, hw, preset, crf, max_height) # encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)] 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 # 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 # 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 # (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 # 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") 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 # 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). # remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0 preset, crf, max_height = "veryfast", 20, 0
@@ -519,6 +596,7 @@ def start_session(
audio_langs if is_multi else None, audio_langs if is_multi else None,
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap,
preset=preset, preset=preset,
crf=crf, crf=crf,
max_height=max_height, max_height=max_height,
@@ -535,6 +613,7 @@ def start_session(
"master.m3u8" if is_multi else "index.m3u8", "master.m3u8" if is_multi else "index.m3u8",
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap,
) )
_sessions[key] = s _sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # 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"))) _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
return s 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 # 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). # 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) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if not ready and s.hw: if not ready and s.hw:
# Runtime HW→SW fallback: the VAAPI session produced no segments (a VAProfile/init failure the # Runtime HW→SW fallback: the VAAPI session produced no segments (a VAProfile/init/tonemap
# _want_hw gates didn't catch — e.g. an admin-forced "on" on a 10-bit source, or an unforeseen # failure the _select_encode gates didn't catch — e.g. an admin-forced "on" on a source the GPU
# driver refusal). Respawn on libx264 so a failed GPU session never strands playback; the # can't decode, or an unforeseen driver refusal). Respawn on libx264 (no HW, no tonemap) so a
# software path CPU-decodes and downconverts every source. s.hw is now False, so the route # failed GPU session never strands playback; the software path CPU-decodes and downconverts every
# honestly reports "CPU" and the player's Preparing-stream state stays correct. # source. s.hw is now False, so the route honestly reports "CPU" and the player's Preparing-stream
# state stays correct.
log.warning( log.warning(
"plex transcode: HW session produced no segments — falling back to software key=%s", key "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) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready: if ready:
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS, # 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): if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
break break
log.info( 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, key,
start_s, start_s,
s.media_start_s, s.media_start_s,
@@ -580,6 +660,7 @@ def start_session(
audio_ord, audio_ord,
transcode, transcode,
s.hw, s.hw,
s.tonemap,
) )
return s return s
+4 -2
View File
@@ -554,14 +554,16 @@ def stream_session(
raise HTTPException(status_code=404, detail="Media file not found on disk") 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 # 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. # 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 # `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
# active mode + encoder in the stats overlay; both segment streams are otherwise identical. # surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
# segment streams are otherwise identical.
return { return {
"mode": "hls", "mode": "hls",
"start_s": s.media_start_s, "start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}", "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
"transcode": s.transcode, "transcode": s.transcode,
"hw": s.hw, "hw": s.hw,
"tonemap": s.tonemap,
} }
+1 -1
View File
@@ -58,7 +58,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
def fake_start(db_, it, start, audio, aoff, multi, transcode=False): def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode captured["transcode"] = transcode
return SimpleNamespace( 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) monkeypatch.setattr(plex_stream, "start_session", fake_start)
+102 -13
View File
@@ -267,10 +267,36 @@ class TestTranscodeCommand:
) )
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale 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: class TestWantHw:
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable """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.""" 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") 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. # 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) 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): def test_no_hw_when_not_transcoding(self, monkeypatch):
self._policy(monkeypatch, "auto") self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True) monkeypatch.setattr(stream, "_render_node_present", lambda: True)
@@ -322,15 +353,38 @@ class TestWantHw:
is False is False
) )
def test_auto_falls_back_to_software_for_a_10bit_or_hdr_source(self, monkeypatch): def test_auto_10bit_source_falls_back_to_software_without_opencl(self, monkeypatch):
# BUG 1: a decodable HEVC that is 10-bit/HDR still breaks scale_vaapi=format=nv12 → software. # 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._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, False) self._hwsafe(monkeypatch, False)
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True) monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert ( assert stream._select_encode(
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC) None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
is False ) == (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): def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off") self._policy(monkeypatch, "off")
@@ -356,6 +410,17 @@ class TestWantHw:
is False 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: class TestColdStartWarmup:
"""The cold-start buffer warm-up: before reporting a session ready, start_session waits for the """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( def test_software_transcode_threads_the_knobs_into_the_command(
self, capturing_env, monkeypatch 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( s = stream.start_session(
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True 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 assert kwargs.get("preexec_fn") is stream._sw_child_preexec
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch): 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( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True 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 assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi" assert _venc(cmd) == "h264_vaapi"
@@ -479,13 +548,31 @@ class TestTranscodeStartSession:
) # no SW cap ) # no SW cap
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced 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( def test_a_hw_session_that_produces_nothing_falls_back_to_software(
self, capturing_env, monkeypatch self, capturing_env, monkeypatch
): ):
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile # 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 # 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. # 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} seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None): 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): 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 # 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`). # 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 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 assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
+5
View File
@@ -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, // 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"). // null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null); 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); const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio // ≥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. // 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. // re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode; const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null; hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode); setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG); // 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 // 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} hlsRef={hlsRef}
playable={detail?.playable ?? ""} playable={detail?.playable ?? ""}
hwRef={hwRef} hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef} sessionStartRef={sessionStartRef}
absRef={absRef} absRef={absRef}
durationRef={durationRef} durationRef={durationRef}
+4 -1
View File
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
hlsRef, hlsRef,
playable, playable,
hwRef, hwRef,
tonemapRef,
sessionStartRef, sessionStartRef,
absRef, absRef,
durationRef, durationRef,
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
hlsRef: RefObject<Hls | null>; hlsRef: RefObject<Hls | null>;
playable: string; playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode 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>; sessionStartRef: RefObject<number>;
absRef: RefObject<number>; absRef: RefObject<number>;
durationRef: RefObject<number>; durationRef: RefObject<number>;
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
buildStatRows({ buildStatRows({
playable, playable,
hwEncoder: hwRef.current, hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct", transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0, videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0, videoHeight: v?.videoHeight ?? 0,
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
sample(); sample();
const iv = setInterval(sample, 1000); const iv = setInterval(sample, 1000);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]); }, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
return ( return (
<div <div
+1
View File
@@ -392,6 +392,7 @@ export interface PlexPlaySession {
start_s: number; start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux) 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) 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 // Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
+9
View File
@@ -118,6 +118,11 @@ describe("buildStatRows", () => {
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU"); 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", () => { it("shows an empty classification honestly as a dash", () => {
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe( expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
DASH, DASH,
@@ -133,5 +138,9 @@ describe("fmtMode", () => {
expect(fmtMode("transcode")).toBe("transcode"); expect(fmtMode("transcode")).toBe("transcode");
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
expect(fmtMode("")).toBe(DASH); 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
}); });
}); });
+19 -6
View File
@@ -12,6 +12,9 @@ export interface PlexStatsInput {
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU" // 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. // vs "transcode · CPU", the visible confirmation of which S1 path engaged.
hwEncoder?: boolean | null; 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) videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
videoHeight: number; videoHeight: number;
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames 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 // 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 · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged — and a
// transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens (not i18n'd), // "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
// consistent with the other technical values in this overlay (e.g. transport "hls"). // HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
export function fmtMode(playable: string, hwEncoder?: boolean | null): string { // (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; const mode = playable || DASH;
if (playable !== "transcode" || hwEncoder == null) return mode; 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 // 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. // (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
export function buildStatRows(s: PlexStatsInput): StatRow[] { export function buildStatRows(s: PlexStatsInput): StatRow[] {
const rows: 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: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
{ {
key: "res", key: "res",