fix(engine): harden ffmpeg binary resolution

This commit is contained in:
Miguel Ángel
2026-07-05 11:46:38 -07:00
committed by GitHub
parent b34e623bf8
commit b7dcb9e2a3
4 changed files with 139 additions and 16 deletions
@@ -13,7 +13,7 @@ import { EventEmitter } from "events";
import { mkdtempSync } from "fs"; import { mkdtempSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
buildStreamingArgs, buildStreamingArgs,
@@ -475,9 +475,17 @@ async function resolveWithin<T>(promise: Promise<T>, ms = 100): Promise<T | "tim
} }
describe("spawnStreamingEncoder lifecycle and cleanup", () => { describe("spawnStreamingEncoder lifecycle and cleanup", () => {
const originalPath = process.env.PATH;
beforeEach(() => {
process.env.PATH = "";
});
afterEach(() => { afterEach(() => {
vi.resetModules(); vi.resetModules();
vi.doUnmock("child_process"); vi.doUnmock("child_process");
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
}); });
it("returns a success result when ffmpeg exits cleanly after close()", async () => { it("returns a success result when ffmpeg exits cleanly after close()", async () => {
@@ -1,6 +1,8 @@
// fallow-ignore-file code-duplication // fallow-ignore-file code-duplication
import { resolve } from "node:path"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
assertConfiguredFfmpegBinariesExist, assertConfiguredFfmpegBinariesExist,
getFfmpegBinary, getFfmpegBinary,
@@ -10,12 +12,17 @@ import {
describe("ffmpeg binary env resolution", () => { describe("ffmpeg binary env resolution", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH; const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH; const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
const originalPath = process.env.PATH;
afterEach(() => { afterEach(() => {
vi.resetModules();
vi.doUnmock("child_process");
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH; if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath; else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH; if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath; else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
}); });
it("uses configured absolute paths when env vars are set", () => { it("uses configured absolute paths when env vars are set", () => {
@@ -40,4 +47,43 @@ describe("ffmpeg binary env resolution", () => {
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow(); expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
}); });
it("prefers the real Windows exe when PATH lookup lists a cmd shim first", async () => {
vi.resetModules();
vi.doMock("child_process", () => ({
execFileSync: () => "C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n",
}));
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");
expect(getMockedFfmpegBinary()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
});
it("falls back to scanning PATH when which/where fails", async () => {
const binDir = mkdtempSync(join(tmpdir(), "hyperframes-ffmpeg-path-"));
const ffmpegPath = join(binDir, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
const execFileSync = vi.fn(() => {
throw new Error("lookup command failed");
});
writeFileSync(ffmpegPath, "#!/bin/sh\n");
chmodSync(ffmpegPath, 0o755);
process.env.PATH = binDir;
vi.resetModules();
vi.doMock("child_process", () => ({ execFileSync }));
try {
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");
expect(getMockedFfmpegBinary()).toBe(resolve(ffmpegPath));
expect(execFileSync).toHaveBeenCalledOnce();
} finally {
rmSync(binDir, { force: true, recursive: true });
}
});
it("calls out a mangled replacement character in configured binary paths", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
expect(() => assertConfiguredFfmpegBinariesExist()).toThrow(/replacement character|mangled/i);
});
}); });
+70 -13
View File
@@ -1,15 +1,71 @@
// fallow-ignore-file code-duplication // fallow-ignore-file code-duplication
import { execFileSync } from "child_process"; import { execFileSync } from "child_process";
import { existsSync } from "fs"; import { accessSync, constants, existsSync } from "fs";
import { resolve } from "path"; import { delimiter, join, resolve } from "path";
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH"; export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH"; export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
const pathCache = new Map<string, string | undefined>(); const pathCache = new Map<string, string | undefined>();
function candidateFileName(candidate: string): string {
return candidate.split(/[\\/]/).at(-1)?.toLowerCase() ?? candidate.toLowerCase();
}
function chooseBestPathCandidate(
name: "ffmpeg" | "ffprobe",
candidates: readonly string[],
): string | undefined {
const normalized = candidates.map((candidate) => candidate.trim()).filter(Boolean);
return (
normalized.find((candidate) => candidateFileName(candidate) === `${name}.exe`) ??
normalized.find((candidate) => candidateFileName(candidate) === name) ??
normalized.find((candidate) => !candidateFileName(candidate).match(/\.(cmd|bat)$/i)) ??
normalized[0]
);
}
function scanPath(name: "ffmpeg" | "ffprobe"): string | undefined {
const pathValue = process.env.PATH;
if (!pathValue) return undefined;
const extensions =
process.platform === "win32"
? [
".exe",
...new Set(
(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean),
),
"",
]
: [""];
const candidates: string[] = [];
for (const dir of pathValue.split(delimiter)) {
if (!dir) continue;
for (const ext of extensions) {
const candidate = join(dir, `${name}${ext}`);
if (isExecutablePathCandidate(candidate)) candidates.push(candidate);
}
}
return chooseBestPathCandidate(name, candidates);
}
function isExecutablePathCandidate(candidate: string): boolean {
if (process.platform === "win32") return existsSync(candidate);
try {
accessSync(candidate, constants.X_OK);
return true;
} catch {
return false;
}
}
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined { function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
if (pathCache.has(name)) return pathCache.get(name); if (pathCache.has(name)) return pathCache.get(name);
let found: string | undefined;
try { try {
const command = process.platform === "win32" ? "where" : "which"; const command = process.platform === "win32" ? "where" : "which";
const output = execFileSync(command, [name], { const output = execFileSync(command, [name], {
@@ -17,17 +73,13 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
timeout: 5000, timeout: 5000,
}); });
const first = output found = chooseBestPathCandidate(name, output.split(/\r?\n/));
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
const resolved = first ? resolve(first) : undefined;
pathCache.set(name, resolved);
return resolved;
} catch { } catch {
pathCache.set(name, undefined); found = scanPath(name);
return undefined;
} }
const resolved = found ? resolve(found) : undefined;
pathCache.set(name, resolved);
return resolved;
} }
function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string { function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
@@ -49,7 +101,7 @@ export function assertConfiguredFfmpegBinariesExist(): void {
if (ffmpegPath && !existsSync(ffmpegPath)) { if (ffmpegPath && !existsSync(ffmpegPath)) {
throw new Error( throw new Error(
`[FFmpeg] FFmpeg binary not found at ${FFMPEG_PATH_ENV}="${ffmpegPath}". ` + `[FFmpeg] FFmpeg binary not found at ${FFMPEG_PATH_ENV}="${ffmpegPath}". ` +
"Install FFmpeg or unset the override.", `Install FFmpeg or unset the override.${pathEncodingHint(ffmpegPath)}`,
); );
} }
@@ -57,7 +109,12 @@ export function assertConfiguredFfmpegBinariesExist(): void {
if (ffprobePath && !existsSync(ffprobePath)) { if (ffprobePath && !existsSync(ffprobePath)) {
throw new Error( throw new Error(
`[FFmpeg] FFprobe binary not found at ${FFPROBE_PATH_ENV}="${ffprobePath}". ` + `[FFmpeg] FFprobe binary not found at ${FFPROBE_PATH_ENV}="${ffprobePath}". ` +
"Install FFmpeg or unset the override.", `Install FFmpeg or unset the override.${pathEncodingHint(ffprobePath)}`,
); );
} }
} }
function pathEncodingHint(configuredPath: string): string {
if (!configuredPath.includes("\uFFFD")) return "";
return " The path contains a Unicode replacement character, which usually means it was mangled while being copied or decoded.";
}
+12
View File
@@ -166,12 +166,19 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
describe("ffprobe missing-binary fallback", () => { describe("ffprobe missing-binary fallback", () => {
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH; const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
const originalPath = process.env.PATH;
function hidePathBinaries(): void {
process.env.PATH = "";
}
afterEach(() => { afterEach(() => {
vi.resetModules(); vi.resetModules();
vi.doUnmock("child_process"); vi.doUnmock("child_process");
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH; if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath; else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
}); });
it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => { it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
@@ -198,6 +205,7 @@ describe("ffprobe missing-binary fallback", () => {
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => { it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules(); vi.resetModules();
vi.doMock("child_process", () => ({ spawn })); vi.doMock("child_process", () => ({ spawn }));
@@ -294,6 +302,7 @@ describe("ffprobe missing-binary fallback", () => {
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => { it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]); const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules(); vi.resetModules();
vi.doMock("child_process", () => ({ spawn })); vi.doMock("child_process", () => ({ spawn }));
@@ -304,6 +313,7 @@ describe("ffprobe missing-binary fallback", () => {
it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => { it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules(); vi.resetModules();
vi.doMock("child_process", () => ({ spawn })); vi.doMock("child_process", () => ({ spawn }));
@@ -318,6 +328,7 @@ describe("ffprobe missing-binary fallback", () => {
it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => { it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules(); vi.resetModules();
vi.doMock("child_process", () => ({ spawn })); vi.doMock("child_process", () => ({ spawn }));
@@ -332,6 +343,7 @@ describe("ffprobe missing-binary fallback", () => {
it("ffprobe-missing error message includes install hint", async () => { it("ffprobe-missing error message includes install hint", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]); const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
vi.resetModules(); vi.resetModules();
vi.doMock("child_process", () => ({ spawn })); vi.doMock("child_process", () => ({ spawn }));