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:
@@ -0,0 +1,65 @@
|
|||||||
|
"""plex_items.transcode_hw: the facet says whether the GPU ENCODES, under the measured codec sets
|
||||||
|
|
||||||
|
Measured on prod (2026-11-07): the iGPU has no VC-1 Advanced decode, so a VC-1 film never ran on the
|
||||||
|
GPU although the facet said it did; VC-1, XviD and DivX now take the CPU-decode → GPU-encode path; and an
|
||||||
|
HDR film runs on the GPU through the OpenCL tonemap although the facet said software. The rule is
|
||||||
|
`plex.sync.transcode_hw_capable`; the two codec sets below are pinned to `plex.sync` by
|
||||||
|
tests/test_plex_playback_class.py.
|
||||||
|
|
||||||
|
Rewritten from the stored codec and bit depth. HDR is not stored, so a 10-bit HW-decodable item is set to
|
||||||
|
software here and its probe watermark cleared: the bit-depth backfill (`plex.sync._probe_media_batch`,
|
||||||
|
a bounded batch per Plex sync) reads its HDR flag again and moves an HDR one back to the GPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0119_plex_transcode_gpu"
|
||||||
|
down_revision: Union[str, None] = "0118_videos_shuffle_key"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
HW_DECODABLE_CODECS = ("h264", "hevc", "h265", "vp9", "mpeg2video")
|
||||||
|
CPU_DECODE_CODECS = ("vc1", "mpeg4", "msmpeg4v3")
|
||||||
|
|
||||||
|
|
||||||
|
def _in(codecs: tuple[str, ...]) -> str:
|
||||||
|
return ", ".join(f"'{c}'" for c in codecs)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE plex_items
|
||||||
|
SET transcode_hw = CASE
|
||||||
|
WHEN lower(codec_video) IN ({_in(HW_DECODABLE_CODECS)})
|
||||||
|
THEN coalesce(video_bit_depth, 8) <= 8
|
||||||
|
WHEN lower(codec_video) IN ({_in(CPU_DECODE_CODECS)})
|
||||||
|
THEN coalesce(video_bit_depth, 8) <= 8
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
WHERE playable = 'transcode'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE plex_items
|
||||||
|
SET media_probed_at = NULL
|
||||||
|
WHERE playable = 'transcode'
|
||||||
|
AND lower(codec_video) IN ({_in(HW_DECODABLE_CODECS)})
|
||||||
|
AND video_bit_depth > 8
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# The old rule: a decodable codec (VC-1 counted) at 8 bits; HDR and every other codec software.
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE plex_items
|
||||||
|
SET transcode_hw = lower(codec_video) IN ({_in(HW_DECODABLE_CODECS + ("vc1",))})
|
||||||
|
AND coalesce(video_bit_depth, 8) <= 8
|
||||||
|
WHERE playable = 'transcode'
|
||||||
|
"""
|
||||||
|
)
|
||||||
+75
-32
@@ -35,7 +35,7 @@ from app.config import settings
|
|||||||
from app.downloads.edit import encode_thread_cap
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.plex.models import PlexItem
|
from app.plex.models import PlexItem
|
||||||
from app.plex import paths
|
from app.plex import paths
|
||||||
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
|
from app.plex.sync import CPU_DECODE_CODECS, HDR_TRANSFERS, HW_DECODABLE_CODECS
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
_HLS_ROOT = (
|
_HLS_ROOT = (
|
||||||
@@ -120,6 +120,7 @@ class HlsSession:
|
|||||||
hw: bool = False,
|
hw: bool = False,
|
||||||
tonemap: bool = False,
|
tonemap: bool = False,
|
||||||
user_id: int = 0,
|
user_id: int = 0,
|
||||||
|
cpu_decode: bool = False,
|
||||||
):
|
):
|
||||||
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
|
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
|
||||||
# replaces the viewer's registered session for the item — and the old player's next request
|
# replaces the viewer's registered session for the item — and the old player's next request
|
||||||
@@ -135,6 +136,7 @@ class HlsSession:
|
|||||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
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.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.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
|
||||||
|
self.cpu_decode = cpu_decode # HW encode of frames the CPU decoded (a codec the GPU can't)
|
||||||
self.start_s = start_s # the REQUESTED seek offset
|
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
|
# 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).
|
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||||
@@ -188,8 +190,11 @@ _HLS_TAIL = [
|
|||||||
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
|
# * 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
|
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
|
||||||
# 1080p transcodes are fine and the API stays responsive.
|
# 1080p transcodes are fine and the API stays responsive.
|
||||||
|
# * CPU decode → VAAPI encode (`format=nv12,hwupload` + `h264_vaapi`) — a codec the GPU cannot decode
|
||||||
|
# but whose 8-bit picture it encodes (`CPU_DECODE_CODECS`: VC-1, XviD, DivX). The encode is the
|
||||||
|
# expensive half, so this still takes most of the load off the CPU.
|
||||||
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
|
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
|
||||||
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
|
# iGPU enablement) or the source fits neither GPU route (AV1, a 10-bit SDR source). CPU-bound, so
|
||||||
# it reserves a core via `-threads` (see encode_thread_cap).
|
# it reserves a core via `-threads` (see encode_thread_cap).
|
||||||
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
|
# 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.
|
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
|
||||||
@@ -198,10 +203,10 @@ _VAAPI_DEVICE = "/dev/dri/renderD128"
|
|||||||
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
|
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
|
||||||
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
|
_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
|
# 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 it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
|
# full-GPU VAAPI pipeline. A source outside it is decoded on the CPU when its codec is in
|
||||||
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
|
# `CPU_DECODE_CODECS` (the GPU still encodes), else transcoded in software. THE single source of both
|
||||||
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
|
# sets is plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't
|
||||||
# codec; aliased to the old name to keep the call sites below unchanged.
|
# disagree by codec; aliased to the old name to keep the call sites below unchanged.
|
||||||
_HW_DECODABLE = HW_DECODABLE_CODECS
|
_HW_DECODABLE = HW_DECODABLE_CODECS
|
||||||
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
|
# 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
|
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||||
@@ -302,16 +307,22 @@ def _opencl_present() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
|
def _hwaccel_in_opts(hw: bool, tonemap: bool = False, cpu_decode: bool = False) -> list[str]:
|
||||||
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
|
"""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
|
`-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
|
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
|
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`."""
|
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`.
|
||||||
|
|
||||||
|
The CPU-decode variant opens the VAAPI device for the FILTER graph only (`hwupload` needs one) and
|
||||||
|
asks for no `-hwaccel`: the codec is one the GPU refuses to decode. This exact form is the one
|
||||||
|
measured on prod with the HLS muxer and `-copyts` (and to the end of the file)."""
|
||||||
if not hw:
|
if not hw:
|
||||||
return []
|
return []
|
||||||
|
if cpu_decode:
|
||||||
|
return ["-init_hw_device", f"vaapi=va:{_VAAPI_DEVICE}", "-filter_hw_device", "va"]
|
||||||
if tonemap:
|
if tonemap:
|
||||||
return [
|
return [
|
||||||
"-init_hw_device",
|
"-init_hw_device",
|
||||||
@@ -359,6 +370,7 @@ def _venc_opts(
|
|||||||
preset: str = "veryfast",
|
preset: str = "veryfast",
|
||||||
crf: int = 20,
|
crf: int = 20,
|
||||||
max_height: int = 0,
|
max_height: int = 0,
|
||||||
|
cpu_decode: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
|
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
|
||||||
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
|
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
|
||||||
@@ -385,6 +397,10 @@ def _venc_opts(
|
|||||||
"h264_vaapi",
|
"h264_vaapi",
|
||||||
*force_kf,
|
*force_kf,
|
||||||
]
|
]
|
||||||
|
if hw and cpu_decode:
|
||||||
|
# The CPU decoded the frames: convert them to the encoder's 8-bit nv12 and upload them to the
|
||||||
|
# device `-filter_hw_device` named (see _hwaccel_in_opts).
|
||||||
|
return ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", *force_kf]
|
||||||
if hw:
|
if hw:
|
||||||
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
|
# 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.
|
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
|
||||||
@@ -422,13 +438,18 @@ def _render_node_present() -> bool:
|
|||||||
return Path(_VAAPI_DEVICE).exists()
|
return Path(_VAAPI_DEVICE).exists()
|
||||||
|
|
||||||
|
|
||||||
def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
|
def _select_encode(
|
||||||
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
|
db: DbSession, item: PlexItem, transcode: bool, src: Path
|
||||||
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
|
) -> tuple[bool, bool, bool]:
|
||||||
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
|
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap, cpu_decode)`:
|
||||||
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
|
* `(False, False, False)` → software libx264 (the fallback: no render node, a source neither GPU
|
||||||
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
|
route takes, or a 10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
|
||||||
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
|
* `(True, False, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR
|
||||||
|
source the GPU decodes.
|
||||||
|
* `(True, True, False)` → 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).
|
||||||
|
* `(True, False, True)` → CPU decode → `hwupload` → VAAPI encode — an 8-bit SDR source in a codec
|
||||||
|
the GPU cannot decode (`CPU_DECODE_CODECS`).
|
||||||
|
|
||||||
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
|
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.
|
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
|
||||||
@@ -438,31 +459,40 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) ->
|
|||||||
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
|
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
|
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)
|
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."""
|
source that has an OpenCL runtime, while a 10-bit SDR source drops to software. In both "on" and
|
||||||
|
"auto" a codec the GPU is known not to decode goes the CPU-decode route: asking the GPU to decode it
|
||||||
|
only bought the fallback's wasted second before libx264."""
|
||||||
|
sw = (False, False, False)
|
||||||
if not transcode:
|
if not transcode:
|
||||||
return (False, False)
|
return sw
|
||||||
mode = sysconfig.get_str(db, "plex_transcode_hw")
|
mode = sysconfig.get_str(db, "plex_transcode_hw")
|
||||||
if mode == "off" or not _render_node_present():
|
if mode == "off" or not _render_node_present():
|
||||||
return (False, False)
|
return sw
|
||||||
|
codec = (item.codec_video or "").lower()
|
||||||
if mode == "on":
|
if mode == "on":
|
||||||
|
if codec in CPU_DECODE_CODECS:
|
||||||
|
return (True, False, True)
|
||||||
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
|
# 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
|
# 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
|
# 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.
|
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
|
||||||
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
|
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
|
||||||
return (True, tonemap)
|
return (True, tonemap, False)
|
||||||
# auto
|
# auto
|
||||||
if (item.codec_video or "").lower() not in _HW_DECODABLE:
|
if codec in CPU_DECODE_CODECS:
|
||||||
return (False, False)
|
# The upload converts to 8-bit nv12: only an 8-bit SDR picture comes through it unchanged.
|
||||||
|
return (True, False, True) if _probe_video_kind(src) == _VK_HWSAFE else sw
|
||||||
|
if codec not in _HW_DECODABLE:
|
||||||
|
return sw
|
||||||
kind = _probe_video_kind(src)
|
kind = _probe_video_kind(src)
|
||||||
if kind == _VK_HWSAFE:
|
if kind == _VK_HWSAFE:
|
||||||
return (True, False) # 8-bit SDR → full-GPU scale path
|
return (True, False, False) # 8-bit SDR → full-GPU scale path
|
||||||
if kind == _VK_HDR:
|
if kind == _VK_HDR:
|
||||||
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
|
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
|
||||||
return (True, True) if _opencl_present() else (False, False)
|
return (True, True, False) if _opencl_present() else sw
|
||||||
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
|
# _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.
|
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
|
||||||
return (False, False)
|
return sw
|
||||||
|
|
||||||
|
|
||||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||||
@@ -540,13 +570,15 @@ def _ffmpeg_cmd(
|
|||||||
crf: int = 20,
|
crf: int = 20,
|
||||||
max_height: int = 0,
|
max_height: int = 0,
|
||||||
loudnorm: bool = False,
|
loudnorm: bool = False,
|
||||||
|
cpu_decode: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
|
# 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
|
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
|
||||||
# identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
|
# 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.
|
# 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)
|
# `cpu_decode` is the HW encode of CPU-decoded frames: a device for the filter graph, no -hwaccel.
|
||||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
|
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height, cpu_decode)
|
||||||
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap, cpu_decode)]
|
||||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
# 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
|
# 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
|
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||||
@@ -800,7 +832,11 @@ def start_session(
|
|||||||
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
||||||
|
|
||||||
def _spawn(
|
def _spawn(
|
||||||
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
|
hw: bool,
|
||||||
|
tonemap: bool = False,
|
||||||
|
cpu_decode: bool = False,
|
||||||
|
*,
|
||||||
|
replace_only: "HlsSession | None" = None,
|
||||||
) -> "HlsSession | None":
|
) -> "HlsSession | None":
|
||||||
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
# `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
|
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
|
||||||
@@ -815,10 +851,12 @@ def start_session(
|
|||||||
preset = sysconfig.get_str(db, "plex_transcode_preset")
|
preset = sysconfig.get_str(db, "plex_transcode_preset")
|
||||||
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
||||||
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
|
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
|
# Nice the child that works the CPU — a SOFTWARE transcode, or a CPU decode feeding the GPU
|
||||||
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
# encoder (not the full-GPU path, and not remux) — so it can't starve the co-located
|
||||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
# API/playback. preexec_fn runs in the forked child (POSIX); on a platform without fork (the API
|
||||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
# always runs in a Linux container in prod) it's simply omitted.
|
||||||
|
cpu_bound = transcode and (not hw or cpu_decode)
|
||||||
|
preexec = _sw_child_preexec if (cpu_bound and os.name == "posix") else None
|
||||||
doomed: list[tuple[HlsSession, Path | None]] = []
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
|
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
|
||||||
# was moved aside under the lock is otherwise left for a restart's sweep.
|
# was moved aside under the lock is otherwise left for a restart's sweep.
|
||||||
@@ -851,6 +889,7 @@ def start_session(
|
|||||||
crf=crf,
|
crf=crf,
|
||||||
max_height=max_height,
|
max_height=max_height,
|
||||||
loudnorm=loudnorm,
|
loudnorm=loudnorm,
|
||||||
|
cpu_decode=cpu_decode,
|
||||||
),
|
),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
@@ -866,6 +905,7 @@ def start_session(
|
|||||||
hw=hw,
|
hw=hw,
|
||||||
tonemap=tonemap,
|
tonemap=tonemap,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
cpu_decode=cpu_decode,
|
||||||
)
|
)
|
||||||
_sessions[reg] = s
|
_sessions[reg] = s
|
||||||
# The one line that says which pipeline a session actually got. Everything downstream
|
# The one line that says which pipeline a session actually got. Everything downstream
|
||||||
@@ -879,6 +919,7 @@ def start_session(
|
|||||||
transcode=transcode,
|
transcode=transcode,
|
||||||
hw=hw,
|
hw=hw,
|
||||||
tonemap=tonemap,
|
tonemap=tonemap,
|
||||||
|
cpu_decode=cpu_decode,
|
||||||
variants=len(variants or ()),
|
variants=len(variants or ()),
|
||||||
start_s=round(start_s, 1),
|
start_s=round(start_s, 1),
|
||||||
),
|
),
|
||||||
@@ -937,7 +978,8 @@ def start_session(
|
|||||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
|
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
|
||||||
break
|
break
|
||||||
log.info(
|
log.info(
|
||||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
|
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s"
|
||||||
|
" tonemap=%s cpu_decode=%s",
|
||||||
key,
|
key,
|
||||||
start_s,
|
start_s,
|
||||||
s.media_start_s,
|
s.media_start_s,
|
||||||
@@ -946,6 +988,7 @@ def start_session(
|
|||||||
transcode,
|
transcode,
|
||||||
s.hw,
|
s.hw,
|
||||||
s.tonemap,
|
s.tonemap,
|
||||||
|
s.cpu_decode,
|
||||||
)
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
+34
-21
@@ -60,14 +60,20 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
|||||||
|
|
||||||
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
|
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
|
||||||
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
|
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
|
||||||
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
|
# the runtime encoder choice (`_select_encode`) and this catalog facet can never disagree by codec. VC-1
|
||||||
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
# is NOT in it: measured on prod, the iHD driver answers "No support for codec vc1 profile 3" (VC-1
|
||||||
# own encoder).
|
# Advanced, what a Blu-ray remux carries), so every session that asked the GPU to decode one died and
|
||||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
# fell back to libx264.
|
||||||
|
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "mpeg2video"})
|
||||||
|
# Codecs the iGPU cannot decode but whose picture it can still ENCODE: the CPU decodes, the frames go up
|
||||||
|
# to the GPU as 8-bit nv12, and h264_vaapi encodes (plex.stream's `cpu_decode` path). Measured on prod:
|
||||||
|
# three segments in ~5 s for a 1080p VC-1 remux (libx264: ~17 s) and ~1 s for an SD XviD or DivX
|
||||||
|
# episode. Only codecs that are always 8-bit SDR, and only ones measured; any other codec stays software.
|
||||||
|
CPU_DECODE_CODECS = frozenset({"vc1", "mpeg4", "msmpeg4v3"})
|
||||||
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
|
||||||
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
|
||||||
# one is tonemapped by all. A 10-bit/HDR source breaks the VAAPI scale=nv12 path on Gen 9.5 (see
|
# one is tonemapped by all. An HDR source breaks the plain VAAPI scale=nv12 path on Gen 9.5, so it takes
|
||||||
# plex.stream) → software transcode, so it counts as NOT HW-capable.
|
# the OpenCL tonemap graph (see plex.stream) — still the GPU encoder.
|
||||||
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||||
|
|
||||||
|
|
||||||
@@ -78,18 +84,24 @@ def transcode_hw_capable(
|
|||||||
hdr: bool = False,
|
hdr: bool = False,
|
||||||
) -> bool | None:
|
) -> bool | None:
|
||||||
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
|
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
|
||||||
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
|
iGPU ENCODES it ("Transcoded on the hardware encoder"), else software libx264; None for a
|
||||||
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
|
non-transcode item. The same answer plex.stream's `_select_encode` gives on prod (render node and
|
||||||
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
|
OpenCL runtime present, policy "auto"):
|
||||||
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
|
* a HW-decodable codec: 8-bit SDR → the full-GPU path; HDR → the OpenCL tonemap, also on the GPU;
|
||||||
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
|
10-bit SDR → software (an exact downconvert, never the HDR tone curve);
|
||||||
|
* a CPU-decode codec (`CPU_DECODE_CODECS`) at 8 bits → CPU decode, GPU encode;
|
||||||
|
* any other codec → software.
|
||||||
|
`bit_depth`/`hdr` are unknown at sync time (the cheap listing carries no Stream), so they default to
|
||||||
|
8-bit SDR and the bit-depth backfill refines a 10-bit SDR HEVC to False later."""
|
||||||
if playable != "transcode":
|
if playable != "transcode":
|
||||||
return None
|
return None
|
||||||
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
|
codec = (vcodec or "").lower()
|
||||||
return False
|
deep = bit_depth is not None and bit_depth > 8
|
||||||
if bit_depth is not None and bit_depth > 8:
|
if codec in HW_DECODABLE_CODECS:
|
||||||
return False
|
return hdr or not deep
|
||||||
return not hdr
|
if codec in CPU_DECODE_CODECS:
|
||||||
|
return not deep and not hdr
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||||
@@ -266,8 +278,9 @@ def sync(db: Session) -> dict:
|
|||||||
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
|
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
|
||||||
log.exception("the Plex people-enrich batch failed")
|
log.exception("the Plex people-enrich batch failed")
|
||||||
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
||||||
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
# playback-class filter's HW/SW split is precise (a 10-bit SDR HEVC → software) over
|
||||||
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are classed
|
||||||
|
# by codec alone.
|
||||||
try:
|
try:
|
||||||
stats["media_probed"] = _probe_media_batch(db, plex)
|
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||||
except Exception: # best-effort; never fail the sync over a backfill
|
except Exception: # best-effort; never fail the sync over a backfill
|
||||||
@@ -294,15 +307,15 @@ def sync(db: Session) -> dict:
|
|||||||
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
||||||
_ENRICH_BATCH = 60
|
_ENRICH_BATCH = 60
|
||||||
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
|
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
|
||||||
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
|
# probed (typically a small minority — the bulk of transcodes are old codecs classed by codec), so
|
||||||
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
|
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
|
||||||
_PROBE_BATCH = 60
|
_PROBE_BATCH = 60
|
||||||
|
|
||||||
|
|
||||||
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
|
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
|
||||||
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
|
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
|
||||||
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
|
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit SDR HEVC →
|
||||||
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
|
False/software; an HDR one stays on the GPU through the tonemap). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
|
||||||
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
|
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
|
||||||
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
|
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -32,15 +32,25 @@ class TestTranscodeHwCapable:
|
|||||||
assert sync.transcode_hw_capable("transcode", "hevc") is True
|
assert sync.transcode_hw_capable("transcode", "hevc") is True
|
||||||
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
|
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
|
||||||
|
|
||||||
def test_non_decodable_codec_is_sw(self):
|
def test_a_codec_neither_path_knows_is_sw(self):
|
||||||
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
|
|
||||||
assert sync.transcode_hw_capable("transcode", "av1") is False
|
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):
|
def test_a_cpu_decoded_8bit_codec_is_encoded_on_the_gpu(self):
|
||||||
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
|
# 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=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:
|
class TestVideoStreamProps:
|
||||||
@@ -87,6 +97,9 @@ class TestSyncDerivation:
|
|||||||
row2 = PlexItem(rating_key="y", kind="movie")
|
row2 = PlexItem(rating_key="y", kind="movie")
|
||||||
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
|
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
|
||||||
assert row2.transcode_hw is False # av1 not HW-decodable
|
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):
|
def test_probed_value_survives_resync_when_codec_unchanged(self):
|
||||||
row = PlexItem(rating_key="x", kind="movie")
|
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
|
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():
|
def test_migration_codec_sets_match_the_sync_sets():
|
||||||
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
|
# The newest migration that rewrites `transcode_hw` hardcodes both codec sets in raw SQL. Pin them
|
||||||
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
|
# to plex.sync, so a codec added to or removed from a frozenset without a migration that rewrites
|
||||||
# equality so adding/removing a codec from the frozenset without editing the migration fails here
|
# the stored rows fails here (else prod rows keep a facet the runtime choice disagrees with).
|
||||||
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
|
|
||||||
mig = (
|
mig = (
|
||||||
Path(__file__).resolve().parent.parent
|
Path(__file__).resolve().parent.parent
|
||||||
/ "alembic"
|
/ "alembic"
|
||||||
/ "versions"
|
/ "versions"
|
||||||
/ "0077_plex_playback_class.py"
|
/ "0119_plex_transcode_gpu.py"
|
||||||
)
|
)
|
||||||
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
|
src = mig.read_text()
|
||||||
assert m, "could not find the codec IN(...) list in migration 0077"
|
sets = {}
|
||||||
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
|
for name in ("HW_DECODABLE_CODECS", "CPU_DECODE_CODECS"):
|
||||||
assert codecs == {c.lower() for c in sync.HW_DECODABLE_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:
|
class _FakePlex:
|
||||||
@@ -182,22 +198,42 @@ def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
|
|||||||
codec_video="mpeg4",
|
codec_video="mpeg4",
|
||||||
transcode_hw=False,
|
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()
|
db.commit()
|
||||||
plex = _FakePlex(
|
plex = _FakePlex(
|
||||||
{
|
{
|
||||||
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
|
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
|
||||||
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
|
"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)
|
probed = sync._probe_media_batch(db, plex)
|
||||||
db.refresh(hevc8)
|
db.refresh(hevc8)
|
||||||
db.refresh(hevc10)
|
db.refresh(hevc10)
|
||||||
db.refresh(xvid)
|
db.refresh(xvid)
|
||||||
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
|
db.refresh(hdr10)
|
||||||
assert probed == 2
|
# 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 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
|
assert xvid.media_probed_at is None # not in the work-list
|
||||||
# A second run has nothing left to do (watermarks set).
|
# A second run has nothing left to do (watermarks set).
|
||||||
assert sync._probe_media_batch(db, plex) == 0
|
assert sync._probe_media_batch(db, plex) == 0
|
||||||
|
|||||||
@@ -284,6 +284,24 @@ class TestTranscodeCommand:
|
|||||||
)
|
)
|
||||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
|
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):
|
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 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.
|
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
|
||||||
@@ -365,19 +383,48 @@ class TestWantHw:
|
|||||||
is True
|
is True
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
|
def test_auto_falls_back_to_software_for_a_codec_neither_path_knows(self, monkeypatch):
|
||||||
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
# 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._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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert (
|
assert (
|
||||||
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||||
is False
|
is False
|
||||||
)
|
)
|
||||||
assert (
|
|
||||||
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
|
@pytest.mark.parametrize("codec", ["vc1", "mpeg4", "msmpeg4v3"])
|
||||||
is False
|
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):
|
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
|
# 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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
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
|
# 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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
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
|
# 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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
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
|
# 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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||||
self._policy(monkeypatch, "off")
|
self._policy(monkeypatch, "off")
|
||||||
@@ -460,7 +507,7 @@ class TestWantHw:
|
|||||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
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
|
# 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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
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):
|
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
|
# "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)
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
assert stream._select_encode(
|
assert stream._select_encode(
|
||||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
) == (True, False)
|
) == (True, False, False)
|
||||||
|
|
||||||
|
|
||||||
class TestColdStartWarmup:
|
class TestColdStartWarmup:
|
||||||
@@ -620,6 +667,30 @@ class TestTranscodeStartSession:
|
|||||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # no SW cap
|
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
|
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(
|
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
|
||||||
self, capturing_env, monkeypatch
|
self, capturing_env, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user