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/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/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 1f75d071..8727d1a3 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). @@ -143,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. @@ -153,24 +155,123 @@ _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 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 _hwaccel_in_opts(hw: bool) -> list[str]: +# 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 + + +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( + [ + "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 _VK_HWSAFE + 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 _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: + """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, @@ -183,6 +284,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. @@ -220,20 +338,53 @@ def _render_node_present() -> bool: return Path(_VAAPI_DEVICE).exists() -def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> 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).""" +def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]: + """Pick the transcode video pipeline for this session — returns `(hw, tonemap)`: + * `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a + 10-bit/HDR source with no OpenCL runtime to tonemap on the GPU). + * `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source. + * `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when + the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively). + + 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 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 + 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 - return (item.codec_video or "").lower() in _HW_DECODABLE + # 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) + kind = _probe_video_kind(src) + if kind == _VK_HWSAFE: + return (True, False) # 8-bit SDR → full-GPU scale path + if kind == _VK_HDR: + # PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264. + return (True, True) if _opencl_present() else (False, False) + # _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: + """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] # CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API @@ -288,15 +439,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 @@ -425,67 +578,104 @@ 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, 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 + 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: + 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) + 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, + tonemap=tonemap, + 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, + tonemap=tonemap, + ) + _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(*_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: + # 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. 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 + ) + respawned = _spawn(False, False, replace_only=s) + if respawned is 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) + 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,17 +687,18 @@ 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", + "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, is_multi, audio_ord, transcode, - hw, + s.hw, + s.tonemap, ) return s @@ -520,10 +711,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 +726,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/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 00c55ece..39be017f 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -267,50 +267,206 @@ 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, - with an admin `plex_transcode_hw` policy (auto/on/off) on top.""" + with an admin `plex_transcode_hw` policy (auto/on/off) on top. A HW session on a 10-bit/HDR source + tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to + software — see the `_select_encode` cases below.""" + + SRC = Path("/media/movie.mkv") @staticmethod 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): + # 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 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) - 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_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._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_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._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._kind(monkeypatch, stream._VK_HWSAFE) + 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") 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 + 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) 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 + ) + + 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._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: @@ -333,7 +489,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 +502,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 +516,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 +554,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 +562,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: 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 ) @@ -421,25 +579,292 @@ 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, "_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" 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( + 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, "_select_encode", lambda db, item, transcode, src: (True, False) + ) + 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_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_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`). - monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: 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] assert _venc(cmd) == "copy" assert kwargs.get("preexec_fn") is None + + +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): + class _R: + def __init__(self): + self.stdout = stdout + + monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R()) + + 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_kind(Path("/x")) == stream._VK_HWSAFE + + 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_kind(Path("/x")) == stream._VK_SDR10 + + 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_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=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n" + ) + assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR + + 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_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): + # 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 + 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 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", 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",