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);