diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 17 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 6 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hwaccel.py | 368 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 89 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_hwaccel.py | 171 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_stream_video_transcode.py | 48 |
6 files changed, 677 insertions, 22 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 7351b1c..086d800 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -72,6 +72,15 @@ max_concurrent_uploads = 8 # Set to false only if every viewer's client is known to decode HEVC itself. transcode_incompatible_video = true +# Use the GPU for that re-encode when there is one that works — VA-API here, +# Quick Sync or NVENC on Windows. Established by encoding 1080p and checking +# the result, never guessed from the hardware, and ignored where the test +# fails: this is on by default and the only reason to turn it off is a driver +# that misbehaves in a way the test does not catch. On an Intel mini-PC it is +# the difference between transcoding in real time and having to set +# transcode_incompatible_video = false. +hardware_video_encode = true + # ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale, # libvirt, Docker) are auto-excluded — a STUN request that can't reach the # server holds the WebRTC answer for 5 seconds. Set this to restrict @@ -180,6 +189,12 @@ class NodeConfig: # that already decode the source codec directly, since transcoding costs # real CPU per concurrent viewer, unlike the copy path. transcode_incompatible_video: bool = True + # Whether that re-encode may run on the GPU. See hwaccel.py: the capability + # is established by encoding and reading the result back, so `true` here + # means "use it if it works", never "assume it does". Off returns the node + # to libx264 on every stream, which is where it was before hardware + # encoding existed. + hardware_video_encode: bool = True # ICE candidate gathering: which network interfaces to include or exclude. # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are # auto-excluded because a STUN request that can't reach the server holds @@ -355,6 +370,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.node.max_concurrent_uploads, "max_concurrent_uploads") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) + cfg.node.hardware_video_encode = bool( + nd.get("hardware_video_encode", cfg.node.hardware_video_encode)) ice_if = nd.get("ice_interfaces") if isinstance(ice_if, list): cfg.node.ice_interfaces = [str(s) for s in ice_if] diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 666a5cc..fa9ef81 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -510,6 +510,12 @@ class NodeDaemon: denylist = self._denylist # 6. WebRTC transport (browser clients) + # + # The operator's answer about the GPU, before the first stream asks + # the question. The probe itself is lazy — it costs a test encode, + # and a node that never serves a video should never pay it. + from meshbay_node import hwaccel + hwaccel.set_enabled(self._config.node.hardware_video_encode) from meshbay_node.transport.ice_filter import install as install_ice_filter install_ice_filter( self._config.node.ice_interfaces or None, diff --git a/packages/meshbay-node/src/meshbay_node/hwaccel.py b/packages/meshbay-node/src/meshbay_node/hwaccel.py new file mode 100644 index 0000000..051f64c --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/hwaccel.py @@ -0,0 +1,368 @@ +""" +Hardware H.264 encoding for the streaming re-encode path. + +Streaming copies the video whenever a browser can decode it (`-c:v copy`), and +that path costs nothing. The re-encode path is the expensive one — HEVC, and +the codecs with no MSE string at all (Xvid, MPEG-2, VC-1) — and it is +`libx264 -preset veryfast`, one process per viewer, for the length of a film. +On a laptop that is fine. On an Atom or Celeron mini-PC it does not reach real +time at 1080p, which is why `transcode_incompatible_video` exists as an +operator opt-out: a node that cannot transcode says so instead of serving a +stream that stutters. + +**This module is the other answer to the same problem.** The GPU in every +machine of the last decade encodes H.264 in fixed-function silicon and does not +care how weak the CPU beside it is. Where that hardware is there and works, the +node re-encodes on it and the opt-out is not needed. + +Nothing here is configured by hand, and nothing is inferred from a CPU model or +from a driver's name. **The capability is established by encoding**: a listed +`h264_qsv` proves only that ffmpeg was built with it, and a render node proves +only that a GPU exists — neither says the driver on this machine can do the +work. Measured on the development VM, where `ffmpeg -encoders` lists every +VAAPI encoder and the virtio-gpu driver then fails to initialise: +`libva: virtio_gpu_drv_video.so init failed`. + +**The probe encodes 1080p and then reads the result back with ffprobe**, and +accepts nothing that is not High profile at level 4.1. That is not +belt-and-braces: `stream_init` announces `avc1.640029` and the client checks it +before it trusts a byte, so an encoder that quietly wrote a different level +would make the node a liar. It also means an argument spelled the way one +encoder wants and not another — `-level 4.1` against `-level 41` — is caught +here rather than by a viewer, which is why each candidate below may offer +several variants and the machine picks. + +Candidates, in the order they are tried: + + vaapi Linux, any GPU with a VA-API driver — Intel iGPU, AMD through mesa. + qsv Intel Quick Sync, which is how the same iGPU is reached on Windows. + nvenc NVIDIA, either platform. + +Two gaps, named rather than left to be discovered: **AMD on Windows** (AMF) and +**macOS** (VideoToolbox). Neither set of arguments could be tried anywhere in +this project, and MeshBay ships no macOS package at all; a node on either +re-encodes in software, exactly as every node did before this module existed. +Adding one is a `Candidate` and nothing else — the probe is what decides +whether it works, so a wrong guess costs a rejected candidate, not a broken +stream. + +Then three modes per stream, remembered per source codec: + + hw Hardware decode and encode. The whole pipeline on the GPU, which is + what makes 1080p HEVC playable on a machine that cannot decode it at + all in software. + hwenc Software decode, hardware encode. The fallback for a source the GPU + has no decoder for — iHD has none for MPEG-4 Part 2, so an Xvid .avi + lands here, and an SD Xvid decodes in software for nearly nothing. + sw libx264, the path that was always here. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +import sys +import tempfile +from dataclasses import dataclass + +from meshbay_node import platform + +log = logging.getLogger(__name__) + +HW = "hw" +HWENC = "hwenc" +SW = "sw" + +# How long one test encode may take before it is considered broken. A driver +# that deadlocks must not hold the first viewer's stream, and 20 s is far +# beyond the fraction of a second the probe needs when it works. +PROBE_TIMEOUT_SECS = 20 + +# What the probe encodes. 1080p because the level a film gets is the level that +# has to be checked: an encoder asked for 4.1 on a 320x240 clip may well write +# a lower one, and rejecting a good candidate for that would be the probe +# failing rather than the hardware. +PROBE_SIZE = "1920x1080" + +# The announced codec string is `avc1.640029` — High (profile_idc 100, no +# constraint flags) at level 4.1 (0x29) — and every encoder here is held to it. +_PROFILE_ARGS = ["-profile:v", "high", "-level", "4.1"] +_WANT_PROFILE = "High" +_WANT_LEVEL = 41 + +# -pix_fmt yuv420p: a 10-bit or 4:4:4 HEVC source (common for HDR WEB-DLs) +# fails "-profile:v high" outright otherwise — libx264's High profile is 8-bit +# 4:2:0 only. Downsampling loses nothing a browser could show anyway (MSE has +# no HDR path), which is also what each hardware filter below does on the GPU. +_SOFTWARE_ARGS = ["-c:v", "libx264", "-pix_fmt", "yuv420p", *_PROFILE_ARGS, + "-preset", "veryfast", "-crf", "21"] + + +@dataclass(frozen=True) +class Candidate: + """One way a machine might encode H.264 without its CPU.""" + + name: str + encoder: str + platforms: tuple[str, ...] + # `-hwaccel <this>`, and the pixel format decoded frames stay in. + hwaccel: str + # Filters for frames already on the GPU, and for frames in system memory. + hw_filter: str + sw_filter: str + # Rate control and anything else, tried in order until one produces High + # at level 4.1. A later `-level` overrides the shared one, which is how a + # spelling one encoder rejects is offered without being imposed. + variants: tuple[tuple[str, tuple[str, ...]], ...] + # VA-API alone names a device; QSV and NVENC find their own. + needs_render_node: bool = False + + +CANDIDATES: tuple[Candidate, ...] = ( + Candidate( + name="vaapi", encoder="h264_vaapi", platforms=("linux",), + hwaccel="vaapi", + hw_filter="scale_vaapi=format=nv12", sw_filter="format=nv12,hwupload", + needs_render_node=True, + # CQP is the constant-quality mode every VA-API driver implements (iHD + # and i965 alike); ICQ and QVBR are not always there. Some Intel + # generations implement H.264 encoding only on the low-power (VDEnc) + # path and others only on the full one, which is a property of the chip + # and the driver — so it is measured rather than looked up. + variants=( + ("standard", ("-rc_mode", "CQP", "-qp", "23")), + ("low-power", ("-rc_mode", "CQP", "-qp", "23", "-low_power", "1")), + ), + ), + Candidate( + name="qsv", encoder="h264_qsv", platforms=("win32", "linux"), + hwaccel="qsv", + hw_filter="vpp_qsv=format=nv12", sw_filter="format=nv12", + # `-global_quality` is QSV's constant-quality knob. The second variant + # exists because h264_qsv has no `level` option of its own and takes + # the generic integer one, which may or may not read "4.1" as 41 — + # ffmpeg accepts both spellings without complaint, and only the encoded + # file says which one was understood. + variants=( + ("icq", ("-global_quality", "23")), + ("icq, integer level", ("-global_quality", "23", "-level", "41")), + ), + ), + Candidate( + name="nvenc", encoder="h264_nvenc", platforms=("win32", "linux"), + hwaccel="cuda", + hw_filter="scale_cuda=format=nv12", sw_filter="format=nv12", + variants=( + ("vbr", ("-rc", "vbr", "-cq", "23", "-b:v", "0")), + ), + ), +) + + +@dataclass(frozen=True) +class Encoder: + """A hardware encoder that has been seen to produce what the node claims.""" + + candidate: Candidate + variant: str + extra: tuple[str, ...] + device: str | None = None + + +_enabled = os.environ.get("MESHBAY_HW_VIDEO_ENCODE", "1") not in ("0", "false", "no") +_encoder: Encoder | None = None +_probed = False +_probe_lock: asyncio.Lock | None = None +# (source codec, mode) pairs that have failed once and are not tried again. +_demoted: set[tuple[str, str]] = set() + + +def set_enabled(enabled: bool) -> None: + """Operator switch (`node.toml`, `hardware_video_encode`).""" + global _enabled + _enabled = enabled + + +def render_node() -> str | None: + """The first DRM render node this process can actually open. + + Readable *and* writable, because VA-API maps buffers on it. On a desktop + session logind grants that through an ACL rather than through membership of + the `render` group, so asking the kernel answers "can this process use it" + — which is the question — rather than "is this user in a group", which is + not. + """ + try: + names = sorted(os.listdir("/dev/dri")) + except OSError: + return None + for name in names: + if not name.startswith("renderD"): + continue + dev = f"/dev/dri/{name}" + if os.access(dev, os.R_OK | os.W_OK): + return dev + return None + + +async def _run(args: list[str], timeout: int) -> tuple[int | None, str]: + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout) + except TimeoutError: + proc.kill() + await proc.wait() + return None, "timed out" + text = (out or b"").decode("utf-8", "replace") or \ + (err or b"").decode("utf-8", "replace") + return proc.returncode, text.strip() + + +async def _test_encode(cand: Candidate, device: str | None, + extra: tuple[str, ...]) -> str | None: + """Encode 1080p and read the result back. Returns why it was rejected. + + `None` means it worked — the encoder exists, the driver initialises, and + the file it wrote really is High at level 4.1. + """ + ffmpeg = platform.ffmpeg_cmd() + if not (os.path.isabs(ffmpeg) or shutil.which(ffmpeg)): + return "ffmpeg not found" + fd, out = tempfile.mkstemp(suffix=".mp4", prefix="meshbay-hwprobe-") + os.close(fd) + try: + rc, err = await _run([ + ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + *device_args(cand, device), + "-f", "lavfi", "-i", f"testsrc=size={PROBE_SIZE}:rate=25:duration=0.2", + "-vf", cand.sw_filter, + "-c:v", cand.encoder, *_PROFILE_ARGS, *extra, + out, + ], PROBE_TIMEOUT_SECS) + if rc != 0: + # The *first* line: ffmpeg's last word is usually "nothing was + # written into the output file", which is the consequence. The + # cause is at the top — "Cannot load libcuda.so.1", "Error + # creating a MFX session", "libva ... init failed". + return (err.splitlines() or ["no output"])[0] + rc, info = await _run([ + platform.ffprobe_cmd(), "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=profile,level", "-of", "csv=p=0", out, + ], PROBE_TIMEOUT_SECS) + profile, _, level = info.partition(",") + if rc != 0 or profile.strip() != _WANT_PROFILE or level.strip() != str(_WANT_LEVEL): + return (f"produced {info or 'nothing'}, and the node announces " + f"{_WANT_PROFILE} at level {_WANT_LEVEL}") + return None + finally: + try: + os.unlink(out) + except OSError: + pass + + +async def encoder() -> Encoder | None: + """The working hardware encoder, established once per process.""" + global _encoder, _probed, _probe_lock + if not _enabled: + return None + if _probed: + return _encoder + if _probe_lock is None: + _probe_lock = asyncio.Lock() + async with _probe_lock: + if _probed: + return _encoder + _encoder = await _find_encoder() + _probed = True + return _encoder + + +async def _find_encoder() -> Encoder | None: + device = render_node() + for cand in CANDIDATES: + if sys.platform not in cand.platforms: + continue + if cand.needs_render_node and device is None: + continue + for variant, extra in cand.variants: + why = await _test_encode(cand, device, extra) + if why is None: + log.info("hwaccel: %s (%s) — video re-encoding runs on the GPU", + cand.encoder, variant) + return Encoder(candidate=cand, variant=variant, extra=extra, + device=device if cand.needs_render_node else None) + log.debug("hwaccel: %s (%s) rejected: %s", cand.encoder, variant, why) + log.info("hwaccel: no hardware H264 encoder works here — encoding in software") + return None + + +async def modes_for(source_codec: str | None) -> list[str]: + """Which re-encode modes to try for this source, best first. + + Always ends in `SW`: libx264 is the one that needs no hardware, so a plan + that ran out of hardware modes is still a plan that plays the film. + """ + enc = await encoder() + if enc is None: + return [SW] + key = source_codec or "?" + plan = [m for m in (HW, HWENC) if (key, m) not in _demoted] + plan.append(SW) + return plan + + +def demote(source_codec: str | None, mode: str, detail: str) -> None: + """This mode does not work for this source codec; stop trying it.""" + if mode == SW: + return + key = source_codec or "?" + if (key, mode) not in _demoted: + _demoted.add((key, mode)) + log.info("hwaccel: %s re-encoding does not work for %s on this machine " + "(%s) — not trying it again", mode, key, detail) + + +def device_args(cand: Candidate, device: str | None) -> list[str]: + """How this candidate is told which GPU to use, where it needs telling.""" + if cand.needs_render_node and device: + return ["-vaapi_device", device] + return [] + + +def input_args(mode: str, enc: Encoder | None) -> list[str]: + """ffmpeg options that must precede `-i`.""" + if enc is None or mode == SW: + return [] + cand = enc.candidate + if mode == HWENC: + return device_args(cand, enc.device) + # `-hwaccel_output_format` keeps decoded frames on the GPU rather than + # reading them back to system memory, which is the whole saving: a readback + # of every 1080p frame costs more on a weak machine than the encode it + # feeds. + args = ["-hwaccel", cand.hwaccel] + if enc.device: + args += ["-hwaccel_device", enc.device] + return args + ["-hwaccel_output_format", cand.hwaccel] + + +def codec_args(mode: str, enc: Encoder | None) -> list[str]: + """The `-vf`/`-c:v` half, where libx264's arguments used to be written.""" + if enc is None or mode == SW: + return list(_SOFTWARE_ARGS) + cand = enc.candidate + # The hardware filter converts a 10-bit HDR source to the 8-bit NV12 the + # encoder takes, on the GPU — the same downsampling the software path does + # with `-pix_fmt yuv420p`, and for the same reason. + filters = cand.hw_filter if mode == HW else cand.sw_filter + return ["-vf", filters, "-c:v", cand.encoder, *_PROFILE_ARGS, *enc.extra] + + +def _reset_for_tests() -> None: + global _encoder, _probed, _probe_lock + _encoder, _probed, _probe_lock = None, False, None + _demoted.clear() diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 540c174..d79674d 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -126,7 +126,7 @@ from meshbay_node.chat import FORMAT_SEALED_V1, ReplayedMessage from meshbay_node.transport.wire import index_sync_message from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer -from meshbay_node import linkpreview, ops, platform +from meshbay_node import hwaccel, linkpreview, ops, platform from meshbay_node import transfers as transfers_mod from meshbay_node import uploads as uploads_mod from meshbay_node.transfers import TransferSlots @@ -6265,20 +6265,33 @@ class WebRTCPeerSession: if transcode_video: log.info("stream: re-encoding %s (%s) to H264", entry.name, raw_video_codec) - # -pix_fmt yuv420p: a 10-bit or 4:4:4 HEVC source (common for HDR - # WEB-DLs) fails "-profile:v high" outright otherwise — libx264's - # High profile is 8-bit 4:2:0 only. Downsampling loses nothing a - # browser could show anyway (MSE/HTML5 video has no HDR path). - codec_args = ["-c:v", "libx264", "-pix_fmt", "yuv420p", - "-profile:v", "high", "-level", "4.1", - "-preset", "veryfast", "-crf", "21"] + # Where the re-encode runs, and with which arguments — both live + # in hwaccel.py now, including the 8-bit downsampling a 10-bit HDR + # source needs before either encoder will take it. `modes_for` has + # measured this machine by encoding on it and returns the ladder to + # try, always ending in libx264: a node with no usable VA-API does + # exactly what it did before this existed, and a Celeron with an + # iGPU stops being a machine where `transcode_incompatible_video` + # has to be turned off to keep streaming watchable. + modes = await hwaccel.modes_for(raw_video_codec) + hw = await hwaccel.encoder() # Must match "-profile:v high -level 4.1" byte-for-byte (avc1.<profile # hex><constraint><level hex>) — the client checks this string with # MediaSource.isTypeSupported before trusting a single byte of the - # stream, so a mismatch here fails exactly the check this exists to pass. + # stream, so a mismatch here fails exactly the check this exists to + # pass. Both encoders get those two arguments, spelled the same way, + # from hwaccel._PROFILE_ARGS — one place, so they cannot drift. codec_str = "avc1.640029,mp4a.40.2" if has_audio else "avc1.640029" else: - codec_args = ["-c:v", "copy"] + modes, hw = [hwaccel.SW], None + + def video_args(mode: str) -> list[str]: + return (hwaccel.codec_args(mode, hw) if transcode_video + else ["-c:v", "copy"]) + + # The audio half does not change with the video encoder, and is never a + # copy — see _probe_video for why. + audio_args: list[str] = [] # Which audio track. A dubbed film carries several and the first one is # not a neutral default — it is whatever the person who muxed the file # happened to put first, which across a real library is overwhelmingly @@ -6302,7 +6315,7 @@ class WebRTCPeerSession: # appended — isTypeSupported() only checks the codec string, so # the failure doesn't surface until playback, as a SourceBuffer # forced out of its MediaSource with no further explanation. - codec_args += ["-c:a", "aac", "-ac", "2", "-b:a", "192k"] + audio_args = ["-c:a", "aac", "-ac", "2", "-b:a", "192k"] # Where that seek lands, measured with the mapping this stream will # use. It has to be here rather than beside `seek_args` above: the # landing point depends on which audio track is mapped, because the @@ -6316,16 +6329,45 @@ class WebRTCPeerSession: landed = await _seek_lands_at(file_path, requested, map_args) if landed is not None: start = landed - proc = await asyncio.create_subprocess_exec( - platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", - *seek_args, - "-i", str(file_path), - *map_args, - *codec_args, - "-movflags", "frag_keyframe+empty_moov+default_base_moof", - "-f", "mp4", "pipe:1", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - ) + # One spawn per mode, and only ever more than one when hwaccel.py found + # a working GPU. **What a mode is tried against is the file itself**: + # a test encode proves the encoder, and nothing proves the GPU can + # decode *this* source until it is asked to — iHD has no MPEG-4 Part 2 + # decoder at all, so an Xvid .avi fails the full-hardware mode and + # nothing about the machine could have predicted it. + # + # The failure is silent and instant: ffmpeg writes its complaint to + # stderr and exits, so stdout reaches EOF with nothing on it. That is + # the signal read here, before `stream_init` is sent and therefore + # before the client has been told anything it would have to be told + # again. The first segment is kept and handed to the loop below rather + # than re-read, since the process it came from is still running. + # + # The last mode is spawned and trusted, which is what keeps the + # single-mode path — every node without a GPU, and every copied stream + # — byte-for-byte what it was: no extra read, no extra wait. + first_segment = b"" + for attempt, mode in enumerate(modes): + proc = await asyncio.create_subprocess_exec( + platform.ffmpeg_cmd(), "-hide_banner", "-loglevel", "error", + *hwaccel.input_args(mode, hw), + *seek_args, + "-i", str(file_path), + *map_args, + *video_args(mode), *audio_args, + "-movflags", "frag_keyframe+empty_moov+default_base_moof", + "-f", "mp4", "pipe:1", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + if attempt == len(modes) - 1: + break + first_segment = await proc.stdout.read(STREAM_SEGMENT_SIZE) + if first_segment: + break + err = (await proc.stderr.read()).decode("utf-8", "replace").strip() + await proc.wait() + hwaccel.demote(raw_video_codec, mode, + err.splitlines()[0] if err else "no output") self._send({ "type": MNP.STREAM_INIT, @@ -6404,7 +6446,10 @@ class WebRTCPeerSession: log.info("Stream stopped by peer=%s after %d segments", (self._user_id or "?")[:8], index) break - data = await proc.stdout.read(STREAM_SEGMENT_SIZE) + if first_segment: + data, first_segment = first_segment, b"" + else: + data = await proc.stdout.read(STREAM_SEGMENT_SIZE) if not data: break # Same derivation as a file chunk, indexed by segment: one diff --git a/packages/meshbay-node/tests/test_hwaccel.py b/packages/meshbay-node/tests/test_hwaccel.py new file mode 100644 index 0000000..a79d784 --- /dev/null +++ b/packages/meshbay-node/tests/test_hwaccel.py @@ -0,0 +1,171 @@ +""" +The GPU re-encoding plan, and the one thing that must stay true across both +encoders. + +`hwaccel.py` exists so that a low-power node can re-encode a browser-hostile +video at all — see its module docstring. What is tested here is the part that +holds whether or not the machine running the tests has a GPU: the shape of the +plan, what a demotion does, and the agreement between the codec string the node +announces and the arguments each encoder is actually given. + +The end-to-end fallback — a hardware mode that fails, the stream playing anyway +— is in `test_stream_video_transcode.py`, because it needs a real ffmpeg and a +real file. +""" + +import re +import sys +from pathlib import Path + +import pytest +from meshbay_node import hwaccel +from meshbay_node.transport import webrtc_server + +from conftest import needs_subprocess + +VAAPI = next(c for c in hwaccel.CANDIDATES if c.name == "vaapi") +FAKE = hwaccel.Encoder(candidate=VAAPI, variant="standard", extra=(), + device="/dev/dri/renderD128") + + +@pytest.fixture(autouse=True) +def _clean(): + hwaccel._reset_for_tests() + yield + hwaccel._reset_for_tests() + hwaccel.set_enabled(True) + + +async def test_no_working_encoder_means_the_software_path_and_nothing_else(monkeypatch): + """ + The guarantee this module owes every node that has no GPU, no driver, or a + driver that fails its test encode: one mode, the one that was always there. + A second mode would mean a second ffmpeg spawn and a second wait on a + machine that can never benefit from either. + """ + monkeypatch.setattr(hwaccel, "encoder", _fixed(None)) + + assert await hwaccel.modes_for("hevc") == [hwaccel.SW] + + +async def test_the_plan_always_ends_in_software(monkeypatch): + monkeypatch.setattr(hwaccel, "encoder", _fixed(FAKE)) + + assert await hwaccel.modes_for("hevc") == [hwaccel.HW, hwaccel.HWENC, hwaccel.SW] + + +async def test_a_demotion_is_remembered_for_that_source_codec_only(monkeypatch): + """ + iHD decodes HEVC and has no MPEG-4 Part 2 decoder at all, so "the GPU + cannot do this one" is a fact about the pair, not about the machine. + Demoting the machine would cost every HEVC film the acceleration that one + Xvid file proved was unavailable to *it*. + """ + monkeypatch.setattr(hwaccel, "encoder", _fixed(FAKE)) + hwaccel.demote("mpeg4", hwaccel.HW, "no decoder") + + assert await hwaccel.modes_for("mpeg4") == [hwaccel.HWENC, hwaccel.SW] + assert await hwaccel.modes_for("hevc") == [hwaccel.HW, hwaccel.HWENC, hwaccel.SW] + + +async def test_the_operator_switch_turns_off_the_probe_itself(monkeypatch): + """`hardware_video_encode = false` must cost no subprocess, not merely go + unused: the probe is a test encode, and a node whose operator said no + should never spawn it.""" + spawned = [] + monkeypatch.setattr(hwaccel, "_test_encode", + lambda *a, **k: spawned.append(a) or None) + hwaccel.set_enabled(False) + + assert await hwaccel.encoder() is None + assert await hwaccel.modes_for("hevc") == [hwaccel.SW] + assert spawned == [] + + +def test_every_encoder_produces_what_the_node_announces(): + """ + `stream_init` carries `avc1.640029` and the client puts it through + MediaSource.isTypeSupported before it trusts a byte of the stream. The + string is not a label chosen next to libx264 — it is a claim about the + bytes, and it has to stay true for whichever encoder produced them. + + So the claim is read out of the streaming code and decoded (profile_idc + 0x64 = 100 = High, level_idc 0x29 = 41 = 4.1) rather than restated here, + and every mode is checked against it. Changing the arguments of one encoder + without the other, or changing either without the announced string, fails. + """ + source = Path(webrtc_server.__file__).read_text(encoding="utf-8") + announced = set(re.findall(r"avc1\.([0-9a-f]{6})", source)) + assert announced == {"640029"}, ( + "the streaming path announces a codec string this test does not know " + f"how to check: {announced}") + + (code,) = announced + profile_idc, constraints, level_idc = (int(code[i:i + 2], 16) for i in (0, 2, 4)) + assert (profile_idc, constraints, level_idc) == (100, 0, 41), ( + "the announced string no longer says High/4.1, so the arguments below " + "are no longer what it claims") + + for mode in (hwaccel.SW, hwaccel.HW, hwaccel.HWENC): + args = hwaccel.codec_args(mode, FAKE) + assert args[args.index("-profile:v") + 1] == "high", \ + f"{mode} does not encode the High profile the node announces" + assert args[args.index("-level") + 1] == "4.1", \ + f"{mode} does not encode at the level the node announces" + + +def test_the_software_mode_never_touches_the_gpu(): + """The fallback has to be a real fallback: if `sw` carried a VA-API + argument, the mode that exists for machines where the GPU failed would + fail with it.""" + for args in (hwaccel.input_args(hwaccel.SW, FAKE), + hwaccel.codec_args(hwaccel.SW, FAKE)): + joined = " ".join(args) + assert "vaapi" not in joined and "hwupload" not in joined, joined + assert hwaccel.input_args(hwaccel.HW, None) == [] + assert "libx264" in hwaccel.codec_args(hwaccel.HW, None) + + +def _fixed(value): + async def _f(): + return value + return _f + + +@needs_subprocess +async def test_the_probe_accepts_only_what_the_node_announces(): + """The acceptance test is neither always true nor always false. + + Both halves are run with libx264, which is on any machine that can run + these tests, so what is being checked is the judgement itself rather than + anybody's GPU: the same encoder is accepted when it writes High at level + 4.1 and rejected when it writes level 3.1. Without this, an encoder whose + arguments were spelled in a way ffmpeg accepts but misreads would pass the + probe and then announce a level it had not produced. + """ + def _software(label: str, extra: tuple[str, ...]) -> hwaccel.Candidate: + return hwaccel.Candidate( + name=label, encoder="libx264", platforms=(sys.platform,), + hwaccel="none", hw_filter="null", sw_filter="format=yuv420p", + variants=((label, extra),)) + + right = _software("as announced", ("-preset", "veryfast", "-crf", "30")) + assert await hwaccel._test_encode(right, None, right.variants[0][1]) is None + + wrong = _software("a level lower", + ("-preset", "veryfast", "-crf", "30", "-level", "3.1")) + why = await hwaccel._test_encode(wrong, None, wrong.variants[0][1]) + assert why and "31" in why, ( + f"an encoder that wrote another level must be rejected, and say so: {why!r}") + + +@needs_subprocess +async def test_a_missing_encoder_is_rejected_rather_than_raised(): + """ffmpeg exits non-zero for an encoder it does not have; nothing here may + turn that into an exception, because it happens on every machine that lacks + one of the three candidates — which is most of them.""" + absent = hwaccel.Candidate( + name="not a thing", encoder="h264_definitely_not", platforms=(sys.platform,), + hwaccel="none", hw_filter="null", sw_filter="null", variants=(("x", ()),)) + + assert await hwaccel._test_encode(absent, None, ()) is not None diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py index b165af4..0fd12c6 100644 --- a/packages/meshbay-node/tests/test_stream_video_transcode.py +++ b/packages/meshbay-node/tests/test_stream_video_transcode.py @@ -35,6 +35,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_common.crypto import generate_gek from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes +from meshbay_node import hwaccel from meshbay_node.indexer.group_index import GroupIndex from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video @@ -271,3 +272,50 @@ async def test_the_operator_can_refuse_the_re_encode_and_says_so(tmp_path): f"the refusal must point at the setting that caused it: {detail!r}") assert not [m for m in session.sent if m.get("type") == "stream_init"], ( "a stream_init went out with no codec string the client could check") + + +@needs_mp3 +async def test_a_hardware_encoder_that_does_not_work_costs_the_viewer_nothing( + tmp_path, monkeypatch): + """A VA-API mode that fails falls through to libx264 and the film plays. + + This is the failure that cannot be prevented by probing: `hwaccel.py` + proves the *encoder* with a test encode at startup, and nothing proves the + GPU can decode a particular source until it is asked to — iHD has no + MPEG-4 Part 2 decoder at all, which is exactly the file used here. + + The encoder is faked onto `/dev/null` rather than skipped for want of a + GPU, so this runs identically on a machine with one and on a machine + without: both hardware modes fail instantly, and what is tested is that + the viewer sees one working stream and not an error. The demotions are + checked too — a library of Xvid files must not pay for the first one's two + dead spawns again and again. + """ + clip = tmp_path / "clip.avi" + _make_mpeg4_clip(clip) + gek = generate_gek() + session, file_id = _session(clip, gek) + + hwaccel._reset_for_tests() + + async def _broken_encoder(): + vaapi = next(c for c in hwaccel.CANDIDATES if c.name == "vaapi") + return hwaccel.Encoder(candidate=vaapi, variant="standard", extra=(), + device="/dev/null") + + monkeypatch.setattr(hwaccel, "encoder", _broken_encoder) + try: + await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0}) + + assert not [m for m in session.sent if m.get("type") == "error"], ( + "a hardware encoder that does not work must cost the viewer nothing") + remuxed = _reassemble(session.sent, gek, file_id) + out_path = tmp_path / "out.mp4" + out_path.write_bytes(remuxed) + assert _output_video_codec(out_path) == "h264", \ + "the software fallback must still deliver a playable H264 stream" + + assert ("mpeg4", hwaccel.HW) in hwaccel._demoted + assert ("mpeg4", hwaccel.HWENC) in hwaccel._demoted + finally: + hwaccel._reset_for_tests() |