Skip to content

Commit cee6fd0

Browse files
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.
1 parent c3554dc commit cee6fd0

31 files changed

Lines changed: 896 additions & 151 deletions

packages/cli/src/background-removal/pipeline.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file complexity
12
/**
23
* Background-removal rendering pipeline.
34
*
@@ -14,7 +15,7 @@
1415
*/
1516
import { spawn } from "node:child_process";
1617
import { extname } from "node:path";
17-
import { hasFFmpeg, hasFFprobe } from "../whisper/manager.js";
18+
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
1819
import { createSession, type Session } from "./inference.js";
1920
import { type Device, type ModelId } from "./manager.js";
2021

@@ -263,8 +264,9 @@ export function resolveRenderTargets(
263264
}
264265

265266
export async function render(options: RenderOptions): Promise<RenderResult> {
266-
if (!hasFFmpeg() || !hasFFprobe()) {
267-
throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg");
267+
const ffmpegPath = findFFmpeg();
268+
if (!ffmpegPath || !findFFprobe()) {
269+
throw new Error(`ffmpeg and ffprobe are required. Install: ${getFFmpegInstallHint()}`);
268270
}
269271

270272
const { format, bgFormat } = resolveRenderTargets(
@@ -291,7 +293,14 @@ export async function render(options: RenderOptions): Promise<RenderResult> {
291293

292294
try {
293295
const start = Date.now();
294-
const framesProcessed = await runPipeline(options, session, media, format, bgFormat);
296+
const framesProcessed = await runPipeline(
297+
options,
298+
session,
299+
media,
300+
format,
301+
bgFormat,
302+
ffmpegPath,
303+
);
295304
const durationSeconds = (Date.now() - start) / 1000;
296305
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
297306

@@ -321,8 +330,13 @@ interface FfmpegProc {
321330
type StdioFd = "ignore" | "pipe";
322331
type StdioTuple = [StdioFd, StdioFd, StdioFd];
323332

324-
function spawnFfmpeg(args: string[], label: string, stdio: StdioTuple): FfmpegProc {
325-
const proc = spawn("ffmpeg", args, { stdio });
333+
function spawnFfmpeg(
334+
ffmpegPath: string,
335+
args: string[],
336+
label: string,
337+
stdio: StdioTuple,
338+
): FfmpegProc {
339+
const proc = spawn(ffmpegPath, args, { stdio });
326340
let stderrBuf = "";
327341
proc.stderr?.on("data", (d: Buffer) => {
328342
stderrBuf += d.toString();
@@ -343,19 +357,22 @@ async function runPipeline(
343357
media: MediaInfo,
344358
format: OutputFormat,
345359
bgFormat: OutputFormat | undefined,
360+
ffmpegPath: string,
346361
): Promise<number> {
347362
const { inputPath, outputPath, backgroundOutputPath } = options;
348363
const { width, height, fps, frameCount } = media;
349364
const frameBytes = width * height * 3;
350365
const quality = options.quality ?? DEFAULT_QUALITY;
351366

352367
const decoder = spawnFfmpeg(
368+
ffmpegPath,
353369
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
354370
"ffmpeg decoder",
355371
["ignore", "pipe", "pipe"],
356372
);
357373

358374
const fg = spawnFfmpeg(
375+
ffmpegPath,
359376
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
360377
"ffmpeg encoder",
361378
["pipe", "ignore", "pipe"],
@@ -364,6 +381,7 @@ async function runPipeline(
364381
const bg =
365382
backgroundOutputPath && bgFormat
366383
? spawnFfmpeg(
384+
ffmpegPath,
367385
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
368386
"ffmpeg background encoder",
369387
["pipe", "ignore", "pipe"],

packages/cli/src/browser/ffmpeg.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
// fallow-ignore-file code-duplication
12
import { execSync } from "node:child_process";
3+
import { existsSync } from "node:fs";
4+
import { resolve } from "node:path";
25

3-
export function findFFmpeg(): string | undefined {
6+
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
7+
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
8+
9+
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
410
try {
5-
const cmd = process.platform === "win32" ? "where ffmpeg" : "which ffmpeg";
11+
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
612
const output = execSync(cmd, {
713
encoding: "utf-8",
814
stdio: ["pipe", "pipe", "pipe"],
@@ -12,18 +18,37 @@ export function findFFmpeg(): string | undefined {
1218
.split(/\r?\n/)
1319
.map((s) => s.trim())
1420
.find(Boolean);
15-
return first || undefined;
21+
return first ? resolve(first) : undefined;
1622
} catch {
1723
return undefined;
1824
}
1925
}
2026

27+
function findConfiguredBinary(
28+
envName: string,
29+
binaryName: "ffmpeg" | "ffprobe",
30+
): string | undefined {
31+
const configured = process.env[envName]?.trim();
32+
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
33+
return findOnPath(binaryName);
34+
}
35+
36+
export function findFFmpeg(): string | undefined {
37+
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
38+
}
39+
40+
export function findFFprobe(): string | undefined {
41+
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
42+
}
43+
2144
export function getFFmpegInstallHint(): string {
2245
switch (process.platform) {
2346
case "darwin":
2447
return "brew install ffmpeg";
2548
case "linux":
2649
return "sudo apt install ffmpeg";
50+
case "win32":
51+
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
2752
default:
2853
return "https://ffmpeg.org/download.html";
2954
}

packages/cli/src/browser/manager.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
/**
23
* Browser-binary resolution tests for `findBrowser()`.
34
*
@@ -72,30 +73,34 @@ function installFsMocks({ existing, dirs }: FsMockOptions) {
7273
function installPuppeteerBrowsersMock(
7374
opts: {
7475
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
76+
installResult?: { executablePath: string };
7577
} = {},
7678
) {
7779
vi.doMock("@puppeteer/browsers", () => ({
7880
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
7981
detectBrowserPlatform: () => "linux",
8082
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
81-
install: vi.fn(),
83+
install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }),
8284
}));
8385
}
8486

8587
describe("findBrowser — cache resolution", () => {
8688
const origPlatform = process.platform;
89+
const origArch = process.arch;
8790

8891
beforeEach(() => {
8992
vi.resetModules();
9093
// Force Linux for the system-fallback warning assertions. The
9194
// `Object.defineProperty` dance is needed because `process.platform` is a
9295
// getter on Node — direct assignment is silently a no-op.
9396
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
97+
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
9498
delete process.env["HYPERFRAMES_BROWSER_PATH"];
9599
});
96100

97101
afterEach(() => {
98102
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
103+
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
99104
vi.restoreAllMocks();
100105
vi.doUnmock("node:fs");
101106
vi.doUnmock("node:os");
@@ -117,6 +122,28 @@ describe("findBrowser — cache resolution", () => {
117122
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
118123
});
119124

125+
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
126+
const redownloadedBinary = join(
127+
HF_CACHE,
128+
"chrome-headless-shell",
129+
"linux-131.0.6778.85",
130+
"chrome-headless-shell-linux64",
131+
"redownloaded-chrome-headless-shell",
132+
);
133+
installFsMocks({ existing: new Set([HF_CACHE]) });
134+
installPuppeteerBrowsersMock({
135+
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
136+
installResult: { executablePath: redownloadedBinary },
137+
});
138+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
139+
140+
const { findBrowser } = await import("./manager.js");
141+
const result = await findBrowser();
142+
143+
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
144+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
145+
});
146+
120147
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
121148
// Empty hyperframes cache, populated puppeteer cache — the regression
122149
// scenario from the hf#677 spike.

packages/cli/src/browser/manager.ts

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
import { execSync, spawnSync } from "node:child_process";
23
import { existsSync, readdirSync, rmSync } from "node:fs";
34
import { basename } from "node:path";
@@ -37,6 +38,11 @@ export interface EnsureBrowserOptions {
3738
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
3839
}
3940

41+
interface CacheLookupResult {
42+
result?: BrowserResult;
43+
staleHyperframesCachePath?: string;
44+
}
45+
4046
// --- Internal helpers -------------------------------------------------------
4147

4248
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
@@ -75,7 +81,7 @@ function findFromEnv(): BrowserResult | undefined {
7581
return undefined;
7682
}
7783

78-
async function findFromCache(): Promise<BrowserResult | undefined> {
84+
async function findFromCache(): Promise<CacheLookupResult> {
7985
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
8086
// chrome-headless-shell` lands, and where `puppeteer install` from a project
8187
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
@@ -90,7 +96,7 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
9096
// newer binary, not the pinned-stale fallback.
9197
const fromPuppeteer = findFromPuppeteerCache();
9298
if (fromPuppeteer) {
93-
return fromPuppeteer;
99+
return { result: fromPuppeteer };
94100
}
95101

96102
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
@@ -100,12 +106,15 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
100106
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
101107
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
102108
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
109+
if (match && existsSync(match.executablePath)) {
110+
return { result: { executablePath: match.executablePath, source: "cache" } };
111+
}
103112
if (match) {
104-
return { executablePath: match.executablePath, source: "cache" };
113+
return { staleHyperframesCachePath: match.executablePath };
105114
}
106115
}
107116

108-
return undefined;
117+
return {};
109118
}
110119

111120
/**
@@ -251,7 +260,21 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
251260
if (fromEnv) return fromEnv;
252261

253262
const fromCache = await findFromCache();
254-
if (fromCache) return fromCache;
263+
if (fromCache.result) return fromCache.result;
264+
if (fromCache.staleHyperframesCachePath) {
265+
console.warn(
266+
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
267+
);
268+
try {
269+
return await downloadBrowser();
270+
} catch (err) {
271+
const cause = err instanceof Error ? err.message : String(err);
272+
throw new Error(
273+
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
274+
`Run \`hyperframes browser ensure --force\` to re-download.`,
275+
);
276+
}
277+
}
255278

256279
const fromSystem = findFromSystem();
257280
if (fromSystem) {
@@ -314,22 +337,39 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
314337
* Resolution: env var -> cached download -> system Chrome -> auto-download.
315338
*/
316339
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
317-
const existing = await findBrowser();
318-
if (existing) return existing;
340+
const fromEnv = findFromEnv();
341+
if (fromEnv) return fromEnv;
319342

320-
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
343+
const fromCache = await findFromCache();
344+
if (fromCache.result) return fromCache.result;
345+
if (fromCache.staleHyperframesCachePath) {
346+
console.warn(
347+
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
348+
);
349+
return downloadBrowser(options);
350+
}
321351

322-
const platform = detectBrowserPlatform();
323-
if (!platform) {
324-
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
352+
const fromSystem = findFromSystem();
353+
if (fromSystem) {
354+
warnSystemFallbackOnce(fromSystem.executablePath);
355+
return fromSystem;
325356
}
326357

327-
// Chrome headless shell has no Linux ARM64 build (e.g. DGX Spark, GB10).
328-
// Try to auto-install system Chromium via apt, then find it.
358+
return downloadBrowser(options);
359+
}
360+
361+
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
329362
if (isLinuxArm()) {
330363
return ensureLinuxArmBrowser(options);
331364
}
332365

366+
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
367+
368+
const platform = detectBrowserPlatform();
369+
if (!platform) {
370+
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
371+
}
372+
333373
const installed = await install({
334374
cacheDir: CACHE_DIR,
335375
browser: Browser.CHROMEHEADLESSSHELL,

0 commit comments

Comments
 (0)