fix(engine): prefer ffmpeg.exe over a .cmd/.bat shim on Windows PATH

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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-04 18:04:56 -07:00
parent 78cca797f1
commit da0550e386
2 changed files with 56 additions and 5 deletions
@@ -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();
});
});
+27 -5
View File
@@ -8,6 +8,31 @@ export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
const pathCache = new Map<string, string | undefined>();
/**
* 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 {