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:
Miguel Ángel
2026-06-12 01:36:28 -04:00
committed by GitHub
parent c3554dcffe
commit cee6fd02d6
31 changed files with 896 additions and 151 deletions
+53 -1
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const producerState = vi.hoisted(() => ({
@@ -5,6 +6,31 @@ const producerState = vi.hoisted(() => ({
resolveConfigCalls: [] as Array<Record<string, unknown>>,
}));
const preflightState = vi.hoisted(() => ({
result: {
outcomes: [
{ name: "FFmpeg", ok: true, level: "ok", detail: "/usr/bin/ffmpeg", path: "/usr/bin/ffmpeg" },
{
name: "FFprobe",
ok: true,
level: "ok",
detail: "/usr/bin/ffprobe",
path: "/usr/bin/ffprobe",
},
{
name: "Chrome",
ok: true,
level: "ok",
detail: "cache: /mock/chrome",
path: "/mock/chrome",
},
],
ffmpegPath: "/usr/bin/ffmpeg",
ffprobePath: "/usr/bin/ffprobe",
browser: { executablePath: "/mock/chrome", source: "cache" },
},
}));
vi.mock("../utils/producer.js", () => ({
loadProducer: vi.fn(async () => ({
resolveConfig: vi.fn((overrides: Record<string, unknown>) => {
@@ -29,6 +55,10 @@ vi.mock("../browser/ffmpeg.js", () => ({
getFFmpegInstallHint: vi.fn(() => "brew install ffmpeg"),
}));
vi.mock("../browser/preflight.js", () => ({
runEnvironmentChecks: vi.fn(async () => preflightState.result),
}));
describe("renderLocal browser GPU config", () => {
const savedEnv = new Map<string, string | undefined>();
// Pre-resolve once. The first dynamic `import("./render.js")` in this file
@@ -46,7 +76,7 @@ describe("renderLocal browser GPU config", () => {
});
function setEnv(key: string, value: string) {
savedEnv.set(key, process.env[key]);
if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
process.env[key] = value;
}
@@ -54,6 +84,12 @@ describe("renderLocal browser GPU config", () => {
producerState.createdJobs = [];
producerState.resolveConfigCalls = [];
savedEnv.clear();
savedEnv.set("HYPERFRAMES_FFMPEG_PATH", process.env.HYPERFRAMES_FFMPEG_PATH);
savedEnv.set("HYPERFRAMES_FFPROBE_PATH", process.env.HYPERFRAMES_FFPROBE_PATH);
savedEnv.set("PRODUCER_HEADLESS_SHELL_PATH", process.env.PRODUCER_HEADLESS_SHELL_PATH);
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
});
afterEach(() => {
@@ -125,6 +161,22 @@ describe("renderLocal browser GPU config", () => {
});
});
it("passes preflight-resolved FFmpeg, FFprobe, and browser paths through env", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(process.env.HYPERFRAMES_FFMPEG_PATH).toBe("/usr/bin/ffmpeg");
expect(process.env.HYPERFRAMES_FFPROBE_PATH).toBe("/usr/bin/ffprobe");
expect(process.env.PRODUCER_HEADLESS_SHELL_PATH).toBe("/mock/chrome");
});
it("resolves browser GPU from CLI flags, Docker mode, and env fallback", () => {
// Default (no flag, no env): auto — engine probes and chooses.
expect(resolveBrowserGpuForCli(false, undefined, undefined)).toBe("auto");