mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(engine): harden ffmpeg binary resolution
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/)
|
} catch {
|
||||||
.map((s) => s.trim())
|
found = scanPath(name);
|
||||||
.find(Boolean);
|
}
|
||||||
const resolved = first ? resolve(first) : undefined;
|
const resolved = found ? resolve(found) : undefined;
|
||||||
pathCache.set(name, resolved);
|
pathCache.set(name, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
} catch {
|
|
||||||
pathCache.set(name, undefined);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user