From 65daf2545717b53d60eab4a8dc96ef82fcd12da3 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 14 Oct 2026 18:23:45 +0200 Subject: [PATCH] fix(linux): ship the shared ffmpeg CLI so native transcription can run Linux packages carried the libav shared objects but no ffmpeg executable, so native STT extraction threw FfmpegUnavailableError over MCP and the CLI. build-linux-pipewire-helper.mjs now stages the SDK's shared CLI at linux-/ffmpeg with its RUNPATH on helper-ffmpeg/ (plus libavdevice), probes it, and before-pack refuses a Linux payload without it. --- electron-builder.json5 | 5 +- electron/media/audioPeaks.test.ts | 23 +++++++++ scripts/before-pack.cjs | 9 ++++ scripts/before-pack.test.mjs | 33 +++++++++++++ scripts/build-linux-pipewire-helper.mjs | 65 +++++++++++++++++++++++-- scripts/fetch-ffmpeg.mjs | 15 +++--- 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index b8e833c9c..11027e424 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -248,8 +248,9 @@ // A single-level filter ships a helper that cannot start. // // The name is `helper-ffmpeg` rather than `ffmpeg` because that shorter - // name is taken: fetch-ffmpeg.mjs vendors the static ffmpeg executable - // there, and audioPeaks.ts resolves it by that path. + // name is taken: `linux-x64/ffmpeg` is the ffmpeg executable (the SDK's + // shared CLI, staged by build-linux-pipewire-helper.mjs with its RUNPATH + // on helper-ffmpeg/), and audioPeaks.ts resolves it by that path. "filter": ["linux-*/**"] } ] diff --git a/electron/media/audioPeaks.test.ts b/electron/media/audioPeaks.test.ts index 0376651c2..24891506c 100644 --- a/electron/media/audioPeaks.test.ts +++ b/electron/media/audioPeaks.test.ts @@ -118,6 +118,29 @@ describe("ffmpeg resolution", () => { expect(isIncluded && !isExcluded).toBe(true); }); + it("on Linux, resolves the CLI the package ships beside helper-ffmpeg/", () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const arch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "linux" }); + Object.defineProperty(process, "arch", { value: "x64" }); + try { + // build-linux-pipewire-helper.mjs stages it at exactly this path; a rename on + // either side would ship a package whose transcription cannot start. + expect(ffmpegCandidates("/opt/app")).toContain( + path.join("/opt/app", "electron", "native", "bin", "linux-x64", "ffmpeg"), + ); + } finally { + Object.defineProperty(process, "platform", platform); + Object.defineProperty(process, "arch", arch); + } + const linuxBlock = objectBody( + stripJson5Comments(readFileSync(path.join(ROOT, "electron-builder.json5"), "utf8")), + "linux", + ); + // `linux-*/**` ships linux-x64/ffmpeg along with helper-ffmpeg/, which it links. + expect(linuxBlock).toMatch(/"filter"\s*:\s*\[\s*"linux-\*\/\*\*"\s*\]/); + }); + it("honours the env override first", () => { process.env.OPENSCREEN_FFMPEG_PATH = "/custom/ffmpeg"; try { diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index ea96e59c1..d4ecb5d48 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -191,6 +191,14 @@ const LINUX_REQUIRED = [ breaks: "Wayland capture is unavailable and cursor recording throws", fix: FIX_LINUX_HELPER, }, + { + match: (name) => name === "ffmpeg", + what: "the LGPL ffmpeg CLI (spawned for waveform peaks and STT audio extraction)", + breaks: + "transcription fails over MCP and the CLI with stt:native-extraction-unavailable,\n" + + "and the editor falls back to a slow renderer decode", + fix: `${FIX_LINUX_HELPER}\n\nwhich stages the SDK's shared ffmpeg beside helper-ffmpeg/.`, + }, { match: (name) => name === "whisper-stt-server", what: "the whisper.cpp STT helper", @@ -859,6 +867,7 @@ exports.__testing = { checkMacOsVersionFloor, MAC_MIN_OS_FLOOR, MAC_REQUIRED, + LINUX_REQUIRED, checkNativePayload, checkWinShippedRedist, }; diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index e39e9e041..b7df9a3bd 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -358,6 +358,39 @@ describe("MAC_REQUIRED", () => { }); }); +// The Linux twin of #616: packages shipped the libav shared objects but no ffmpeg CLI, so +// transcription over MCP and the CLI failed with stt:native-extraction-unavailable. +describe("LINUX_REQUIRED", () => { + it("refuses a payload that has everything except the ffmpeg binary", () => { + const { LINUX_REQUIRED, checkNativePayload } = testing(); + const everything = [ + "libonnxruntime.so", + "compositor_view.node", + "openscreen-pipewire-helper", + "whisper-stt-server", + "libggml-base.so", + ...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map( + (lib, i) => `lib${lib}.so.${62 - i}`, + ), + ]; + const check = (dir) => + checkNativePayload({ + dir, + required: LINUX_REQUIRED, + osLabel: "Linux", + bundleNoun: "the package", + emptyDirFix: "unused", + }); + const payload = (names) => Object.fromEntries(names.map((n) => [n, Buffer.from("x")])); + withPayload(payload(everything), (dir) => { + expect(() => check(dir)).toThrow(/ffmpeg CLI/); + }); + withPayload(payload([...everything, "ffmpeg"]), (dir) => { + expect(() => check(dir)).not.toThrow(); + }); + }); +}); + // The Windows counterpart. A synthetic PE32+ carrying only what importedDlls() reads: the // COFF machine, one section, and an import directory naming `imports`. The tests run on // the Linux CI, where no real Windows binary exists to point the guard at. diff --git a/scripts/build-linux-pipewire-helper.mjs b/scripts/build-linux-pipewire-helper.mjs index b6928c3a8..32a20be28 100644 --- a/scripts/build-linux-pipewire-helper.mjs +++ b/scripts/build-linux-pipewire-helper.mjs @@ -101,6 +101,7 @@ for (const dir of [outDir, devDir]) { console.log(`Copied ${dest}`); stageFfmpeg(dir); } +stageFfmpegCli(outDir); /** * Copies the vendored ffmpeg shared libraries into `/helper-ffmpeg/`. @@ -140,10 +141,10 @@ function stageFfmpeg(dir) { const target = path.join(dir, "helper-ffmpeg"); fs.mkdirSync(target, { recursive: true }); - // Only the sonames the helper actually links, and only the real files — - // the tree also holds unversioned `.so` symlinks that the loader never - // consults at runtime. - const wanted = /^lib(avcodec|avformat|avutil|avfilter|swscale|swresample)\.so\.\d+$/; + // Only the sonames the helper and the ffmpeg CLI (stageFfmpegCli) link, and + // only the real files — the tree also holds unversioned `.so` symlinks that + // the loader never consults at runtime. libavdevice is the CLI's alone. + const wanted = /^lib(avcodec|avformat|avutil|avfilter|avdevice|swscale|swresample)\.so\.\d+$/; let copied = 0; for (const entry of fs.readdirSync(source)) { if (!wanted.test(entry)) { @@ -161,6 +162,62 @@ function stageFfmpeg(dir) { console.log(`Staged ${copied} ffmpeg libraries into ${target}`); } +/** + * Stages the SDK's shared ffmpeg CLI as `/ffmpeg`, the path resolveFfmpeg() + * (electron/media/audioPeaks.ts) looks up, running against the libraries in + * `/helper-ffmpeg/`. + * + * WHY. Native STT audio extraction (electron/stt/extractAudio.ts), waveform peaks + * and extension clips all spawn this CLI. Without it a Linux package transcribes + * nothing over MCP or the CLI: extraction throws FfmpegUnavailableError and the + * renderer fallback cannot read the file outside the editor. Windows ships + * `ffmpeg-shared.exe` beside its DLLs and macOS stages the SDK's CLI beside its + * dylibs; this is the same shape. It is the shared CLI (~0.5 MB) linking the + * unrenamed set the helper already ships, not the 110 MB static build. + * + * BtbN's binary carries a broken RPATH (`-Wl:../lib`), so it cannot find its + * libraries anywhere; patchelf points it at `$ORIGIN/helper-ffmpeg`, the same + * RUNPATH the helper uses. The `-version` probe fails the build if it still + * cannot start. + */ +function stageFfmpegCli(dir) { + const source = path.join( + root, + "crates", + "thirdparty", + "ffmpeg-linux64-lgpl-shared", + "bin", + "ffmpeg", + ); + if (!fs.existsSync(source)) { + console.warn( + `Vendored ffmpeg CLI not found at ${source}; native transcription needs OPENSCREEN_FFMPEG_PATH.`, + ); + return; + } + const dest = path.join(dir, "ffmpeg"); + fs.rmSync(dest, { recursive: true, force: true }); + fs.copyFileSync(source, dest); + fs.chmodSync(dest, 0o755); + // ~/.local/bin first, like build-linux-compositor-addon.mjs: CI installs 0.18 there. + const localPatchelf = path.join(process.env.HOME ?? "", ".local", "bin", "patchelf"); + const patchelf = + process.env.PATCHELF ?? (fs.existsSync(localPatchelf) ? localPatchelf : "patchelf"); + const patched = spawnSync(patchelf, ["--set-rpath", "$ORIGIN/helper-ffmpeg", dest], { + encoding: "utf8", + }); + if (patched.status !== 0) { + throw new Error( + `patchelf --set-rpath failed on ${dest}: ${patched.error?.message ?? patched.stderr}`, + ); + } + const probe = spawnSync(dest, ["-hide_banner", "-version"], { encoding: "utf8" }); + if (probe.status !== 0) { + throw new Error(`Staged ffmpeg CLI cannot start: ${probe.error?.message ?? probe.stderr}`); + } + console.log(`Staged ${dest}: ${probe.stdout.split("\n")[0]}`); +} + // A helper that cannot dlopen libpipewire is useless, and the failure is // otherwise invisible until someone records. `probeOnly` runs the whole // non-interactive path — dlopen, D-Bus, AvailableCursorModes — and stops short diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 222485d97..ef61d3412 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -559,15 +559,14 @@ async function main() { // one clobbered the other (`EEXIST: mkdir .../linux-x64/ffmpeg`). That conflict // is gone — the helper's libraries live in `helper-ffmpeg/` now — so this flag // no longer avoids a collision. What is left is a size argument: the static CLI - // is ~110 MB, `linux.extraResources` has no exclusion for it (unlike Windows' - // "!win32-*/ffmpeg.exe"), and Linux packages have shipped without it since - // v1.7.0. + // is ~110 MB and `linux.extraResources` has no exclusion for it (unlike Windows' + // "!win32-*/ffmpeg.exe"). // - // One caveat if that is ever revisited: the app is NOT entirely done with the - // CLI, contrary to assertLgpl's note below. electron/media/audioPeaks.ts spawns - // it to decode waveform peaks ~6x faster than the renderer can, and falls back - // to the browser pipelines when it is absent — so on Linux that fallback is - // always the one taken. Degraded, cached after the first decode, not broken. + // The app still needs A CLI, contrary to assertLgpl's note below: waveform peaks + // and native STT audio extraction spawn one. Linux packages ship the SHARED + // build's CLI instead (~0.5 MB, out of the SDK this flag vendors), staged at + // `/ffmpeg` by build-linux-pipewire-helper.mjs beside the libraries it + // links. If this flag is dropped, that staged file is overwritten by the static one. if (process.argv.includes("--sdk-only")) { console.log(`Skipping the standalone ffmpeg CLI (--sdk-only).`); await fetchSharedDlls(tag, binDir);