diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-18 14:37:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-18 14:37:08 +0200 |
| commit | d22ce0ba5aebfff5ffaea97982472f876af5da22 (patch) | |
| tree | 073b7ad96bc58aa834cd8f781d81915fdaa6e338 /packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | |
| parent | 0d56cd33b975afe7648dd6f6380a30a7b5230c90 (diff) | |
| download | meshbay-d22ce0ba5aebfff5ffaea97982472f876af5da22.tar.gz | |
feat: decode and re-encode video on the GPU where there is one
A 1080p film is decoded by whoever watches it and re-encoded by the node
when no browser can decode the source. Both were on the CPU, and on an
Atom or Celeron mini-PC neither reaches real time — which is what
`transcode_incompatible_video` exists to refuse. This adds the mechanism
that makes refusing it unnecessary.
Node — `hwaccel.py`: VA-API on Linux, Quick Sync or NVENC on Windows,
established by encoding 1080p and reading the file back with ffprobe.
Nothing is accepted that does not produce the exact profile and level
`stream_init` announces, since the client checks that string before it
trusts a byte: an encoder that wrote another level would make the node's
own codec string a lie, and ffmpeg takes `-level 4.1` and `-level 41`
from h264_qsv without saying which it understood. Three modes per
stream — hardware decode and encode, hardware encode alone, libx264 —
demoted per source codec, because a GPU that decodes HEVC may have no
decoder for MPEG-4 Part 2 and only asking it finds out. A mode that
fails is detected on an empty stdout before `stream_init` goes out, so
the viewer sees one working stream and never an error.
Client — Chromium ships VA-API off on Linux. It is enabled where a
render node and a driver are present, then verified through
`navigator.mediaCapabilities`: a no moves to the next GL backend on the
next launch and an exhausted list drops the switches, so a renamed
feature cannot pass for a feature that is on and `--ignore-gpu-blocklist`
cannot survive on a machine it did not help. Feature lists now merge
rather than overwrite — `appendSwitch` replaces the value, and a second
caller would have silently cancelled the mDNS switch aiortc depends on.
Packaging — the drivers are weak dependencies on all four formats, so a
machine without a GPU installs exactly as before. `dpkg -i` and
`rpm -ivh` ignore weak deps; `packaging/README.md` now says so. Windows
needs no driver: the bundled ffmpeg already carries h264_qsv and
h264_nvenc, and a re-pin that dropped them would cost every low-power
Windows node its hardware encoding silently.
AMD on Windows (AMF) and macOS (VideoToolbox) are named gaps, not
oversights: neither could be tried anywhere in this project, and both
re-encode in software as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 89 |
1 files changed, 67 insertions, 22 deletions
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 |