mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user