fix(plex): route 10-bit/HDR HEVC to software + runtime VAAPI→libx264 fallback
A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12 on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments and the item never played, with no fallback — a retry just re-picked the failing HW path. An 8-bit HEVC and the same file on libx264 both work. Two defenses: - `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from color_transfer) — a 10-bit/HDR source takes the software libx264 path, which downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this is probed at session start. "on" still trusts the admin and skips the gate. - `start_session` adds a runtime HW→SW fallback: a HW session that emits no first segment respawns on libx264 and reports hw=False (the route/stats overlay honestly show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead of burning the 25s budget before the fallback fires. Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in TestTranscodeStartSession. Backend lane green.
This commit is contained in:
@@ -269,48 +269,92 @@ class TestTranscodeCommand:
|
||||
|
||||
|
||||
class TestWantHw:
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable
|
||||
AND 8-bit SDR, with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
|
||||
SRC = Path("/media/movie.mkv")
|
||||
|
||||
@staticmethod
|
||||
def _policy(monkeypatch, mode):
|
||||
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||
|
||||
@staticmethod
|
||||
def _hwsafe(monkeypatch, safe):
|
||||
# Default the bit-depth probe so the auto-decodable tests don't shell out to ffprobe.
|
||||
monkeypatch.setattr(stream, "_probe_video_hwsafe", lambda src: safe)
|
||||
|
||||
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_no_hw_without_a_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
|
||||
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
|
||||
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_10bit_or_hdr_source(self, monkeypatch):
|
||||
# BUG 1: a decodable HEVC that is 10-bit/HDR still breaks scale_vaapi=format=nv12 → software.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, False)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||
self._policy(monkeypatch, "off")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
|
||||
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
|
||||
# required — HW is impossible without it).
|
||||
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
|
||||
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
|
||||
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||
is True
|
||||
)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
class TestColdStartWarmup:
|
||||
@@ -333,7 +377,7 @@ class TestColdStartWarmup:
|
||||
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return True
|
||||
|
||||
@@ -346,7 +390,7 @@ class TestColdStartWarmup:
|
||||
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||
|
||||
@@ -360,7 +404,7 @@ class TestColdStartWarmup:
|
||||
# still returned so the client's own segment requests can drive it, as before.
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return False
|
||||
|
||||
@@ -398,7 +442,7 @@ class TestTranscodeStartSession:
|
||||
"get_str",
|
||||
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
|
||||
)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
|
||||
_CapturingProc.calls = []
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
|
||||
return tmp_path
|
||||
@@ -406,7 +450,7 @@ class TestTranscodeStartSession:
|
||||
def test_software_transcode_threads_the_knobs_into_the_command(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: False)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
@@ -421,11 +465,12 @@ class TestTranscodeStartSession:
|
||||
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
|
||||
|
||||
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: True)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True
|
||||
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
@@ -434,12 +479,82 @@ class TestTranscodeStartSession:
|
||||
) # no SW cap
|
||||
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
|
||||
|
||||
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
|
||||
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
|
||||
# the item still plays and the route/player honestly show CPU.
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: True)
|
||||
seen = {"n": 0}
|
||||
|
||||
def flaky_wait(path, timeout=20.0, proc=None):
|
||||
seen["n"] += 1
|
||||
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert (
|
||||
s is not None and s.transcode is True and s.hw is False
|
||||
) # honest CPU after the fallback
|
||||
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
|
||||
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
|
||||
assert _venc(hw_cmd) == "h264_vaapi"
|
||||
assert _venc(sw_cmd) == "libx264"
|
||||
if os.name == "posix":
|
||||
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
|
||||
|
||||
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
|
||||
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
|
||||
# the preexec nice are gated behind `transcode=True`).
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode, src: False)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "copy"
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
|
||||
class TestProbeVideoHwsafe:
|
||||
"""The ffprobe-based 8-bit-SDR gate that keeps 10-bit/HDR sources off the VAAPI pipeline (BUG 1):
|
||||
scale_vaapi=format=nv12 can't convert them on Gen 9.5, so they must take software libx264."""
|
||||
|
||||
@staticmethod
|
||||
def _probe(monkeypatch, stdout):
|
||||
class _R:
|
||||
def __init__(self):
|
||||
self.stdout = stdout
|
||||
|
||||
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
|
||||
|
||||
def test_8bit_sdr_is_hw_safe(self, monkeypatch):
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
|
||||
assert stream._probe_video_hwsafe(Path("/x")) is True
|
||||
|
||||
def test_10bit_pix_fmt_is_not_hw_safe(self, monkeypatch):
|
||||
# p010/yuv420p10le breaks the VPP format pass even when bits_per_raw_sample is absent.
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
|
||||
assert stream._probe_video_hwsafe(Path("/x")) is False
|
||||
|
||||
def test_bits_per_raw_sample_over_8_is_not_hw_safe(self, monkeypatch):
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\n")
|
||||
assert stream._probe_video_hwsafe(Path("/x")) is False
|
||||
|
||||
def test_hdr_transfer_is_not_hw_safe(self, monkeypatch):
|
||||
# Even an 8-bit-tagged PQ/HLG source can't be tonemapped to nv12 as configured.
|
||||
self._probe(
|
||||
monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=smpte2084\n"
|
||||
)
|
||||
assert stream._probe_video_hwsafe(Path("/x")) is False
|
||||
|
||||
def test_probe_failure_assumes_hw_safe(self, monkeypatch):
|
||||
# ffprobe missing/erroring → assume safe; the runtime HW→SW fallback is the real backstop, so a
|
||||
# probe miss costs at most one wasted HW attempt, never a stranded item.
|
||||
def boom(*a, **k):
|
||||
raise OSError("no ffprobe")
|
||||
|
||||
monkeypatch.setattr(stream.subprocess, "run", boom)
|
||||
assert stream._probe_video_hwsafe(Path("/x")) is True
|
||||
|
||||
Reference in New Issue
Block a user