Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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-*/**"]
}
]
Expand Down
23 changes: 23 additions & 0 deletions electron/media/audioPeaks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -859,6 +867,7 @@ exports.__testing = {
checkMacOsVersionFloor,
MAC_MIN_OS_FLOOR,
MAC_REQUIRED,
LINUX_REQUIRED,
checkNativePayload,
checkWinShippedRedist,
};
Expand Down
33 changes: 33 additions & 0 deletions scripts/before-pack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 61 additions & 4 deletions scripts/build-linux-pipewire-helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/helper-ffmpeg/`.
Expand Down Expand Up @@ -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)) {
Expand All @@ -161,6 +162,62 @@ function stageFfmpeg(dir) {
console.log(`Staged ${copied} ffmpeg libraries into ${target}`);
}

/**
* Stages the SDK's shared ffmpeg CLI as `<dir>/ffmpeg`, the path resolveFfmpeg()
* (electron/media/audioPeaks.ts) looks up, running against the libraries in
* `<dir>/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
Expand Down
15 changes: 7 additions & 8 deletions scripts/fetch-ffmpeg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<binDir>/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);
Expand Down
Loading