From da0550e3862ade059529f6273e87ccb7bf2aa569 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 4 Jul 2026 18:04:56 -0700 Subject: [PATCH 1/2] fix(engine): prefer ffmpeg.exe over a .cmd/.bat shim on Windows PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When HYPERFRAMES_FFMPEG_PATH is unset, findOnPath() auto-detects ffmpeg/ffprobe via `where` (Windows) / `which` and used the FIRST result. On Windows `where ffmpeg` frequently lists a .cmd/.bat wrapper (npm/scoop/winget shims) ahead of the real .exe, and Node's spawn without shell:true cannot execute a .cmd/.bat — it throws spawn EINVAL. So render capture/encode AND audio muxing failed with "spawn EINVAL" even though FFmpeg was installed; the reporter's own fix was to rename the .cmd wrappers so ffmpeg.exe resolved first. Two independent reports of this exact EINVAL (one explicitly tracing it to `where` returning ffmpeg.cmd before ffmpeg.exe). Fix: extract selectBinaryFromPathResults(output, platform) — on win32 it prefers the first directly-spawnable executable (.exe/.com) among the results, falling back to the first result only when none is listed (so a shell-runnable .cmd-only setup isn't dropped). Non-win32 is unchanged (first result). Scoped to selection: the .cmd-only case (would still EINVAL on spawn) is left for a separate shell-spawn change. Test: selectBinaryFromPathResults unit tests — win32 prefers .exe over a leading .cmd and over a .bat, falls back to the first when no exe is listed, returns the first on non-win32, and undefined for empty output. Existing env-resolution tests unchanged. Engine build green. --- .../engine/src/utils/ffmpegBinaries.test.ts | 29 +++++++++++++++++ packages/engine/src/utils/ffmpegBinaries.ts | 32 ++++++++++++++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/utils/ffmpegBinaries.test.ts b/packages/engine/src/utils/ffmpegBinaries.test.ts index e026e7fdfb..64f4b2476b 100644 --- a/packages/engine/src/utils/ffmpegBinaries.test.ts +++ b/packages/engine/src/utils/ffmpegBinaries.test.ts @@ -5,6 +5,7 @@ import { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, + selectBinaryFromPathResults, } from "./ffmpegBinaries.js"; describe("ffmpeg binary env resolution", () => { @@ -41,3 +42,31 @@ describe("ffmpeg binary env resolution", () => { expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow(); }); }); + +describe("selectBinaryFromPathResults", () => { + it("on win32, prefers the .exe over a .cmd shim listed first (spawn EINVAL fix)", () => { + // The exact reported layout: `where ffmpeg` lists a .cmd wrapper first. + const out = "C:\\tools\\bin\\ffmpeg.cmd\r\nC:\\ffmpeg\\bin\\ffmpeg.exe\r\n"; + expect(selectBinaryFromPathResults(out, "win32")).toBe("C:\\ffmpeg\\bin\\ffmpeg.exe"); + }); + + it("on win32, also skips a .bat shim in favor of a later .exe", () => { + const out = "C:\\a\\ffmpeg.bat\nC:\\b\\ffmpeg.exe\n"; + expect(selectBinaryFromPathResults(out, "win32")).toBe("C:\\b\\ffmpeg.exe"); + }); + + it("on win32, falls back to the first result when no .exe/.com is listed", () => { + // No directly-spawnable exe present — keep prior behavior (don't drop it). + const out = "C:\\tools\\bin\\ffmpeg.cmd\r\n"; + expect(selectBinaryFromPathResults(out, "win32")).toBe("C:\\tools\\bin\\ffmpeg.cmd"); + }); + + it("on non-win32, returns the first result unchanged", () => { + const out = "/usr/local/bin/ffmpeg\n/usr/bin/ffmpeg\n"; + expect(selectBinaryFromPathResults(out, "linux")).toBe("/usr/local/bin/ffmpeg"); + }); + + it("returns undefined for empty output", () => { + expect(selectBinaryFromPathResults("\r\n \n", "win32")).toBeUndefined(); + }); +}); diff --git a/packages/engine/src/utils/ffmpegBinaries.ts b/packages/engine/src/utils/ffmpegBinaries.ts index 89df65830f..8795b0f3ee 100644 --- a/packages/engine/src/utils/ffmpegBinaries.ts +++ b/packages/engine/src/utils/ffmpegBinaries.ts @@ -8,6 +8,31 @@ export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH"; const pathCache = new Map(); +/** + * Pick the binary path to use from `where`/`which`'s (newline-separated) output. + * + * On Windows `where ffmpeg` frequently lists a `.cmd`/`.bat` shim (npm/scoop/ + * winget wrappers) AHEAD of the real `.exe`. Node's spawn (no `shell:true`) + * cannot execute a `.cmd`/`.bat` directly — it throws `spawn EINVAL` — which + * surfaces as a render (and audio-mux) failure even though FFmpeg is installed. + * So on win32 prefer the first directly-spawnable executable (`.exe`/`.com`), + * falling back to the first result only when no such executable is listed + * (preserving prior behavior rather than dropping a usable-via-shell path). + * Pure and exported so the platform-specific selection is unit-testable. + */ +export function selectBinaryFromPathResults(output: string, platform: string): string | undefined { + const candidates = output + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + if (candidates.length === 0) return undefined; + if (platform === "win32") { + const spawnable = candidates.find((p) => /\.(exe|com)$/i.test(p)); + return spawnable ?? candidates[0]; + } + return candidates[0]; +} + function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined { if (pathCache.has(name)) return pathCache.get(name); try { @@ -17,11 +42,8 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined { stdio: ["pipe", "pipe", "pipe"], timeout: 5000, }); - const first = output - .split(/\r?\n/) - .map((s) => s.trim()) - .find(Boolean); - const resolved = first ? resolve(first) : undefined; + const selected = selectBinaryFromPathResults(output, process.platform); + const resolved = selected ? resolve(selected) : undefined; pathCache.set(name, resolved); return resolved; } catch { From 34a63667760c81f291f9469e06e66cba7c088197 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 15:31:10 -0400 Subject: [PATCH 2/2] fix(cli): prefer ffmpeg.exe in the CLI PATH resolver too (snapshot/transcribe/remove-bg) The CLI had its own ffmpeg PATH resolver in packages/cli/src/browser/ffmpeg.ts, separate from the engine resolver that already preferred spawnable Windows binaries. Its results feed spawn/execFileSync without shell:true in snapshot.ts, transcribe.ts, and background-removal/pipeline.ts, so a .cmd/.bat shim ahead of ffmpeg.exe on Windows PATH still caused spawn EINVAL there. Converges both resolvers on the engine's shared selectBinaryFromPathResults (now re-exported from the engine package root) instead of duplicating the selection logic a third time. --- packages/cli/src/browser/ffmpeg.test.ts | 16 ++++++++++++++++ packages/cli/src/browser/ffmpeg.ts | 6 ++---- packages/engine/src/index.ts | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 1088d22adf..e53295b28d 100644 --- a/packages/cli/src/browser/ffmpeg.test.ts +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", () => ({ execSync: vi.fn() })); @@ -43,4 +44,19 @@ describe("findFFmpeg", () => { const { findFFmpeg } = await import("./ffmpeg.js"); expect(findFFmpeg()).toBeUndefined(); }); + + it("on win32, prefers ffmpeg.exe over a ffmpeg.cmd shim listed first", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + mockExec.mockReturnValue("C:\\tools\\bin\\ffmpeg.cmd\r\nC:\\ffmpeg\\bin\\ffmpeg.exe\r\n"); + + const { findFFmpeg } = await import("./ffmpeg.js"); + expect(findFFmpeg()).toBe(resolve("C:\\ffmpeg\\bin\\ffmpeg.exe")); + }); + + it("on non-win32, returns the first ffmpeg path from which unchanged", async () => { + mockExec.mockReturnValue("/usr/local/bin/ffmpeg\n/usr/bin/ffmpeg\n"); + + const { findFFmpeg } = await import("./ffmpeg.js"); + expect(findFFmpeg()).toBe("/usr/local/bin/ffmpeg"); + }); }); diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index 9c0670ca14..3d6d33f498 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -1,4 +1,5 @@ // fallow-ignore-file code-duplication +import { selectBinaryFromPathResults } from "@hyperframes/engine"; import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; @@ -15,10 +16,7 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined { stdio: ["pipe", "pipe", "pipe"], timeout: 5000, }); - const first = output - .split(/\r?\n/) - .map((s) => s.trim()) - .find(Boolean); + const first = selectBinaryFromPathResults(output, process.platform); return first ? resolve(first) : undefined; } catch { return undefined; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6aedbb7727..156481b42d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -216,6 +216,7 @@ export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, + selectBinaryFromPathResults, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";