From d22ce0ba5aebfff5ffaea97982472f876af5da22 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 18 Sep 2026 14:37:08 +0200 Subject: feat: decode and re-encode video on the GPU where there is one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-client/src/main.js | 170 +++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-client/src') diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 122a302..755f1f7 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -41,6 +41,19 @@ const { pathToFileURL } = require('node:url'); // runs (`electron .`) consistent with it. app.commandLine.appendSwitch('class', 'MeshBay'); +// Chromium reads `--enable-features` and `--disable-features` as one +// comma-joined list each, and `appendSwitch` **replaces** that list rather +// than adding to it. Two call sites therefore cancel the first silently: no +// error, nothing in a log, just a feature that is quietly not on. The +// hardware-decode section further down is the second caller, so both go +// through this — and so must the third. +function addFeatures(kind, names) { + const current = app.commandLine.getSwitchValue(kind); + const merged = new Set(current ? current.split(',').filter(Boolean) : []); + for (const name of names) merged.add(name); + app.commandLine.appendSwitch(kind, [...merged].join(',')); +} + // Chromium publishes host candidates as random `.local` mDNS names rather // than as the real local IP. Every peer this client talks to is a node running // aiortc/aioice, and aioice has no mDNS resolver on any platform: it logs @@ -56,7 +69,7 @@ app.commandLine.appendSwitch('class', 'MeshBay'); // *client*; the platform that matters is the peer's, not ours, and the peer is // always a node. The trade is that our private address reaches the hub and the // node in the SDP — both the user's own infrastructure, not an arbitrary page. -app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns'); +addFeatures('disable-features', ['WebRtcHideLocalIpsWithMdns']); const UI_DIR = path.join(__dirname, '..', 'ui'); const SCHEME = 'app'; @@ -245,6 +258,159 @@ function writeConfig(next) { let config = readConfig(); +// ── Hardware video decoding (Linux) ───────────────────────────────────────── +// +// Chromium ships VA-API off on Linux; macOS and Windows decode H.264 in +// hardware without being asked, so everything here is Linux-only. +// +// It pays off exactly where it is needed. A 1080p H.264 film is the streaming +// path's normal case — the node hands the bytes over untouched (`-c:v copy`, +// webrtc_server.py) and all the decoding happens here — and every Intel iGPU +// of the last decade decodes that in fixed-function silicon, while an +// Atom/Celeron-class CPU cannot hold 25 fps at 1080p in software. +// +// Three decisions, written down because each replaces something a person +// would otherwise have to do by hand: +// +// - **Detected, not configured.** One package installs on every machine, so +// the switches cannot be chosen at build time and there is no second +// "low-power" package. The probe is two filesystem checks and runs before +// `whenReady`, because Chromium reads its command line exactly once. +// +// - **The feature name is not stable, so it is not trusted.** +// `VaapiVideoDecodeLinuxGL` has been renamed across Chromium releases, and +// `build-client.sh` deliberately rebuilds against the *latest* Electron +// every time — a name pinned here would stop matching one bump later with +// nothing failing to say so. Both known names are passed (Chromium ignores +// an unknown one) and the outcome is measured rather than assumed. +// +// - **The verdict is measured, remembered, and re-opened by a Chromium +// bump.** `measureVideoDecoding` asks the renderer whether the codec the +// node really streams decodes `powerEfficient`ly — Chromium's own answer to +// "is this hardware", which is what reading it out of devtools would have +// told you. A no moves to the next set of switches on the next launch; when +// the list runs out they are dropped altogether, so `--ignore-gpu-blocklist` +// cannot leave a machine with a broken GL stack paying for a feature it +// never got. +// +// `videoAcceleration` in config.json overrides the lot: "off" never applies +// anything, "on" applies it even where the probe finds no driver. + +const VAAPI_FEATURES = ['VaapiVideoDecodeLinuxGL', 'AcceleratedVideoDecodeLinuxGL']; + +// One per launch, in order, until the measurement says hardware. These are not +// a guess about *this* Chromium — they are the ways its GL backend has had to +// be named, and the machine picks which one is true for it. +const VAAPI_ATTEMPTS = [ + [], + [['use-gl', 'egl']], + [['use-gl', 'angle'], ['use-angle', 'gl']], +]; + +function vaapiDriverInstalled() { + const dirs = [process.env.LIBVA_DRIVERS_PATH, '/usr/lib/x86_64-linux-gnu/dri', + '/usr/lib/aarch64-linux-gnu/dri', '/usr/lib64/dri', '/usr/lib/dri']; + for (const dir of dirs) { + if (!dir) continue; + try { + if (fs.readdirSync(dir).some((f) => f.endsWith('_drv_video.so'))) return true; + } catch { /* no such directory on this distribution */ } + } + return false; +} + +// Readable *and* writable: VA-API maps buffers on the render node. On a +// desktop session logind grants that through an ACL rather than through group +// membership, so asking the kernel answers "can this process use it", which is +// the question — rather than "is this user in `render`", which is not. +function renderNode() { + try { + for (const name of fs.readdirSync('/dev/dri')) { + if (!name.startsWith('renderD')) continue; + const dev = `/dev/dri/${name}`; + try { + fs.accessSync(dev, fs.constants.R_OK | fs.constants.W_OK); + return dev; + } catch { /* another GPU may still be usable */ } + } + } catch { /* no /dev/dri at all */ } + return null; +} + +// Which attempt this launch is running, or null when no switches were applied +// — which is also what stops a second window measuring the same launch twice. +let vaapiAttempt = null; + +function configureVideoDecoding() { + if (process.platform !== 'linux') return; + const mode = config.videoAcceleration || 'auto'; + if (mode === 'off') return; + const forced = mode === 'on'; + if (!forced && !(renderNode() && vaapiDriverInstalled())) { + console.log('[video] no usable VA-API driver — decoding in software'); + return; + } + const probe = config.videoDecodeProbe || {}; + const known = probe.chrome === process.versions.chrome; + let attempt = (known && typeof probe.attempt === 'number') ? probe.attempt : 0; + if (attempt >= VAAPI_ATTEMPTS.length) { + if (!forced) { + console.log('[video] VA-API never took on this machine — decoding in software'); + return; + } + // "on" is the person overruling the measurement, so the list running out + // is not an answer here — it keeps the last set rather than climbing an + // index nothing will ever read again. + attempt = VAAPI_ATTEMPTS.length - 1; + } + addFeatures('enable-features', VAAPI_FEATURES); + app.commandLine.appendSwitch('ignore-gpu-blocklist'); + for (const [name, value] of VAAPI_ATTEMPTS[attempt]) + app.commandLine.appendSwitch(name, value); + vaapiAttempt = attempt; +} + +// `avc1.640029` is H.264 High 4.1 — what the node announces for a re-encode +// (webrtc_server.py) and the profile a copied 1080p film carries. Asking about +// the codec that is actually streamed is the point: a machine can decode +// H.264 in hardware and still answer no for HEVC, and the reverse. +const DECODE_PROBE = `navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: 'video/mp4; codecs="avc1.640029"', + width: 1920, height: 1080, bitrate: 5000000, framerate: 25, + }, +}).then((r) => !!r.powerEfficient).catch(() => false)`; + +async function measureVideoDecoding(win) { + const attempt = vaapiAttempt; + vaapiAttempt = null; + if (attempt === null) return; + let hardware; + try { + hardware = await win.webContents.executeJavaScript(DECODE_PROBE); + } catch { + return; // the window went away; the question is asked again next launch + } + const gpu = app.getGPUFeatureStatus().video_decode; + console.log(`[video] hardware decoding: ${hardware} ` + + `(attempt ${attempt}, gpu video_decode: ${gpu})`); + config = { + ...config, + videoDecodeProbe: { + chrome: process.versions.chrome, + attempt: hardware ? attempt : attempt + 1, + hardware, + gpu, + }, + }; + writeConfig(config); + if (!hardware && attempt + 1 < VAAPI_ATTEMPTS.length) + console.log('[video] another GL backend will be tried on the next launch'); +} + +configureVideoDecoding(); + // ── Secrets ───────────────────────────────────────────────────────────────── // // The OS keychain, through safeStorage. What it protects and what it does not @@ -482,6 +648,8 @@ function createWindow() { }); win.once('ready-to-show', () => win.show()); + // Once per launch, and only when the section above applied switches. + win.webContents.once('did-finish-load', () => { measureVideoDecoding(win); }); // Some Wayland compositors (observed under GNOME/Mutter on a VM with a // virtio-gpu device whose command-buffer creation fails) never schedule a // first paint for a surface that isn't mapped yet — but Electron won't map -- cgit v1.2.3