fix(plex): a codec the GPU cannot decode is decoded on the CPU and still encoded on the GPU

The prod iGPU has no VC-1 Advanced decode ("No support for codec vc1 profile 3"), so every VC-1
session died on the GPU and fell back to libx264, which needs ~17 s for the three warm-up segments:
a start or a seek took ~19.5 s. VC-1, XviD and DivX now take CPU decode -> hwupload -> h264_vaapi
(three segments in 5.5 s for a 1080p VC-1 remux, measured on prod hardware with this argv).

The transcode_hw facet now says whether the GPU encodes: VC-1/XviD/DivX count, and so does an HDR
film (the OpenCL tonemap runs on the GPU; the facet said software). Migration 0119 rewrites the
stored facet and re-queues 10-bit items for the HDR probe.
This commit is contained in:
2026-11-07 18:39:15 +01:00
parent 2b7620ded9
commit 9778bdf351
6 changed files with 385 additions and 87 deletions
+70
View File
@@ -0,0 +1,70 @@
"""Migration 0119's data step, DB-lane: the stored `transcode_hw` facet follows the rule that it says
whether the GPU does the encode, and a 10-bit decodable item is probed again so its HDR flag decides.
The schema half is checked by test_migrations.py; a facet value left stale is invisible there.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_the_stored_facet_is_rewritten_by_the_gpu_encode_rule(db):
cfg = Config("alembic.ini")
command.downgrade(cfg, "0118_videos_shuffle_key")
try:
with engine.begin() as conn:
lib = conn.execute(
text(
"INSERT INTO plex_libraries (plex_key, title, kind, enabled)"
" VALUES ('m1', 'Movies', 'movie', true) RETURNING id"
)
).scalar_one()
def item(key, playable, codec, hw, depth=None, probed=False):
conn.execute(
text(
"INSERT INTO plex_items (rating_key, library_id, kind, title, playable,"
" codec_video, transcode_hw, video_bit_depth, media_probed_at)"
" VALUES (:k, :l, 'movie', :k, :p, :c, :hw, :d,"
" CASE WHEN :pr THEN now() END)"
),
{
"k": key,
"l": lib,
"p": playable,
"c": codec,
"hw": hw,
"d": depth,
"pr": probed,
},
)
item("vc1", "transcode", "VC1", True, 8, probed=True) # the old list said decodable
item("xvid", "transcode", "mpeg4", False)
item("divx", "transcode", "msmpeg4v3", False)
item("av1", "transcode", "av1", False)
item("h8", "transcode", "hevc", True, 8, probed=True)
item("h10", "transcode", "hevc", False, 10, probed=True) # SDR or HDR: unknown here
item("remux", "remux", "h264", None)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
rows = {
k: (hw, probed)
for k, hw, probed in conn.execute(
text("SELECT rating_key, transcode_hw, media_probed_at IS NOT NULL FROM plex_items")
).tuples()
}
assert rows == {
"vc1": (True, True),
"xvid": (True, False),
"divx": (True, False),
"av1": (False, False),
"h8": (True, True),
"h10": (False, False), # re-queued: the probe reads its HDR flag again
"remux": (None, False),
}
+56 -20
View File
@@ -32,15 +32,25 @@ class TestTranscodeHwCapable:
assert sync.transcode_hw_capable("transcode", "hevc") is True
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
def test_non_decodable_codec_is_sw(self):
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
def test_a_codec_neither_path_knows_is_sw(self):
assert sync.transcode_hw_capable("transcode", "av1") is False
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
assert sync.transcode_hw_capable("transcode", "av1", bit_depth=8) is False
def test_10bit_or_hdr_hevc_is_sw(self):
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
def test_a_cpu_decoded_8bit_codec_is_encoded_on_the_gpu(self):
# VC-1 (the iGPU has no VC-1 Advanced decode), XviD, DivX: the CPU decodes, the GPU encodes.
for codec in ("vc1", "mpeg4", "msmpeg4v3"):
assert sync.transcode_hw_capable("transcode", codec) is True
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=10) is False
def test_10bit_sdr_hevc_is_sw(self):
# 10-bit SDR: an exact downconvert on the CPU (libx264), never the HDR tone curve.
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
def test_hdr_hevc_is_encoded_on_the_gpu(self):
# HDR goes through the OpenCL tonemap between VAAPI decode and encode (the prod image ships the
# runtime); the facet said software for it while every such session ran on the GPU.
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10, hdr=True) is True
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is True
class TestVideoStreamProps:
@@ -87,6 +97,9 @@ class TestSyncDerivation:
row2 = PlexItem(rating_key="y", kind="movie")
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
assert row2.transcode_hw is False # av1 not HW-decodable
row3 = PlexItem(rating_key="z", kind="movie")
sync._apply_item(row3, lib_id=1, kind="movie", meta=self._meta("vc1"))
assert row3.transcode_hw is True # CPU decode, GPU encode
def test_probed_value_survives_resync_when_codec_unchanged(self):
row = PlexItem(rating_key="x", kind="movie")
@@ -124,21 +137,24 @@ class TestSyncDerivation:
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
def test_migration_codec_set_matches_hw_decodable_codecs():
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
# equality so adding/removing a codec from the frozenset without editing the migration fails here
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
def test_migration_codec_sets_match_the_sync_sets():
# The newest migration that rewrites `transcode_hw` hardcodes both codec sets in raw SQL. Pin them
# to plex.sync, so a codec added to or removed from a frozenset without a migration that rewrites
# the stored rows fails here (else prod rows keep a facet the runtime choice disagrees with).
mig = (
Path(__file__).resolve().parent.parent
/ "alembic"
/ "versions"
/ "0077_plex_playback_class.py"
/ "0119_plex_transcode_gpu.py"
)
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
assert m, "could not find the codec IN(...) list in migration 0077"
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
src = mig.read_text()
sets = {}
for name in ("HW_DECODABLE_CODECS", "CPU_DECODE_CODECS"):
m = re.search(name + r"\s*=\s*\(([^)]*)\)", src)
assert m, f"could not find {name} in migration 0119"
sets[name] = {c.strip().strip("'\"").lower() for c in m.group(1).split(",") if c.strip()}
assert sets["HW_DECODABLE_CODECS"] == {c.lower() for c in sync.HW_DECODABLE_CODECS}
assert sets["CPU_DECODE_CODECS"] == {c.lower() for c in sync.CPU_DECODE_CODECS}
class _FakePlex:
@@ -182,22 +198,42 @@ def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
codec_video="mpeg4",
transcode_hw=False,
)
db.add_all([hevc8, hevc10, xvid])
hdr10 = PlexItem(
rating_key="hdr",
library_id=lib.id,
kind="movie",
title="hdr",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add_all([hevc8, hevc10, xvid, hdr10])
db.commit()
plex = _FakePlex(
{
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
"hdr": {
"Media": [
{
"Part": [
{"Stream": [{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"}]}
]
}
]
},
}
)
probed = sync._probe_media_batch(db, plex)
db.refresh(hevc8)
db.refresh(hevc10)
db.refresh(xvid)
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
assert probed == 2
db.refresh(hdr10)
# Only the HW-decodable HEVC items are probed; the XviD's class follows from its codec alone.
assert probed == 3
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # 10-bit SDR: software
assert hdr10.video_bit_depth == 10 and hdr10.transcode_hw is True # HDR: the GPU tonemap
assert xvid.media_probed_at is None # not in the work-list
# A second run has nothing left to do (watermarks set).
assert sync._probe_media_batch(db, plex) == 0
+85 -14
View File
@@ -284,6 +284,24 @@ class TestTranscodeCommand:
)
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
def test_cpu_decode_uploads_the_frames_to_the_gpu_encoder(self):
# A source the GPU cannot decode (VC-1 Advanced on Gen 9.5): the CPU decodes, the frames go to
# nv12 and up to the GPU, and h264_vaapi encodes. No -hwaccel: asking the GPU to decode is what
# failed.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, cpu_decode=True
)
assert "-hwaccel" not in cmd
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
assert cmd[cmd.index("-filter_hw_device") + 1] == "va"
assert cmd.index("-init_hw_device") < cmd.index("-i")
assert cmd[cmd.index("-vf") + 1] == "format=nv12,hwupload"
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
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.
@@ -365,19 +383,48 @@ class TestWantHw:
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.
def test_auto_falls_back_to_software_for_a_codec_neither_path_knows(self, monkeypatch):
# AV1 is neither GPU-decodable on Gen 9.5 nor on the CPU-decode list (it can be 10-bit HDR).
self._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
self._hwsafe(monkeypatch, True) # the codec 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, src=self.SRC)
is False
)
assert (
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
is False
)
@pytest.mark.parametrize("codec", ["vc1", "mpeg4", "msmpeg4v3"])
def test_auto_cpu_decodes_what_the_gpu_cannot_and_still_encodes_on_it(self, monkeypatch, codec):
# Measured on prod (UHD 630, iHD 25.2.3): "No support for codec vc1 profile 3", so every VC-1
# HW session died and fell back to libx264 (~19 s to start). Decoding on the CPU and encoding on
# the GPU produced three segments in ~5 s (VC-1 1080p) and ~1 s (XviD, DivX SD).
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=codec), transcode=True, src=self.SRC
) == (True, False, True)
def test_auto_a_cpu_decoded_codec_that_is_not_8bit_sdr_stays_software(self, monkeypatch):
# The upload path converts to nv12 for the encoder; only an 8-bit SDR picture is known to come
# through it unchanged.
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_SDR10)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC
) == (False, False, False)
def test_on_cpu_decodes_a_codec_the_gpu_cannot(self, monkeypatch):
# "on" trusts the GPU, but asking it to DECODE a codec it is known not to decode only buys the
# fallback's wasted second; the upload path is the GPU route for such a source.
self._policy(monkeypatch, "on")
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="vc1"), transcode=True, src=self.SRC
) == (True, False, True)
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
@@ -388,7 +435,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
) == (False, 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
@@ -399,7 +446,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
) == (True, True, False)
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
@@ -411,7 +458,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
) == (False, 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
@@ -422,7 +469,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
) == (True, False, False)
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off")
@@ -460,7 +507,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
) == (True, True, False)
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
@@ -471,7 +518,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
) == (True, False, 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
@@ -483,7 +530,7 @@ class TestWantHw:
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
) == (True, False, False)
class TestColdStartWarmup:
@@ -620,6 +667,30 @@ class TestTranscodeStartSession:
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_a_cpu_decode_session_encodes_on_the_gpu_and_falls_back_to_software(
self, capturing_env, monkeypatch
):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False, True)
)
seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None):
seen["n"] += 1
return seen["n"] > 1 # the upload session yields nothing; the software respawn does
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, user_id=7, item=_FakeItem(codec_video="vc1"), start_s=0.0, transcode=True
)
(up_cmd, *_), up_kwargs = _CapturingProc.calls[0]
assert "format=nv12,hwupload" in up_cmd and _venc(up_cmd) == "h264_vaapi"
if os.name == "posix": # the decode is CPU work: niced like a software transcode
assert up_kwargs.get("preexec_fn") is stream._sw_child_preexec
(sw_cmd, *_), _ = _CapturingProc.calls[1]
assert _venc(sw_cmd) == "libx264" and "format=nv12,hwupload" not in sw_cmd
assert s is not None and s.hw is False and s.cpu_decode is False
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
self, capturing_env, monkeypatch
):