From 4827aebb7f25596e2fce341b3cc8f3aec3bde491 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 06:02:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20HW=20HDR=E2=86=92SDR=20tonemap=20?= =?UTF-8?q?for=2010-bit/HDR=20transcode=20(OpenCL=20on=20the=20iGPU)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile | 13 ++ backend/app/plex/stream.py | 147 ++++++++++++++----- backend/app/routes/plex/playback.py | 6 +- backend/tests/test_plex_playback_session.py | 2 +- backend/tests/test_plex_stream.py | 115 +++++++++++++-- frontend/src/components/PlexPlayer.tsx | 5 + frontend/src/components/PlexStatsOverlay.tsx | 5 +- frontend/src/lib/api/plex.ts | 1 + frontend/src/lib/plexStats.test.ts | 9 ++ frontend/src/lib/plexStats.ts | 25 +++- 10 files changed, 272 insertions(+), 56 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5a43df66..67eb44ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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). diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 7ecebc2f..d11667c1 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -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 diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 04d37f36..d77eb462 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -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, } diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index 4be08777..51a952ac 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -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) diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 7db5acb3..8eb83787 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -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] diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index f781926d..4fc7663b 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(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(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} diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 7f1e9c8f..5fdd1029 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -13,6 +13,7 @@ export function PlexStatsOverlay({ hlsRef, playable, hwRef, + tonemapRef, sessionStartRef, absRef, durationRef, @@ -22,6 +23,7 @@ export function PlexStatsOverlay({ hlsRef: RefObject; playable: string; hwRef: RefObject; // active transcode encoder: true=GPU, false=CPU, null=not transcode + tonemapRef: RefObject; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag) sessionStartRef: RefObject; absRef: RefObject; durationRef: RefObject; @@ -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 (
{ 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 }); }); diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts index fc41393c..53f5f1ea 100644 --- a/frontend/src/lib/plexStats.ts +++ b/frontend/src/lib/plexStats.ts @@ -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",