mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
fix(cli): verify browser/ffmpeg binaries exist before render starts (#1365)
## Problem Windows renders commonly fail with environment errors before any real work starts: - `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists. - `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling. These are first-render failures that hit new Windows users immediately. ## Fix - Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path. - New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints. - Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH. - Map opaque Windows ffmpeg exit codes to actionable messages. ## Testing - New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary. - CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean. - Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertConfiguredFfmpegBinariesExist,
|
||||
getFfmpegBinary,
|
||||
getFfprobeBinary,
|
||||
} from "./ffmpegBinaries.js";
|
||||
|
||||
describe("ffmpeg binary env resolution", () => {
|
||||
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
||||
});
|
||||
|
||||
it("uses configured absolute paths when env vars are set", () => {
|
||||
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
|
||||
|
||||
expect(getFfmpegBinary()).toBe(resolve("/tools/ffmpeg.exe"));
|
||||
expect(getFfprobeBinary()).toBe(resolve("/tools/ffprobe.exe"));
|
||||
});
|
||||
|
||||
it("throws a clear error when a configured FFmpeg path is missing", () => {
|
||||
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
|
||||
|
||||
expect(() => assertConfiguredFfmpegBinariesExist()).toThrow(
|
||||
/FFmpeg binary not found at HYPERFRAMES_FFMPEG_PATH/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts existing configured paths", () => {
|
||||
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
|
||||
|
||||
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { execFileSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
|
||||
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
|
||||
|
||||
const pathCache = new Map<string, string | undefined>();
|
||||
|
||||
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
|
||||
if (pathCache.has(name)) return pathCache.get(name);
|
||||
try {
|
||||
const command = process.platform === "win32" ? "where" : "which";
|
||||
const output = execFileSync(command, [name], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
});
|
||||
const first = output
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.find(Boolean);
|
||||
const resolved = first ? resolve(first) : undefined;
|
||||
pathCache.set(name, resolved);
|
||||
return resolved;
|
||||
} catch {
|
||||
pathCache.set(name, undefined);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
|
||||
const configured = process.env[envName]?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
return findOnPath(binaryName) ?? binaryName;
|
||||
}
|
||||
|
||||
export function getFfmpegBinary(): string {
|
||||
return getConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
|
||||
}
|
||||
|
||||
export function getFfprobeBinary(): string {
|
||||
return getConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
|
||||
}
|
||||
|
||||
export function assertConfiguredFfmpegBinariesExist(): void {
|
||||
const ffmpegPath = process.env[FFMPEG_PATH_ENV]?.trim();
|
||||
if (ffmpegPath && !existsSync(ffmpegPath)) {
|
||||
throw new Error(
|
||||
`[FFmpeg] FFmpeg binary not found at ${FFMPEG_PATH_ENV}="${ffmpegPath}". ` +
|
||||
"Install FFmpeg or unset the override.",
|
||||
);
|
||||
}
|
||||
|
||||
const ffprobePath = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||
if (ffprobePath && !existsSync(ffprobePath)) {
|
||||
throw new Error(
|
||||
`[FFmpeg] FFprobe binary not found at ${FFPROBE_PATH_ENV}="${ffprobePath}". ` +
|
||||
"Install FFmpeg or unset the override.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { EventEmitter } from "events";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
@@ -164,9 +165,35 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
|
||||
}
|
||||
|
||||
describe("ffprobe missing-binary fallback", () => {
|
||||
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("child_process");
|
||||
if (originalFfprobePath === undefined) delete process.env.HYPERFRAMES_FFPROBE_PATH;
|
||||
else process.env.HYPERFRAMES_FFPROBE_PATH = originalFfprobePath;
|
||||
});
|
||||
|
||||
it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
|
||||
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
|
||||
const { spawn, calls } = createSpawnSpy([
|
||||
{
|
||||
kind: "exit",
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
|
||||
format: { duration: "1.25", bit_rate: "128000" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractAudioMetadata } = await import("./ffprobe.js");
|
||||
const meta = await extractAudioMetadata("/tmp/uses-configured-ffprobe.wav");
|
||||
|
||||
expect(meta.durationSeconds).toBe(1.25);
|
||||
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
|
||||
});
|
||||
|
||||
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
import { spawn } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { extname } from "path";
|
||||
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
|
||||
|
||||
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
|
||||
function runFfprobe(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffprobe", args);
|
||||
const command = getFfprobeBinary();
|
||||
const proc = spawn(command, args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
@@ -23,7 +26,14 @@ function runFfprobe(args: string[]): Promise<string> {
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
|
||||
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||
reject(
|
||||
new Error(
|
||||
configured
|
||||
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
|
||||
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file complexity
|
||||
/**
|
||||
* GPU Encoder Detection
|
||||
*
|
||||
@@ -6,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||
|
||||
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
|
||||
export type GpuEncoder = ConcreteGpuEncoder | null;
|
||||
@@ -59,7 +61,7 @@ export async function selectUsableGpuEncoder(
|
||||
|
||||
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn("ffmpeg", ["-encoders"], {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
@@ -147,7 +149,7 @@ async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve(usable);
|
||||
};
|
||||
const ffmpeg = spawn("ffmpeg", getProbeArgs(encoder), {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { formatFfmpegError } from "./runFfmpeg.js";
|
||||
|
||||
describe("formatFfmpegError", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
|
||||
});
|
||||
|
||||
it("reports exit code alone when stderr is empty", () => {
|
||||
expect(formatFfmpegError(-22, "")).toBe("FFmpeg exited with code -22");
|
||||
});
|
||||
@@ -43,4 +51,52 @@ describe("formatFfmpegError", () => {
|
||||
it("wraps stderr in [FFmpeg] prefix when exit code is null (spawn failure)", () => {
|
||||
expect(formatFfmpegError(null, "spawn ffmpeg ENOENT")).toBe("[FFmpeg] spawn ffmpeg ENOENT");
|
||||
});
|
||||
|
||||
it("maps Windows invalid-image exit codes to an actionable architecture hint", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
|
||||
expect(formatFfmpegError(3221225595, "")).toContain("wrong architecture");
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnSpy() {
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const spawn = vi.fn((command: string, args: string[]) => {
|
||||
calls.push({ command, args });
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
killed: boolean;
|
||||
};
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
proc.killed = false;
|
||||
process.nextTick(() => proc.emit("close", 0));
|
||||
return proc;
|
||||
});
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
describe("runFfmpeg binary resolution", () => {
|
||||
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("child_process");
|
||||
if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH;
|
||||
else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath;
|
||||
});
|
||||
|
||||
it("spawns the configured absolute FFmpeg path when HYPERFRAMES_FFMPEG_PATH is set", async () => {
|
||||
process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe";
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { runFfmpeg } = await import("./runFfmpeg.js");
|
||||
const result = await runFfmpeg(["-version"]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(calls[0]).toEqual({ command: resolve("/tools/ffmpeg.exe"), args: ["-version"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Shared FFmpeg process runner.
|
||||
*
|
||||
@@ -6,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||
import { trackChildProcess } from "./processTracker.js";
|
||||
|
||||
export interface RunFfmpegOptions {
|
||||
@@ -25,6 +27,23 @@ const DEFAULT_TIMEOUT = 300_000;
|
||||
|
||||
const DEFAULT_STDERR_TAIL_LINES = 15;
|
||||
|
||||
function formatWindowsFfmpegExit(exitCode: number | null): string | undefined {
|
||||
if (process.platform !== "win32" || exitCode === null) return undefined;
|
||||
if (exitCode === 3221225595 || exitCode === -1073741701) {
|
||||
return (
|
||||
"[FFmpeg] Windows could not start ffmpeg.exe (STATUS_INVALID_IMAGE_FORMAT). " +
|
||||
"The binary may be corrupted or the wrong architecture. Reinstall a 64-bit Windows FFmpeg build."
|
||||
);
|
||||
}
|
||||
if (exitCode === 3221225794 || exitCode === -1073741502) {
|
||||
return (
|
||||
"[FFmpeg] Windows failed while initializing ffmpeg.exe. " +
|
||||
"The binary may be corrupted, blocked, or missing runtime DLLs. Reinstall a 64-bit Windows FFmpeg build."
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a user-facing error message for a failed ffmpeg invocation.
|
||||
*
|
||||
@@ -48,6 +67,10 @@ export function formatFfmpegError(
|
||||
if (exitCode === null) {
|
||||
return tail ? `[FFmpeg] ${tail}` : "[FFmpeg] process error";
|
||||
}
|
||||
const windowsMessage = formatWindowsFfmpegExit(exitCode);
|
||||
if (windowsMessage) {
|
||||
return tail ? `${windowsMessage}\nffmpeg stderr (tail):\n${tail}` : windowsMessage;
|
||||
}
|
||||
return tail
|
||||
? `FFmpeg exited with code ${exitCode}\nffmpeg stderr (tail):\n${tail}`
|
||||
: `FFmpeg exited with code ${exitCode}`;
|
||||
@@ -60,7 +83,7 @@ export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promis
|
||||
const onStderr = opts?.onStderr;
|
||||
|
||||
return new Promise<RunFfmpegResult>((resolve) => {
|
||||
const ffmpeg = spawn("ffmpeg", args);
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user