release: 0.78.2 (feature/plex-hdr-hw-tonemap)

This commit is contained in:
2026-08-30 18:56:32 +02:00
13 changed files with 798 additions and 111 deletions
+13
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
0.78.1
0.78.2
+1 -1
View File
@@ -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
+276 -75
View File
@@ -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()
+4 -2
View File
@@ -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,
}
+1 -1
View File
@@ -58,7 +58,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
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)
+449 -24
View File
@@ -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
+5
View File
@@ -101,6 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -234,6 +237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
@@ -1206,6 +1210,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
+4 -1
View File
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
hlsRef,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
buildStatRows({
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
return (
<div
+1
View File
@@ -392,6 +392,7 @@ export interface PlexPlaySession {
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
+9
View File
@@ -118,6 +118,11 @@ describe("buildStatRows", () => {
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
});
it("tags the mode row with HDR→SDR for a HW tonemap session", () => {
const rows = buildStatRows({ ...base, playable: "transcode", hwEncoder: true, tonemap: true });
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode · GPU · HDR→SDR");
});
it("shows an empty classification honestly as a dash", () => {
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
DASH,
@@ -133,5 +138,9 @@ describe("fmtMode", () => {
expect(fmtMode("transcode")).toBe("transcode");
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
expect(fmtMode("")).toBe(DASH);
// The HDR→SDR tag rides on the GPU encoder token for a tonemap session.
expect(fmtMode("transcode", true, true)).toBe("transcode · GPU · HDR→SDR");
expect(fmtMode("transcode", true, false)).toBe("transcode · GPU");
expect(fmtMode("transcode", null, true)).toBe("transcode"); // no encoder → no tag either
});
});
+19 -6
View File
@@ -12,6 +12,9 @@ export interface PlexStatsInput {
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
// 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",
+15
View File
@@ -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",