From 10d69cb3163b3d577344fba241a7d7b621013f60 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 03:53:31 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix(plex):=20route=2010-bit/HDR=20HEVC=20to?= =?UTF-8?q?=20software=20+=20runtime=20VAAPI=E2=86=92libx264=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12 on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments and the item never played, with no fallback — a retry just re-picked the failing HW path. An 8-bit HEVC and the same file on libx264 both work. Two defenses: - `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from color_transfer) — a 10-bit/HDR source takes the software libx264 path, which downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this is probed at session start. "on" still trusts the admin and skips the gate. - `start_session` adds a runtime HW→SW fallback: a HW session that emits no first segment respawns on libx264 and reports hw=False (the route/stats overlay honestly show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead of burning the 25s budget before the fallback fires. Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in TestTranscodeStartSession. Backend lane green. --- backend/app/plex/stream.py | 208 +++++++++++++++++++++--------- backend/tests/test_plex_stream.py | 157 +++++++++++++++++++--- 2 files changed, 281 insertions(+), 84 deletions(-) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 1f75d071..7ecebc2f 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -157,6 +157,60 @@ _VAAPI_DEVICE = "/dev/dri/renderD128" # 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. +_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. + + 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 + wasted first attempt, never a stranded item.""" + try: + out = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=pix_fmt,bits_per_raw_sample,color_transfer", + "-of", + "default=noprint_wrappers=1", + str(src), + ], + capture_output=True, + text=True, + timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return True + pix_fmt = bits = transfer = "" + for line in out.stdout.splitlines(): + k, _, v = line.strip().partition("=") + v = v.strip().lower() + if k == "pix_fmt": + pix_fmt = v + elif k == "bits_per_raw_sample": + bits = v + elif k == "color_transfer": + transfer = v + if transfer in _HDR_TRANSFERS: + return False # HDR (PQ/HLG) — VPP can't tonemap it to nv12 as configured + if bits.isdigit() and int(bits) > 8: + return False + # pix_fmt bit-depth suffix (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when + # bits_per_raw_sample is absent (some containers don't set it). + if re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt): + return False + return True def _hwaccel_in_opts(hw: bool) -> list[str]: @@ -220,12 +274,13 @@ def _render_node_present() -> bool: return Path(_VAAPI_DEVICE).exists() -def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool: +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-decodability gate); "auto" (default) → VAAPI only when the - render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't - feed itself → software libx264).""" + (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).""" if not transcode: return False mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower() @@ -233,7 +288,9 @@ def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool: return False if mode == "on": return True - return (item.codec_video or "").lower() in _HW_DECODABLE + if (item.codec_video or "").lower() not in _HW_DECODABLE: + return False + return _probe_video_hwsafe(src) # CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API @@ -425,67 +482,82 @@ def start_session( return None key = item.rating_key start_s = max(0.0, float(start_s)) - hw = _want_hw(db, item, transcode) - # 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 - if transcode and not hw: - preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast" - crf = sysconfig.get_int(db, "plex_transcode_crf") - max_height = sysconfig.get_int(db, "plex_transcode_max_height") audio_langs = _probe_audio_langs(src) if multi else [] is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks - # Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't - # starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform - # without fork (the API always runs in a Linux container in prod) it's simply omitted. - preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None - with _lock: - old = _sessions.pop(key, None) - if old is not None: - _kill(old) - tag = "multi" if is_multi else str(audio_ord) - directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" - shutil.rmtree(directory, ignore_errors=True) - directory.mkdir(parents=True, exist_ok=True) - proc = subprocess.Popen( - _ffmpeg_cmd( - src, - start_s, - directory, - audio_ord, - aoff, - audio_langs if is_multi else None, - transcode=transcode, - hw=hw, - preset=preset, - crf=crf, - max_height=max_height, - ), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - preexec_fn=preexec, - ) - s = HlsSession( - key, - directory, - proc, - start_s, - "master.m3u8" if is_multi else "index.m3u8", - transcode=transcode, - hw=hw, - ) - _sessions[key] = s - # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); - # at least 1 so playback can't be capped to zero. - _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) + tag = "multi" if is_multi else str(audio_ord) + directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" - # Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on - # ffmpeg producing them, and we must not hold up other sessions). def _seg(i: int) -> Path: # 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") - if wait_for(_seg(0), timeout=25.0): + def _spawn(hw: bool) -> 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 + if transcode and not hw: + preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast" + crf = sysconfig.get_int(db, "plex_transcode_crf") + max_height = sysconfig.get_int(db, "plex_transcode_max_height") + # Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode + # can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a + # platform without fork (the API always runs in a Linux container in prod) it's simply omitted. + preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None + with _lock: + old = _sessions.pop(key, None) + if old is not None: + _kill(old) + shutil.rmtree(directory, ignore_errors=True) + directory.mkdir(parents=True, exist_ok=True) + proc = subprocess.Popen( + _ffmpeg_cmd( + src, + start_s, + directory, + audio_ord, + aoff, + audio_langs if is_multi else None, + transcode=transcode, + hw=hw, + preset=preset, + crf=crf, + max_height=max_height, + ), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=preexec, + ) + s = HlsSession( + key, + directory, + proc, + start_s, + "master.m3u8" if is_multi else "index.m3u8", + transcode=transcode, + hw=hw, + ) + _sessions[key] = s + # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); + # at least 1 so playback can't be capped to zero. + _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) + return s + + s = _spawn(_want_hw(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. + log.warning( + "plex transcode: HW session produced no segments — falling back to software key=%s", key + ) + s = _spawn(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, # so the client's absolute clock + subtitle shift key off the true keyframe, not the requested # offset. On probe failure we keep the provisional requested offset (old behaviour, small drift). @@ -497,7 +569,7 @@ def start_session( # at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once # warm, so a miss means something slow and we proceed rather than block the request further. for i in range(1, _WARMUP_SEGMENTS): - if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S): + 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", @@ -507,7 +579,7 @@ def start_session( is_multi, audio_ord, transcode, - hw, + s.hw, ) return s @@ -520,10 +592,14 @@ def current_session(key: str) -> HlsSession | None: return s -def wait_for(path: Path, timeout: float = 20.0) -> bool: +def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool: """Wait until a session file (playlist / segment) exists and is non-empty. Segments are produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment - far beyond the generated region won't (the frontend restarts the session at a seek instead).""" + far beyond the generated region won't (the frontend restarts the session at a seek instead). + + With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init + (10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the + whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall.""" end = time.time() + timeout while time.time() < end: try: @@ -531,6 +607,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool: return True except OSError: pass + if proc is not None and proc.poll() is not None: + # the producer exited — no more segments are coming; one last check then give up. + try: + return path.exists() and path.stat().st_size > 0 + except OSError: + return False time.sleep(0.15) return path.exists() diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 00c55ece..7db5acb3 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -269,48 +269,92 @@ class TestTranscodeCommand: class TestWantHw: - """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.""" + """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.""" + + SRC = Path("/media/movie.mkv") @staticmethod def _policy(monkeypatch, mode): monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode) + @staticmethod + def _hwsafe(monkeypatch, safe): + # 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) + def test_no_hw_when_not_transcoding(self, monkeypatch): self._policy(monkeypatch, "auto") monkeypatch.setattr(stream, "_render_node_present", lambda: True) - assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False + assert ( + stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False, src=self.SRC) + is False + ) def test_no_hw_without_a_render_node(self, monkeypatch): self._policy(monkeypatch, "auto") monkeypatch.setattr(stream, "_render_node_present", lambda: False) - assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False + assert ( + stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC) + is False + ) - def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch): + def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch): self._policy(monkeypatch, "auto") + self._hwsafe(monkeypatch, True) monkeypatch.setattr(stream, "_render_node_present", lambda: True) - assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True + assert ( + stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC) + is True + ) def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch): # AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder. self._policy(monkeypatch, "auto") + self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe monkeypatch.setattr(stream, "_render_node_present", lambda: True) - assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False - assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False + assert ( + stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC) + is False + ) + assert ( + stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC) + 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. + self._policy(monkeypatch, "auto") + self._hwsafe(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 + ) def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch): self._policy(monkeypatch, "off") monkeypatch.setattr(stream, "_render_node_present", lambda: True) - assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False + assert ( + stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC) + is False + ) - def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch): - # The admin override trusts the GPU to decode it; skips the decodability gate (render node still - # required — HW is impossible without it). + def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch): + # The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime + # HW→SW fallback still backstops it). Render node still required — HW is impossible without it. self._policy(monkeypatch, "on") + self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it monkeypatch.setattr(stream, "_render_node_present", lambda: True) - assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True + assert ( + stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC) + is True + ) monkeypatch.setattr(stream, "_render_node_present", lambda: False) - assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False + assert ( + stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC) + is False + ) class TestColdStartWarmup: @@ -333,7 +377,7 @@ class TestColdStartWarmup: def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch): waited = [] - def spy(path, timeout=20.0): + def spy(path, timeout=20.0, proc=None): waited.append(path.name) return True @@ -346,7 +390,7 @@ class TestColdStartWarmup: def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch): waited = [] - def spy(path, timeout=20.0): + def spy(path, timeout=20.0, proc=None): waited.append(path.name) return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there @@ -360,7 +404,7 @@ class TestColdStartWarmup: # still returned so the client's own segment requests can drive it, as before. waited = [] - def spy(path, timeout=20.0): + def spy(path, timeout=20.0, proc=None): waited.append(path.name) return False @@ -398,7 +442,7 @@ class TestTranscodeStartSession: "get_str", lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"), ) - monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True) + monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True) _CapturingProc.calls = [] monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc) return tmp_path @@ -406,7 +450,7 @@ 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: False) + monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: False) s = stream.start_session( db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True ) @@ -421,11 +465,12 @@ 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: True) + monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: 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 + assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn (cmd, *_), kwargs = _CapturingProc.calls[0] assert _venc(cmd) == "h264_vaapi" assert "libx264" not in cmd @@ -434,12 +479,82 @@ class TestTranscodeStartSession: ) # no SW cap assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced + 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) + seen = {"n": 0} + + def flaky_wait(path, timeout=20.0, proc=None): + seen["n"] += 1 + return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW) + + monkeypatch.setattr(stream, "wait_for", flaky_wait) + 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 False + ) # honest CPU after the fallback + assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn + (hw_cmd, *_), _ = _CapturingProc.calls[0] + (sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1] + assert _venc(hw_cmd) == "h264_vaapi" + assert _venc(sw_cmd) == "libx264" + if os.name == "posix": + assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced + 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: False) + monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: 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] assert _venc(cmd) == "copy" assert kwargs.get("preexec_fn") is None + + +class TestProbeVideoHwsafe: + """The ffprobe-based 8-bit-SDR gate that keeps 10-bit/HDR sources off the VAAPI pipeline (BUG 1): + scale_vaapi=format=nv12 can't convert them on Gen 9.5, so they must take software libx264.""" + + @staticmethod + def _probe(monkeypatch, stdout): + class _R: + def __init__(self): + self.stdout = stdout + + monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R()) + + def test_8bit_sdr_is_hw_safe(self, monkeypatch): + self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n") + assert stream._probe_video_hwsafe(Path("/x")) is True + + def test_10bit_pix_fmt_is_not_hw_safe(self, monkeypatch): + # p010/yuv420p10le breaks the VPP format pass even when bits_per_raw_sample is absent. + self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n") + assert stream._probe_video_hwsafe(Path("/x")) is False + + def test_bits_per_raw_sample_over_8_is_not_hw_safe(self, monkeypatch): + self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\n") + assert stream._probe_video_hwsafe(Path("/x")) is False + + def test_hdr_transfer_is_not_hw_safe(self, monkeypatch): + # Even an 8-bit-tagged PQ/HLG source can't be tonemapped to nv12 as configured. + self._probe( + monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=smpte2084\n" + ) + assert stream._probe_video_hwsafe(Path("/x")) is False + + def test_probe_failure_assumes_hw_safe(self, monkeypatch): + # ffprobe missing/erroring → assume safe; the runtime HW→SW fallback is the real backstop, so a + # probe miss costs at most one wasted HW attempt, never a stranded item. + def boom(*a, **k): + raise OSError("no ffprobe") + + monkeypatch.setattr(stream.subprocess, "run", boom) + assert stream._probe_video_hwsafe(Path("/x")) is True From 4827aebb7f25596e2fce341b3cc8f3aec3bde491 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 06:02:45 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(plex):=20HW=20HDR=E2=86=92SDR=20tonema?= =?UTF-8?q?p=20for=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", From a2a8f5681d953b25da963007b838fa55a7898588 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 15:02:41 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(plex):=20don't=20tonemap=2010-bit=20SDR?= =?UTF-8?q?;=20identity-guard=20the=20HW=E2=86=92SW=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review round 1 on feature/plex-hdr-hw-tonemap: - F1 (correctness): _select_encode gated tonemap on bit-depth alone, so a 10-bit *SDR* (bt709) source got the HDR hable tone curve applied, visibly altering an SDR picture where only a 10->8-bit downconvert was needed. Split the ffprobe gate into a 3-way classifier (_probe_video_kind: hwsafe / hdr / sdr10); only true-HDR (PQ/HLG) takes the OpenCL tonemap graph, 10-bit SDR drops to software libx264 (an exact downconvert). Applies to both auto and on. - F2 (race): the runtime HW->SW fallback popped+killed _sessions[key] with no identity check. Because wait_for blocks OUTSIDE _lock, a concurrent same-key _spawn (rapid seek-restart) or _enforce_cap cull could kill this session; the fallback then respawned software over the NEWER session and breached the transcode cap. Guard the respawn: only fall back if _sessions[key] is still this session, else yield the currently-registered one. - tests: 3-way classifier (incl. HLG + empty-probe), real _opencl_present glob, real wait_for proc fast-fail branch, tonemap-variant fallback, the F1 regressions (auto + on), the supersede/no-respawn race, and the on/opencl matrix cell. Backend gate green (ruff + full pytest). --- backend/app/plex/stream.py | 109 +++++++++----- backend/tests/test_plex_stream.py | 240 ++++++++++++++++++++++++++---- 2 files changed, 275 insertions(+), 74 deletions(-) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index d11667c1..4685938c 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -162,23 +162,35 @@ _OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors") # 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). 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. +# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the +# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph +# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source +# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind. _HDR_TRANSFERS = {"smpte2084", "arib-std-b67"} -def _probe_video_hwsafe(src: Path) -> bool: - """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. +# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind). +_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path +_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime) +_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve - 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 - wasted first attempt, never a stranded item.""" + +def _probe_video_kind(src: Path) -> str: + """Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`: + + * `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi). + * `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the + OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264. + * `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would + visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was + needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and + downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path + is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.) + + 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 `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback + in start_session backstops a HW session that still produces nothing, so a probe miss only costs the + rare wasted first attempt, never a stranded item.""" try: out = subprocess.run( [ @@ -198,7 +210,7 @@ def _probe_video_hwsafe(src: Path) -> bool: timeout=10, ) except (subprocess.SubprocessError, OSError): - return True + return _VK_HWSAFE pix_fmt = bits = transfer = "" for line in out.stdout.splitlines(): k, _, v = line.strip().partition("=") @@ -210,14 +222,12 @@ def _probe_video_hwsafe(src: Path) -> bool: elif k == "color_transfer": transfer = v if transfer in _HDR_TRANSFERS: - return False # HDR (PQ/HLG) — VPP can't tonemap it to nv12 as configured - if bits.isdigit() and int(bits) > 8: - return False - # pix_fmt bit-depth suffix (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when - # bits_per_raw_sample is absent (some containers don't set it). - if re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt): - return False - return True + return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert + # >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix + # (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent. + if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt): + return _VK_SDR10 + return _VK_HWSAFE def _opencl_present() -> bool: @@ -333,33 +343,42 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> * `(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). + * `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when + the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively). + + A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert, + not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software. 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).""" + backstops a session that produces nothing — but still routing a true-HDR source through the tonemap + graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is + present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG) + source that has an OpenCL runtime, while a 10-bit SDR source drops to software.""" if not transcode: return (False, False) mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower() if mode == "off" or not _render_node_present(): return (False, False) if mode == "on": - # 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) + # Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime + # backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR + # source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists + # (short-circuit), so "on" stays probe-free where tonemap can't apply anyway. + tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR return (True, tonemap) # auto if (item.codec_video or "").lower() not in _HW_DECODABLE: return (False, False) - if _probe_video_hwsafe(src): + kind = _probe_video_kind(src) + if kind == _VK_HWSAFE: 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) + if kind == _VK_HDR: + # PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264. + return (True, True) if _opencl_present() else (False, False) + # _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone + # curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software. + return (False, False) def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool: @@ -626,12 +645,20 @@ def start_session( # 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/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. + # We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its + # own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or + # _enforce_cap may have killed it to install/keep a newer session. Only fall back if THIS session + # is still the registered one — otherwise respawning would tear down the newer session and (via + # _spawn's supersede + _enforce_cap) breach the transcode cap. If superseded, yield to whoever is + # registered now. (A tiny window remains between this check and _spawn's own lock; that just makes + # the fallback lose to an even-newer seek, which is the correct seek-restart outcome anyway.) + with _lock: + superseded = _sessions.get(key) is not s + if superseded: + return current_session(key) + # Runtime HW→SW fallback: 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 ) diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 8eb83787..fb300923 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -304,14 +304,23 @@ class TestWantHw: def _policy(monkeypatch, mode): monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode) + @staticmethod + def _kind(monkeypatch, kind): + # Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of + # stream._VK_HWSAFE / _VK_HDR / _VK_SDR10. + monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind) + @staticmethod def _hwsafe(monkeypatch, safe): - # 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) + # Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True → + # _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves). + monkeypatch.setattr( + stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10 + ) @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. + # Pin the OpenCL-runtime probe so the 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): @@ -353,33 +362,45 @@ class TestWantHw: is False ) - 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). + def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch): + # A decodable HEVC that is true-HDR (PQ/HLG) 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._kind(monkeypatch, stream._VK_HDR) self._opencl(monkeypatch, False) monkeypatch.setattr(stream, "_render_node_present", lambda: True) 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. + def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch): + # With the OpenCL runtime present, a true-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._kind(monkeypatch, stream._VK_HDR) 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_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch): + # F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone + # curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes + # software libx264 (an exact 10→8-bit downconvert), never (True, True). + self._policy(monkeypatch, "auto") + self._kind(monkeypatch, stream._VK_SDR10) + 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 + ) == (False, False) + 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._kind(monkeypatch, stream._VK_HWSAFE) self._opencl(monkeypatch, True) monkeypatch.setattr(stream, "_render_node_present", lambda: True) assert stream._select_encode( @@ -399,6 +420,9 @@ class TestWantHw: # HW→SW fallback still backstops it). Render node still required — HW is impossible without it. self._policy(monkeypatch, "on") self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it + self._opencl( + monkeypatch, False + ) # pin: tonemap-half is irrelevant here, keep it deterministic monkeypatch.setattr(stream, "_render_node_present", lambda: True) assert ( stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC) @@ -410,17 +434,40 @@ 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 + def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch): + # "on" trusts the GPU but must still route a true-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._kind(monkeypatch, stream._VK_HDR) 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_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch): + # F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must + # pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture. + self._policy(monkeypatch, "on") + self._kind(monkeypatch, stream._VK_SDR10) + 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_on_without_opencl_is_plain_hw(self, monkeypatch): + # "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime + # HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half + # of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw. + self._policy(monkeypatch, "on") + self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU + self._opencl(monkeypatch, False) + monkeypatch.setattr(stream, "_render_node_present", lambda: True) + assert stream._select_encode( + None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC + ) == (True, False) + class TestColdStartWarmup: """The cold-start buffer warm-up: before reporting a session ready, start_session waits for the @@ -594,6 +641,69 @@ class TestTranscodeStartSession: if os.name == "posix": assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced + def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software( + self, capturing_env, monkeypatch + ): + # The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only + # the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that + # emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False, + # tonemap=False, so an HDR item still plays on CPU. + monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True)) + seen = {"n": 0} + + def flaky_wait(path, timeout=20.0, proc=None): + seen["n"] += 1 + return ( + seen["n"] > 1 + ) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds + + monkeypatch.setattr(stream, "wait_for", flaky_wait) + 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 False and s.tonemap is False + assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn + (hw_cmd, *_), _ = _CapturingProc.calls[0] + (sw_cmd, *_), _ = _CapturingProc.calls[1] + assert ( + "tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1] + ) # first attempt built the OpenCL graph + assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap + + def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one( + self, capturing_env, monkeypatch + ): + # F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid + # seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT + # trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap. + # The identity guard must detect it's been superseded and yield the currently-registered session. + monkeypatch.setattr( + stream, "_select_encode", lambda db, item, transcode, src: (True, False) + ) + + class _Newer: # stands in for the session a concurrent _spawn registered + hw = True + tonemap = False + transcode = True + last_access = 0.0 + + newer = _Newer() + + def wait_then_supersede(path, timeout=20.0, proc=None): + # Simulate the concurrent replacement happening during our out-of-lock wait, then report the + # (now-killed) HW proc produced nothing. + stream._sessions["12345"] = newer + return False + + monkeypatch.setattr(stream, "wait_for", wait_then_supersede) + s = stream.start_session( + db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True + ) + # No SW respawn — only the initial HW _spawn ran; the newer session was left intact. + assert len(_CapturingProc.calls) == 1 + assert s is newer # yielded the currently-registered session, did not clobber it + assert stream._sessions["12345"] is newer + 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`). @@ -607,9 +717,10 @@ class TestTranscodeStartSession: assert kwargs.get("preexec_fn") is None -class TestProbeVideoHwsafe: - """The ffprobe-based 8-bit-SDR gate that keeps 10-bit/HDR sources off the VAAPI pipeline (BUG 1): - scale_vaapi=format=nv12 can't convert them on Gen 9.5, so they must take software libx264.""" +class TestProbeVideoKind: + """The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI; + true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER + the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch).""" @staticmethod def _probe(monkeypatch, stdout): @@ -619,31 +730,94 @@ class TestProbeVideoHwsafe: monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R()) - def test_8bit_sdr_is_hw_safe(self, monkeypatch): + def test_8bit_sdr_is_hwsafe(self, monkeypatch): self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n") - assert stream._probe_video_hwsafe(Path("/x")) is True + assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE - def test_10bit_pix_fmt_is_not_hw_safe(self, monkeypatch): - # p010/yuv420p10le breaks the VPP format pass even when bits_per_raw_sample is absent. + def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch): + # yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not + # tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent. self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n") - assert stream._probe_video_hwsafe(Path("/x")) is False + assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10 - def test_bits_per_raw_sample_over_8_is_not_hw_safe(self, monkeypatch): - self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\n") - assert stream._probe_video_hwsafe(Path("/x")) is False + def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch): + self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n") + assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10 - def test_hdr_transfer_is_not_hw_safe(self, monkeypatch): - # Even an 8-bit-tagged PQ/HLG source can't be tonemapped to nv12 as configured. + def test_pq_transfer_is_hdr(self, monkeypatch): + # PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged. self._probe( - monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=smpte2084\n" + monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n" ) - assert stream._probe_video_hwsafe(Path("/x")) is False + assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR - def test_probe_failure_assumes_hw_safe(self, monkeypatch): - # ffprobe missing/erroring → assume safe; the runtime HW→SW fallback is the real backstop, so a + def test_hlg_transfer_is_hdr(self, monkeypatch): + # HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR. + self._probe( + monkeypatch, + "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n", + ) + assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR + + def test_empty_probe_output_is_hwsafe(self, monkeypatch): + # A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime + # HW→SW fallback backstops a wrong guess. + self._probe(monkeypatch, "") + assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE + + def test_probe_failure_assumes_hwsafe(self, monkeypatch): + # ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a # probe miss costs at most one wasted HW attempt, never a stranded item. def boom(*a, **k): raise OSError("no ffprobe") monkeypatch.setattr(stream.subprocess, "run", boom) - assert stream._probe_video_hwsafe(Path("/x")) is True + assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE + + +class TestOpenclPresent: + """The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives + the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the + ICD path / glob / OSError guard would be caught (this routes every HDR source on prod).""" + + def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch): + monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path) + (tmp_path / "intel.icd").write_text("libintelocl.so\n") + assert stream._opencl_present() is True + + def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path) + assert stream._opencl_present() is False + + def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch): + monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope") + assert stream._opencl_present() is False + + +class TestWaitForProcFastFail: + """wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s + producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW + fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches + it wholesale, so this branch would otherwise never run).""" + + class _DeadProc: + def poll(self): + return 1 # already exited non-zero + + def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path): + seg = tmp_path / "seg_0.ts" + seg.write_bytes(b"x") + # The proc exited but DID write a first segment → ready. (The final existence re-check.) + assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True + + def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path): + # Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by + # using a long timeout the test would visibly hang on if the fast-fail branch regressed. + seg = tmp_path / "seg_0.ts" + assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False + + def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path): + # A zero-byte segment is not a real segment (size > 0 required). + seg = tmp_path / "seg_0.ts" + seg.write_bytes(b"") + assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False From 915319fa351443e91a8c553df86a3fb52d5c0551 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 15:11:37 +0200 Subject: [PATCH 4/6] fix(plex): make the HW->SW fallback supersede-check atomic; honest cull yield Deep-review round 2 on the round-1 fix: - Race (medium): the round-1 identity guard did the supersede check and the respawn in TWO separate _lock sections. A newer same-key session installed in the gap was still torn down by _spawn's unconditional pop+kill (the round-1 comment even had the direction backwards). Fold the check into _spawn via a new replace_only= param: the identity test and the pop+replace now share ONE lock, so the respawn is a true no-op when superseded/culled. - Honest states (low): the superseded branch returned current_session(key), which is None when the key was cap-culled during our out-of-lock wait; the route turns None into a dishonest 'Media file not found on disk' 404 for a file that exists. Yield 'current_session(key) or s' so a culled session degrades to the route's retryable 'not available yet' path instead. - Hygiene: _want_hw docstring corrected (it is a test-only shim now; production decides via _select_encode) and the two stale _want_hw references refreshed (stream.py header, config.py plex_transcode_hw). - tests: the cap-cull no-respawn/no-None sub-case; the _opencl_present except OSError branch (glob raising, not just empty); tightened a tautological -vf assertion. Backend gate green (ruff + full pytest). --- backend/app/config.py | 2 +- backend/app/plex/stream.py | 43 +++++++++++++++---------- backend/tests/test_plex_stream.py | 53 +++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index bd916a90..62acd26e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -265,7 +265,7 @@ class Settings(BaseSettings): # --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- # Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render - # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`. + # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`. plex_transcode_hw: str = "auto" # Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf` # trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index 4685938c..f404fac0 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -145,7 +145,7 @@ _HLS_TAIL = [ # --- Transcode (playable="transcode") video re-encode ----------------------------------------- # The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be -# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`: +# re-encoded to H.264. The encoder + tonemap are chosen per-session by `_select_encode`: # * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline # (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent # 1080p transcodes are fine and the API stays responsive. @@ -382,8 +382,8 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> 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.""" + """The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that + assert only whether a session runs on the GPU (production decides via `_select_encode` directly).""" return _select_encode(db, item, transcode, src)[0] @@ -587,7 +587,15 @@ 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, tonemap: bool = False) -> HlsSession: + def _spawn( + hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None + ) -> "HlsSession | None": + # `replace_only` makes the supersede identity-aware for the runtime fallback: the whole + # check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent + # seek-restart) installed between the fallback's decision and here is never clobbered. When the + # registered session is no longer `replace_only`, we abort and return None (the caller yields to + # whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes — + # that is the seek-restart's intended last-writer-wins. # 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 @@ -600,6 +608,8 @@ def start_session( # platform without fork (the API always runs in a Linux container in prod) it's simply omitted. preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None with _lock: + if replace_only is not None and _sessions.get(key) is not replace_only: + return None # superseded/culled while we waited out of lock — don't clobber the newer one old = _sessions.pop(key, None) if old is not None: _kill(old) @@ -647,22 +657,21 @@ def start_session( if not ready and s.hw: # We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its # own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or - # _enforce_cap may have killed it to install/keep a newer session. Only fall back if THIS session - # is still the registered one — otherwise respawning would tear down the newer session and (via - # _spawn's supersede + _enforce_cap) breach the transcode cap. If superseded, yield to whoever is - # registered now. (A tiny window remains between this check and _spawn's own lock; that just makes - # the fallback lose to an even-newer seek, which is the correct seek-restart outcome anyway.) - with _lock: - superseded = _sessions.get(key) is not s - if superseded: - return current_session(key) - # Runtime HW→SW fallback: 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. + # _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no + # tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so + # the respawn is a no-op if we've been superseded: the identity check and the pop+replace share + # ONE lock in _spawn, so we can never tear down the newer session. 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, False) + respawned = _spawn(False, False, replace_only=s) + if respawned is None: + # Superseded/culled mid-fallback — yield the session registered now (a newer seek-restart), or + # our own now-dead session if the key was cap-culled (its segment requests degrade to the + # route's retryable "not available yet", never a dishonest "file not found" from a None). + return current_session(key) or s + s = respawned 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, diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index fb300923..39be017f 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -590,9 +590,9 @@ class TestTranscodeStartSession: (cmd, *_), kwargs = _CapturingProc.calls[0] assert _venc(cmd) == "h264_vaapi" assert "libx264" not in cmd - assert ( - "-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" - ) # no SW cap + # Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd" + # would silently pass if the filter were dropped — assert it is actually present). + assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # 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( @@ -704,6 +704,41 @@ class TestTranscodeStartSession: assert s is newer # yielded the currently-registered session, did not clobber it assert stream._sessions["12345"] is newer + def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing( + self, capturing_env, monkeypatch + ): + # F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets + # _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not + # respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None + # is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields + # our own (now-dead) session instead, so the client's segment requests degrade to the retryable + # "not available yet" path. + monkeypatch.setattr( + stream, "_select_encode", lambda db, item, transcode, src: (True, False) + ) + captured = {} + + def wait_then_cull(path, timeout=20.0, proc=None): + captured["s"] = stream._sessions[ + "12345" + ] # the HW session the initial _spawn just registered + del stream._sessions[ + "12345" + ] # simulate _enforce_cap dropping the LRU under cap pressure + return False + + monkeypatch.setattr(stream, "wait_for", wait_then_cull) + s = stream.start_session( + db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True + ) + assert ( + len(_CapturingProc.calls) == 1 + ) # no SW respawn — the cull is respected, cap not re-breached + assert ( + s is captured["s"] + ) # our own session yielded, NOT None (which the route would 404 as missing) + assert "12345" not in stream._sessions # the culled key stays gone + 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`). @@ -790,9 +825,21 @@ class TestOpenclPresent: assert stream._opencl_present() is False def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch): + # A missing dir: Path.glob yields nothing (no raise) → False. monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope") assert stream._opencl_present() is False + def test_false_when_glob_raises_oserror(self, monkeypatch): + # The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE, + # not return empty. That must be swallowed to False, never propagated up through _select_encode + # into start_session (which would crash HDR routing). + class _BoomDir: + def glob(self, pattern): + raise OSError("permission denied") + + monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir()) + assert stream._opencl_present() is False + class TestWaitForProcFastFail: """wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s From b563155a376817b8f5fe57940b0fa7e2c74934a0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 15:18:26 +0200 Subject: [PATCH 5/6] docs(plex): correct the cull-yield fallback comment to name the real route 404s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review round 3: the only surviving finding — a comment-drift nit. The fallback's cull-yield comment named the route's 'Not available yet' path, but a culled-key segment request actually returns 'No active playback session' (the substantive invariant — never a None that 404s as file-not-found — is unchanged). Reworded to match the route. Concurrency + correctness came back clean. --- backend/app/plex/stream.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index f404fac0..8727d1a3 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -667,9 +667,11 @@ def start_session( ) respawned = _spawn(False, False, replace_only=s) if respawned is None: - # Superseded/culled mid-fallback — yield the session registered now (a newer seek-restart), or - # our own now-dead session if the key was cap-culled (its segment requests degrade to the - # route's retryable "not available yet", never a dishonest "file not found" from a None). + # Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or + # our own now-dead session if the key was cap-culled. Never None here: the POST /session route + # turns a None return into a "Media file not found on disk" 404 — dishonest for a file that + # exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" / + # "Not available yet") the client re-requests. return current_session(key) or s s = respawned ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) From d4ed45b2eb68f2c422e0093617953c88dd36a237 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 30 Aug 2026 18:56:18 +0200 Subject: [PATCH 6/6] =?UTF-8?q?release:=200.78.2=20=E2=80=94=20HW=20HDR?= =?UTF-8?q?=E2=86=92SDR=20tonemap=20for=2010-bit/HDR=20Plex=20transcode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 89f7615c..b44885b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.78.1 \ No newline at end of file +0.78.2 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index dfcde9c3..2f8fd52c 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,21 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.78.2", + date: "2026-08-30", + summary: + "HDR and 10-bit videos in the Plex player now transcode on the server GPU instead of falling back to the CPU. HDR is tone-mapped to SDR on the GPU so the picture looks right in the browser, at a fraction of the CPU cost.", + features: [ + "A 10-bit / HDR video (HDR10, HLG — common on 4K and modern HEVC files) that needs transcoding now runs on the Intel GPU, tone-mapping HDR→SDR on-device so colours look right in the browser and the CPU is barely touched. Previously these fell back to a heavy software encode; now the GPU does the work. If the GPU path can't start for any reason, playback still falls back to software automatically, so a video never fails to play.", + ], + fixes: [ + "A 10-bit video that is NOT HDR (standard-range 10-bit, common in anime and some HEVC encodes) is no longer run through the HDR tone-curve — it gets a plain down-convert, so its colours are preserved exactly instead of being subtly darkened.", + ], + chores: [ + "The transcode pipeline now decides hardware vs software (and HDR tone-mapping) from a real probe of the source, and the GPU HDR path uses the Intel OpenCL runtime baked into the image. The whole change went through several multi-agent deep-review rounds — including an adversarially-verified fix to a start/seek race in the session fallback — re-reviewed until a round came back clean.", + ], + }, { version: "0.78.1", date: "2026-08-30",