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