feat(plex): transcode playback mode — VAAPI/libx264 re-encode (E6 S1b)
Non-h264 items (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) now play by re-encoding the video to H.264 inside the existing HLS session, instead of the old HTTP 501. VAAPI hardware encode (h264_vaapi, full GPU decode→scale→encode pipeline) where the render node is present and the source is HW-decodable; libx264 software fallback otherwise (yuv420p 8-bit, -threads reserve-one-core). The re-encode mints a keyframe at every segment boundary (-force_key_frames) so the HLS muxer still cuts independent segments and seeking works; everything else — -copyts, the HLS tail, multi-audio renditions, the AAC audio path — is the same machinery as remux, so hls.js/subtitles/seeking are unchanged. The session route returns transcode/hw so the player can surface the active mode. pytest pins both command branches (software + VAAPI shape) and the HW-selection gate (render node present AND source HW-decodable). Browser-UAT'd on the XviD fixture (Winning London): 501→plays, real 528×384 H.264 frames, 0 dropped, seek lands clean; stats overlay Mode row reads "transcode".
This commit is contained in:
+125
-12
@@ -3,7 +3,10 @@
|
|||||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
||||||
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
|
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
|
||||||
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
|
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
|
||||||
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
|
when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode
|
||||||
|
to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with
|
||||||
|
software libx264 as the automatic fallback where the render node is absent or the source codec isn't
|
||||||
|
hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs.
|
||||||
|
|
||||||
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
|
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
|
||||||
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
|
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
|
||||||
@@ -27,6 +30,7 @@ from sqlalchemy.orm import Session as DbSession
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
from app.plex import paths
|
||||||
|
|
||||||
@@ -83,11 +87,20 @@ _sessions: dict[str, "HlsSession"] = {}
|
|||||||
|
|
||||||
class HlsSession:
|
class HlsSession:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
|
self,
|
||||||
|
key: str,
|
||||||
|
directory: Path,
|
||||||
|
proc: subprocess.Popen,
|
||||||
|
start_s: float,
|
||||||
|
entry: str,
|
||||||
|
transcode: bool = False,
|
||||||
|
hw: bool = False,
|
||||||
):
|
):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
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.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).
|
||||||
@@ -127,6 +140,80 @@ _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`:
|
||||||
|
# * 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.
|
||||||
|
# * 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
|
||||||
|
# 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
|
||||||
|
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
|
||||||
|
_VAAPI_DEVICE = "/dev/dri/renderD128"
|
||||||
|
# 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"}
|
||||||
|
|
||||||
|
|
||||||
|
def _hwaccel_in_opts(hw: bool) -> 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."""
|
||||||
|
if not hw:
|
||||||
|
return []
|
||||||
|
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||||
|
|
||||||
|
|
||||||
|
def _venc_opts(transcode: bool, hw: bool) -> list[str]:
|
||||||
|
"""The video-encoder args that replace `-c:v copy` for a transcode session (empty-of-meaning:
|
||||||
|
returns the copy args when not transcoding). A re-encode must MINT its own keyframes at the segment
|
||||||
|
boundaries — the copy path leaned on the source's keyframes, but a re-encoded stream has none the
|
||||||
|
HLS muxer can cut on unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments
|
||||||
|
aren't independent and seeking breaks."""
|
||||||
|
if not transcode:
|
||||||
|
return ["-c:v", "copy"]
|
||||||
|
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
|
||||||
|
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.
|
||||||
|
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
|
||||||
|
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264 veryfast/crf20).
|
||||||
|
# yuv420p forces 8-bit High (not High10) so any browser decodes it; -threads reserves a core so the
|
||||||
|
# CPU-bound encode can't starve request handling / playback on the shared box.
|
||||||
|
return [
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-threads",
|
||||||
|
str(encode_thread_cap()),
|
||||||
|
*force_kf,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_node_present() -> bool:
|
||||||
|
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
|
||||||
|
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
|
||||||
|
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob."""
|
||||||
|
return Path(_VAAPI_DEVICE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _want_hw(item: PlexItem, transcode: bool) -> bool:
|
||||||
|
"""Whether to use the VAAPI hardware encoder for this transcode session. True iff we're
|
||||||
|
transcoding AND the render node is present AND the iGPU can hardware-decode the source (else the
|
||||||
|
full-GPU pipeline can't feed itself → software libx264). (S1c layers an admin on/off/auto override.)"""
|
||||||
|
if not transcode or not _render_node_present():
|
||||||
|
return False
|
||||||
|
return (item.codec_video or "").lower() in _HW_DECODABLE
|
||||||
|
|
||||||
|
|
||||||
def _probe_audio_langs(src: Path) -> list[str]:
|
def _probe_audio_langs(src: Path) -> list[str]:
|
||||||
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
||||||
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
||||||
@@ -160,8 +247,13 @@ def _ffmpeg_cmd(
|
|||||||
audio_ord: int | None,
|
audio_ord: int | None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
audio_langs: list[str] | None = None,
|
audio_langs: list[str] | None = None,
|
||||||
|
transcode: bool = False,
|
||||||
|
hw: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
# 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.
|
||||||
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
|
||||||
# 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
|
||||||
@@ -182,7 +274,7 @@ def _ffmpeg_cmd(
|
|||||||
args += ["-map", "0:v:0"]
|
args += ["-map", "0:v:0"]
|
||||||
for i in range(len(audio_langs)):
|
for i in range(len(audio_langs)):
|
||||||
args += ["-map", f"{ai}:a:{i}?"]
|
args += ["-map", f"{ai}:a:{i}?"]
|
||||||
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
args += ["-sn", *_venc_opts(transcode, hw), "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||||
var = ["v:0,agroup:aud"]
|
var = ["v:0,agroup:aud"]
|
||||||
for i, lang in enumerate(audio_langs):
|
for i, lang in enumerate(audio_langs):
|
||||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||||
@@ -203,7 +295,7 @@ def _ffmpeg_cmd(
|
|||||||
else:
|
else:
|
||||||
ao = audio_ord if audio_ord is not None else 0
|
ao = audio_ord if audio_ord is not None else 0
|
||||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
||||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
args += [*_venc_opts(transcode, hw), "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||||
args += _HLS_TAIL + [
|
args += _HLS_TAIL + [
|
||||||
"-hls_segment_filename",
|
"-hls_segment_filename",
|
||||||
str(out_dir / "seg_%d.ts"),
|
str(out_dir / "seg_%d.ts"),
|
||||||
@@ -275,16 +367,20 @@ def start_session(
|
|||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
multi: bool = False,
|
multi: bool = False,
|
||||||
|
transcode: bool = False,
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
|
||||||
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
|
||||||
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
|
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
|
||||||
Returns None if the local file can't be read."""
|
HLS rendition so the client switches audio without a restart; otherwise a single audio track
|
||||||
|
(`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). Returns None if the
|
||||||
|
local file can't be read."""
|
||||||
src = paths.local_media_path(db, item.file_path)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
key = item.rating_key
|
key = item.rating_key
|
||||||
start_s = max(0.0, float(start_s))
|
start_s = max(0.0, float(start_s))
|
||||||
|
hw = _want_hw(item, transcode)
|
||||||
audio_langs = _probe_audio_langs(src) if multi else []
|
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
|
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -297,12 +393,27 @@ def start_session(
|
|||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
_ffmpeg_cmd(
|
_ffmpeg_cmd(
|
||||||
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
|
src,
|
||||||
|
start_s,
|
||||||
|
directory,
|
||||||
|
audio_ord,
|
||||||
|
aoff,
|
||||||
|
audio_langs if is_multi else None,
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
),
|
),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
|
s = HlsSession(
|
||||||
|
key,
|
||||||
|
directory,
|
||||||
|
proc,
|
||||||
|
start_s,
|
||||||
|
"master.m3u8" if is_multi else "index.m3u8",
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
|
)
|
||||||
_sessions[key] = s
|
_sessions[key] = s
|
||||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||||
# at least 1 so playback can't be capped to zero.
|
# at least 1 so playback can't be capped to zero.
|
||||||
@@ -329,12 +440,14 @@ def start_session(
|
|||||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
||||||
break
|
break
|
||||||
log.info(
|
log.info(
|
||||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
|
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
|
||||||
key,
|
key,
|
||||||
start_s,
|
start_s,
|
||||||
s.media_start_s,
|
s.media_start_s,
|
||||||
is_multi,
|
is_multi,
|
||||||
audio_ord,
|
audio_ord,
|
||||||
|
transcode,
|
||||||
|
hw,
|
||||||
)
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
@@ -532,15 +532,13 @@ def stream_session(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
|
||||||
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||||
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
|
||||||
|
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
transcode = it.playable == "transcode"
|
||||||
raise HTTPException(
|
|
||||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
|
||||||
)
|
|
||||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
||||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
@@ -551,15 +549,19 @@ def stream_session(
|
|||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
|
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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
|
# 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.
|
# 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.
|
||||||
return {
|
return {
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||||
|
"transcode": s.transcode,
|
||||||
|
"hw": s.hw,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
|
|||||||
second half of this file pins what keeps it off a LIVE session's segments."""
|
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -164,10 +165,90 @@ class _FakeProc:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeItem:
|
class _FakeItem:
|
||||||
def __init__(self, rating_key="12345", file_path="/media/movie.mkv"):
|
def __init__(self, rating_key="12345", file_path="/media/movie.mkv", codec_video="h264"):
|
||||||
self.rating_key = rating_key
|
self.rating_key = rating_key
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.playable = "remux"
|
self.playable = "remux"
|
||||||
|
self.codec_video = codec_video
|
||||||
|
|
||||||
|
|
||||||
|
def _venc(cmd):
|
||||||
|
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
|
||||||
|
return cmd[cmd.index("-c:v") + 1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscodeCommand:
|
||||||
|
"""The transcode video re-encode (playable='transcode'): the SAME HLS command as remux with the
|
||||||
|
video encoder swapped in for `-c:v copy`. The HW path can only be exercised on prod (VAAPI needs
|
||||||
|
Linux + Intel /dev/dri), so its SHAPE is pinned here; the software path is the one UAT'd locally."""
|
||||||
|
|
||||||
|
SRC = Path("/media/movie.mkv")
|
||||||
|
OUT = Path("/out")
|
||||||
|
|
||||||
|
def test_remux_still_stream_copies_the_video(self):
|
||||||
|
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None)
|
||||||
|
assert _venc(cmd) == "copy"
|
||||||
|
assert "libx264" not in cmd and "h264_vaapi" not in cmd
|
||||||
|
assert "-hwaccel" not in cmd # no GPU decode on the copy path
|
||||||
|
assert "-force_key_frames" not in cmd # copy leans on the source's own keyframes
|
||||||
|
|
||||||
|
def test_software_transcode_uses_libx264_and_mints_keyframes(self):
|
||||||
|
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=False)
|
||||||
|
assert _venc(cmd) == "libx264"
|
||||||
|
assert "copy" not in cmd # the video is no longer stream-copied
|
||||||
|
# 8-bit High (yuv420p), not High10, so any browser decodes a 10-bit HEVC source.
|
||||||
|
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
|
||||||
|
assert "-threads" in cmd # a core reserved for request handling / playback
|
||||||
|
assert "-hwaccel" not in cmd # software path: CPU decode, no render node needed
|
||||||
|
# A forced keyframe at every segment boundary so the HLS muxer cuts independent segments.
|
||||||
|
assert (
|
||||||
|
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hardware_transcode_uses_the_full_vaapi_pipeline(self):
|
||||||
|
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=True)
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "libx264" not in cmd
|
||||||
|
# GPU decode options must precede the input so the source is decoded on-device.
|
||||||
|
assert cmd[cmd.index("-hwaccel") + 1] == "vaapi"
|
||||||
|
assert "-hwaccel_output_format" in cmd
|
||||||
|
assert cmd[cmd.index("-vaapi_device") + 1] == stream._VAAPI_DEVICE
|
||||||
|
assert cmd.index("-hwaccel") < cmd.index("-i")
|
||||||
|
# scale_vaapi=format=nv12 downconverts a 10-bit source to 8-bit nv12 for h264_vaapi.
|
||||||
|
assert "scale_vaapi=format=nv12" in cmd
|
||||||
|
assert (
|
||||||
|
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multi_audio_transcode_reencodes_the_video_variant(self):
|
||||||
|
cmd = stream._ffmpeg_cmd(
|
||||||
|
self.SRC, 0.0, self.OUT, None, audio_langs=["eng", "hun"], transcode=True, hw=False
|
||||||
|
)
|
||||||
|
assert _venc(cmd) == "libx264" # video re-encoded…
|
||||||
|
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
|
||||||
|
assert "copy" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestWantHw:
|
||||||
|
"""VAAPI is used only where the render node was passed in AND the source is HW-decodable."""
|
||||||
|
|
||||||
|
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=False) is False
|
||||||
|
|
||||||
|
def test_no_hw_without_a_render_node(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||||
|
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=True) is False
|
||||||
|
|
||||||
|
def test_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._want_hw(_FakeItem(codec_video="hevc"), transcode=True) is True
|
||||||
|
|
||||||
|
def test_software_for_a_non_decodable_source_even_with_the_render_node(self, monkeypatch):
|
||||||
|
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._want_hw(_FakeItem(codec_video="av1"), transcode=True) is False
|
||||||
|
assert stream._want_hw(_FakeItem(codec_video="mpeg4"), transcode=True) is False
|
||||||
|
|
||||||
|
|
||||||
class TestColdStartWarmup:
|
class TestColdStartWarmup:
|
||||||
|
|||||||
Reference in New Issue
Block a user