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
@@ -1,3 +1,4 @@
// fallow-ignore-file complexity
/**
* Background-removal rendering pipeline.
*
@@ -14,7 +15,7 @@
*/
import { spawn } from "node:child_process";
import { extname } from "node:path";
import { hasFFmpeg, hasFFprobe } from "../whisper/manager.js";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { createSession, type Session } from "./inference.js";
import { type Device, type ModelId } from "./manager.js";
@@ -263,8 +264,9 @@ export function resolveRenderTargets(
}
export async function render(options: RenderOptions): Promise<RenderResult> {
if (!hasFFmpeg() || !hasFFprobe()) {
throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg");
const ffmpegPath = findFFmpeg();
if (!ffmpegPath || !findFFprobe()) {
throw new Error(`ffmpeg and ffprobe are required. Install: ${getFFmpegInstallHint()}`);
}
const { format, bgFormat } = resolveRenderTargets(
@@ -291,7 +293,14 @@ export async function render(options: RenderOptions): Promise<RenderResult> {
try {
const start = Date.now();
const framesProcessed = await runPipeline(options, session, media, format, bgFormat);
const framesProcessed = await runPipeline(
options,
session,
media,
format,
bgFormat,
ffmpegPath,
);
const durationSeconds = (Date.now() - start) / 1000;
const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0;
@@ -321,8 +330,13 @@ interface FfmpegProc {
type StdioFd = "ignore" | "pipe";
type StdioTuple = [StdioFd, StdioFd, StdioFd];
function spawnFfmpeg(args: string[], label: string, stdio: StdioTuple): FfmpegProc {
const proc = spawn("ffmpeg", args, { stdio });
function spawnFfmpeg(
ffmpegPath: string,
args: string[],
label: string,
stdio: StdioTuple,
): FfmpegProc {
const proc = spawn(ffmpegPath, args, { stdio });
let stderrBuf = "";
proc.stderr?.on("data", (d: Buffer) => {
stderrBuf += d.toString();
@@ -343,6 +357,7 @@ async function runPipeline(
media: MediaInfo,
format: OutputFormat,
bgFormat: OutputFormat | undefined,
ffmpegPath: string,
): Promise<number> {
const { inputPath, outputPath, backgroundOutputPath } = options;
const { width, height, fps, frameCount } = media;
@@ -350,12 +365,14 @@ async function runPipeline(
const quality = options.quality ?? DEFAULT_QUALITY;
const decoder = spawnFfmpeg(
ffmpegPath,
["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"],
"ffmpeg decoder",
["ignore", "pipe", "pipe"],
);
const fg = spawnFfmpeg(
ffmpegPath,
buildEncoderArgs(format, width, height, fps || 30, outputPath, quality),
"ffmpeg encoder",
["pipe", "ignore", "pipe"],
@@ -364,6 +381,7 @@ async function runPipeline(
const bg =
backgroundOutputPath && bgFormat
? spawnFfmpeg(
ffmpegPath,
buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality),
"ffmpeg background encoder",
["pipe", "ignore", "pipe"],
+28 -3
View File
@@ -1,8 +1,14 @@
// fallow-ignore-file code-duplication
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
export function findFFmpeg(): string | undefined {
export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
try {
const cmd = process.platform === "win32" ? "where ffmpeg" : "which ffmpeg";
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
@@ -12,18 +18,37 @@ export function findFFmpeg(): string | undefined {
.split(/\r?\n/)
.map((s) => s.trim())
.find(Boolean);
return first || undefined;
return first ? resolve(first) : undefined;
} catch {
return undefined;
}
}
function findConfiguredBinary(
envName: string,
binaryName: "ffmpeg" | "ffprobe",
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
return findOnPath(binaryName);
}
export function findFFmpeg(): string | undefined {
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
}
export function findFFprobe(): string | undefined {
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
}
export function getFFmpegInstallHint(): string {
switch (process.platform) {
case "darwin":
return "brew install ffmpeg";
case "linux":
return "sudo apt install ffmpeg";
case "win32":
return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH.";
default:
return "https://ffmpeg.org/download.html";
}
+28 -1
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
/**
* Browser-binary resolution tests for `findBrowser()`.
*
@@ -72,18 +73,20 @@ function installFsMocks({ existing, dirs }: FsMockOptions) {
function installPuppeteerBrowsersMock(
opts: {
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
installResult?: { executablePath: string };
} = {},
) {
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "linux",
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
install: vi.fn(),
install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }),
}));
}
describe("findBrowser — cache resolution", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
@@ -91,11 +94,13 @@ describe("findBrowser — cache resolution", () => {
// `Object.defineProperty` dance is needed because `process.platform` is a
// getter on Node — direct assignment is silently a no-op.
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
delete process.env["HYPERFRAMES_BROWSER_PATH"];
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
@@ -117,6 +122,28 @@ describe("findBrowser — cache resolution", () => {
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
});
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"redownloaded-chrome-headless-shell",
);
installFsMocks({ existing: new Set([HF_CACHE]) });
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
installResult: { executablePath: redownloadedBinary },
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
});
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
// Empty hyperframes cache, populated puppeteer cache — the regression
// scenario from the hf#677 spike.
+53 -13
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { execSync, spawnSync } from "node:child_process";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { basename } from "node:path";
@@ -37,6 +38,11 @@ export interface EnsureBrowserOptions {
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
}
interface CacheLookupResult {
result?: BrowserResult;
staleHyperframesCachePath?: string;
}
// --- Internal helpers -------------------------------------------------------
const SYSTEM_CHROME_PATHS: ReadonlyArray<string> =
@@ -75,7 +81,7 @@ function findFromEnv(): BrowserResult | undefined {
return undefined;
}
async function findFromCache(): Promise<BrowserResult | undefined> {
async function findFromCache(): Promise<CacheLookupResult> {
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
// chrome-headless-shell` lands, and where `puppeteer install` from a project
// depending on full `puppeteer` (not `puppeteer-core`) lands. The engine's
@@ -90,7 +96,7 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
// newer binary, not the pinned-stale fallback.
const fromPuppeteer = findFromPuppeteerCache();
if (fromPuppeteer) {
return fromPuppeteer;
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
@@ -100,12 +106,15 @@ async function findFromCache(): Promise<BrowserResult | undefined> {
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { executablePath: match.executablePath, source: "cache" };
return { staleHyperframesCachePath: match.executablePath };
}
}
return undefined;
return {};
}
/**
@@ -251,7 +260,21 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
if (fromEnv) return fromEnv;
const fromCache = await findFromCache();
if (fromCache) return fromCache;
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await downloadBrowser();
} catch (err) {
const cause = err instanceof Error ? err.message : String(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
);
}
}
const fromSystem = findFromSystem();
if (fromSystem) {
@@ -314,8 +337,31 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
* Resolution: env var -> cached download -> system Chrome -> auto-download.
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const existing = await findBrowser();
if (existing) return existing;
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
const fromCache = await findFromCache();
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
return downloadBrowser(options);
}
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
}
return downloadBrowser(options);
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const { Browser, detectBrowserPlatform, install } = await loadPuppeteerBrowsers();
@@ -324,12 +370,6 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
throw new Error(`Unsupported platform: ${process.platform} ${process.arch}`);
}
// Chrome headless shell has no Linux ARM64 build (e.g. DGX Spark, GB10).
// Try to auto-install system Chromium via apt, then find it.
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
}
const installed = await install({
cacheDir: CACHE_DIR,
browser: Browser.CHROMEHEADLESSSHELL,
@@ -0,0 +1,89 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { parseToolVersion, runEnvironmentChecks } from "./preflight.js";
describe("runEnvironmentChecks", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH;
beforeEach(() => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
});
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("returns configured FFmpeg and FFprobe paths when checks pass", async () => {
const result = await runEnvironmentChecks();
expect(result.outcomes.find((outcome) => outcome.name === "FFmpeg")?.ok).toBe(true);
expect(result.outcomes.find((outcome) => outcome.name === "FFprobe")?.ok).toBe(true);
expect(result.ffmpegPath).toBe(process.execPath);
expect(result.ffprobePath).toBe(process.execPath);
});
it("reports ffprobe as a render-blocking error when the explicit path is missing", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/missing/ffprobe.exe";
const result = await runEnvironmentChecks();
expect(result.outcomes).toContainEqual(
expect.objectContaining({
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
}),
);
expect(result.ffprobePath).toBeUndefined();
});
it("fails early when an explicit FFmpeg env override points at a missing file", async () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg.exe";
const result = await runEnvironmentChecks();
const ffmpeg = result.outcomes.find((outcome) => outcome.name === "FFmpeg");
expect(ffmpeg).toMatchObject({
ok: false,
detail: 'Configured path does not exist: HYPERFRAMES_FFMPEG_PATH="/missing/ffmpeg.exe"',
});
});
it("validates an explicit browser path without needing browser discovery", async () => {
const result = await runEnvironmentChecks({
includeBrowser: true,
browserPath: process.execPath,
});
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
ok: true,
path: process.execPath,
});
});
it("reports an explicit missing browser path before render starts", async () => {
const result = await runEnvironmentChecks({
includeBrowser: true,
browserPath: "/missing/chrome-headless-shell.exe",
});
expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({
ok: false,
title: "Chrome not found",
});
});
});
describe("parseToolVersion", () => {
it("extracts ffprobe versions with Windows build suffixes", () => {
expect(parseToolVersion("ffprobe version 7.1.1-essentials_build-www.gyan.dev Copyright")).toBe(
"ffprobe 7.1.1-essentials_build-www.gyan.dev",
);
});
});
+232
View File
@@ -0,0 +1,232 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { findBrowser, type BrowserResult } from "./manager.js";
import {
FFMPEG_PATH_ENV,
FFPROBE_PATH_ENV,
findFFmpeg,
findFFprobe,
getFFmpegInstallHint,
} from "./ffmpeg.js";
import { getFreeDiskMb } from "../telemetry/system.js";
export type EnvironmentCheckLevel = "ok" | "warn" | "error";
export interface EnvironmentCheckOutcome {
name: string;
ok: boolean;
detail: string;
level: EnvironmentCheckLevel;
title?: string;
hint?: string;
path?: string;
}
export interface EnvironmentCheckResult {
outcomes: EnvironmentCheckOutcome[];
ffmpegPath?: string;
ffprobePath?: string;
browser?: BrowserResult;
}
export interface EnvironmentCheckOptions {
projectDir?: string;
browserPath?: string;
includeBrowser?: boolean;
includeDisk?: boolean;
includeWindowsUnc?: boolean;
}
export function parseToolVersion(raw: string): string {
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
return m ? `${m[1]} ${m[2]}` : raw.trim();
}
function configuredMissingDetail(envName: string): string | undefined {
const configured = process.env[envName]?.trim();
if (!configured || existsSync(configured)) return undefined;
return `Configured path does not exist: ${envName}="${configured}"`;
}
function readToolVersion(binaryPath: string): string {
try {
const raw =
execFileSync(binaryPath, ["-version"], { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ??
"";
const version = parseToolVersion(raw);
return version ? `${version} at ${binaryPath}` : binaryPath;
} catch {
return binaryPath;
}
}
function checkFFmpeg(): EnvironmentCheckOutcome {
const missingConfigured = configuredMissingDetail(FFMPEG_PATH_ENV);
if (missingConfigured) {
return {
name: "FFmpeg",
ok: false,
level: "error",
title: "FFmpeg not found",
detail: missingConfigured,
hint: getFFmpegInstallHint(),
};
}
const path = findFFmpeg();
if (path) {
return { name: "FFmpeg", ok: true, level: "ok", detail: readToolVersion(path), path };
}
return {
name: "FFmpeg",
ok: false,
level: "error",
title: "FFmpeg not found",
detail: "FFmpeg is required to encode video. The render cannot proceed without it.",
hint: getFFmpegInstallHint(),
};
}
function checkFFprobe(): EnvironmentCheckOutcome {
const missingConfigured = configuredMissingDetail(FFPROBE_PATH_ENV);
if (missingConfigured) {
return {
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
detail: missingConfigured,
hint: getFFmpegInstallHint(),
};
}
const path = findFFprobe();
if (path) {
return { name: "FFprobe", ok: true, level: "ok", detail: readToolVersion(path), path };
}
return {
name: "FFprobe",
ok: false,
level: "error",
title: "FFprobe not found",
detail:
"FFprobe is required to probe media assets. It ships with FFmpeg but was not found on PATH.",
hint: getFFmpegInstallHint(),
};
}
async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcome> {
if (browserPath) {
if (existsSync(browserPath)) {
return {
name: "Chrome",
ok: true,
level: "ok",
detail: `explicit: ${browserPath}`,
path: browserPath,
};
}
return {
name: "Chrome",
ok: false,
level: "error",
title: "Chrome not found",
detail: `Chrome binary not found at "${browserPath}".`,
hint: "Run: npx hyperframes browser ensure",
};
}
const info = await findBrowser();
if (info) {
return {
name: "Chrome",
ok: true,
level: "ok",
detail: `${info.source}: ${info.executablePath}`,
path: info.executablePath,
};
}
return {
name: "Chrome",
ok: false,
level: "error",
title: "Chrome not found",
detail: "Chrome Headless Shell is required for local rendering.",
hint: "Run: npx hyperframes browser ensure",
};
}
function checkDisk(projectDir = "."): EnvironmentCheckOutcome {
const freeMb = getFreeDiskMb(projectDir);
if (freeMb === null) {
return { name: "Disk", ok: true, level: "ok", detail: "Unable to check" };
}
const freeGb = (freeMb / 1024).toFixed(1);
if (freeMb < 1024) {
return {
name: "Disk",
ok: false,
level: "error",
title: "Low disk space",
detail: `${freeGb} GB free`,
hint: "Renders produce large temp files. Free disk space before rendering.",
};
}
return { name: "Disk", ok: true, level: "ok", detail: `${freeGb} GB free` };
}
function checkWindowsUncPath(projectDir = process.cwd()): EnvironmentCheckOutcome | undefined {
if (platform() !== "win32") return undefined;
if (!projectDir.startsWith("\\\\")) return undefined;
return {
name: "Windows path",
ok: true,
level: "warn",
detail: `UNC path: ${projectDir}`,
hint: "Chrome may fail to launch from a network share. Use a local drive if render startup fails.",
};
}
export async function runEnvironmentChecks(
options: EnvironmentCheckOptions = {},
): Promise<EnvironmentCheckResult> {
const outcomes: EnvironmentCheckOutcome[] = [];
const ffmpeg = checkFFmpeg();
outcomes.push(ffmpeg);
const ffprobe = checkFFprobe();
outcomes.push(ffprobe);
let browser: BrowserResult | undefined;
if (options.includeBrowser) {
const chrome = await checkChrome(options.browserPath);
outcomes.push(chrome);
if (chrome.ok && chrome.path) {
browser = {
executablePath: chrome.path,
source: options.browserPath ? "env" : "cache",
};
}
}
if (options.includeDisk) {
outcomes.push(checkDisk(options.projectDir));
}
if (options.includeWindowsUnc) {
const unc = checkWindowsUncPath(options.projectDir);
if (unc) outcomes.push(unc);
}
return {
outcomes,
...(ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}),
...(ffprobe.path ? { ffprobePath: ffprobe.path } : {}),
...(browser ? { browser } : {}),
};
}
+25 -65
View File
@@ -1,10 +1,10 @@
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { execSync } from "node:child_process";
import { platform } from "node:os";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { findBrowser } from "../browser/manager.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
import { VERSION } from "../version.js";
import { getUpdateMeta, withMeta } from "../utils/updateCheck.js";
import {
@@ -30,61 +30,7 @@ interface CheckResult {
hint?: string;
}
/**
* Extract a clean "toolname X.Y.Z" from the verbose first line of
* `ffmpeg -version` / `ffprobe -version` output. Falls back to the
* trimmed input when the pattern doesn't match.
*/
export function parseToolVersion(raw: string): string {
const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i);
return m ? `${m[1]} ${m[2]}` : raw.trim();
}
function checkFFmpeg(): CheckResult {
const path = findFFmpeg();
if (path) {
try {
const raw =
execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
return { ok: true, detail: parseToolVersion(raw) };
} catch {
return { ok: true, detail: path };
}
}
return {
ok: false,
detail: "Not found",
hint: getFFmpegInstallHint(),
};
}
function checkFFprobe(): CheckResult {
// `ffprobe -version` works cross-platform if it's on PATH — no need for
// `which`/`where` shell detection, which differs by OS.
try {
const raw =
execSync("ffprobe -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? "";
return { ok: true, detail: parseToolVersion(raw) };
} catch {
return {
ok: false,
detail: "Not found",
hint: `Installed with ffmpeg — ${getFFmpegInstallHint()}`,
};
}
}
async function checkChrome(): Promise<CheckResult> {
const info = await findBrowser();
if (info) {
return { ok: true, detail: `${info.source}: ${info.executablePath}` };
}
return {
ok: false,
detail: "Not found",
hint: "Run: npx hyperframes browser ensure",
};
}
export { parseToolVersion };
function checkDocker(): CheckResult {
try {
@@ -252,6 +198,7 @@ export default defineCommand({
json: { type: "boolean", description: "Output as JSON", default: false },
},
async run({ args }) {
const environment = await runEnvironmentChecks({ includeBrowser: true });
const checks: Check[] = [
{ name: "Version", run: checkVersion },
{ name: "Node.js", run: checkNode },
@@ -265,14 +212,7 @@ export default defineCommand({
checks.push({ name: "/dev/shm", run: checkShm });
}
checks.push(
{ name: "Environment", run: checkEnvironment },
{ name: "FFmpeg", run: checkFFmpeg },
{ name: "FFprobe", run: checkFFprobe },
{ name: "Chrome", run: checkChrome },
{ name: "Docker", run: checkDocker },
{ name: "Docker running", run: checkDockerRunning },
);
checks.push({ name: "Environment", run: checkEnvironment });
const outcomes: CheckOutcome[] = [];
for (const check of checks) {
@@ -284,6 +224,26 @@ export default defineCommand({
...(result.hint ? { hint: result.hint } : {}),
});
}
for (const result of environment.outcomes) {
outcomes.push({
name: result.name,
ok: result.ok,
detail: result.detail,
...(result.hint ? { hint: result.hint } : {}),
});
}
for (const check of [
{ name: "Docker", run: checkDocker },
{ name: "Docker running", run: checkDockerRunning },
]) {
const result = await check.run();
outcomes.push({
name: check.name,
ok: result.ok,
detail: result.detail,
...(result.hint ? { hint: result.hint } : {}),
});
}
const allOk = outcomes.every((o) => o.ok);
if (args.json) {
+13 -6
View File
@@ -35,6 +35,7 @@ import {
import { fetchRemoteTemplate } from "../templates/remote.js";
import { trackInitTemplate } from "../telemetry/events.js";
import { hasFFmpeg } from "../whisper/manager.js";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { VERSION } from "../version.js";
import {
CANVAS_DIMENSIONS,
@@ -75,8 +76,10 @@ const TAILWIND_BROWSER_INTEGRITY =
function probeVideo(filePath: string): VideoMeta | undefined {
try {
const ffprobePath = findFFprobe();
if (!ffprobePath) return undefined;
const raw = execFileSync(
"ffprobe",
ffprobePath,
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
{ encoding: "utf-8", timeout: 15_000 },
);
@@ -134,8 +137,13 @@ function isWebCompatible(codec: string): boolean {
function transcodeToMp4(inputPath: string, outputPath: string): Promise<boolean> {
return new Promise((resolvePromise) => {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) {
resolvePromise(false);
return;
}
const child = spawn(
"ffmpeg",
ffmpegPath,
[
"-i",
inputPath,
@@ -345,8 +353,7 @@ async function handleVideoFile(
);
}
} else {
const msg =
"ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: brew install ffmpeg";
const msg = `ffprobe not found — using defaults (1920x1080, 5s, 30fps). Install: ${getFFmpegInstallHint()}`;
if (interactive) {
clack.log.warn(msg);
} else {
@@ -409,10 +416,10 @@ async function handleVideoFile(
} else {
if (interactive) {
clack.log.warn(c.dim("ffmpeg not installed — cannot transcode."));
clack.log.info(c.accent("Install: brew install ffmpeg"));
clack.log.info(c.accent(`Install: ${getFFmpegInstallHint()}`));
} else {
console.log(c.warn("ffmpeg not installed — cannot transcode. Copying original."));
console.log(c.dim("Install: ") + c.accent("brew install ffmpeg"));
console.log(c.dim("Install: ") + c.accent(getFFmpegInstallHint()));
}
copyFileSync(videoPath, resolve(destDir, localVideoName));
}
+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");
+29 -17
View File
@@ -65,7 +65,7 @@ import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { runEnvironmentChecks } from "../browser/preflight.js";
import type { ProducerLogger, RenderJob } from "@hyperframes/producer";
import {
normalizeResolutionFlag,
@@ -967,30 +967,42 @@ export async function renderLocal(
outputPath: string,
options: RenderOptions,
): Promise<void> {
const producer = await loadProducer();
if (!findFFmpeg()) {
errorBox(
"FFmpeg not found",
"FFmpeg is required to encode video. The render cannot proceed without it.",
getFFmpegInstallHint(),
);
const preflight = await runEnvironmentChecks({
projectDir,
browserPath: options.browserPath,
includeBrowser: true,
includeDisk: true,
includeWindowsUnc: true,
});
const failedChecks = preflight.outcomes.filter((outcome) => !outcome.ok);
if (failedChecks.length > 0) {
for (const check of failedChecks) {
errorBox(check.title ?? `${check.name} check failed`, check.detail, check.hint);
}
process.exit(1);
}
if (!options.quiet) {
for (const outcome of preflight.outcomes) {
if (outcome.level === "warn") {
console.warn(c.warn(` ${outcome.name}: ${outcome.detail}`));
if (outcome.hint) console.warn(c.dim(` ${outcome.hint}`));
}
}
}
if (preflight.ffmpegPath) process.env.HYPERFRAMES_FFMPEG_PATH = preflight.ffmpegPath;
if (preflight.ffprobePath) process.env.HYPERFRAMES_FFPROBE_PATH = preflight.ffprobePath;
if (preflight.browser?.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = preflight.browser.executablePath;
}
const producer = await loadProducer();
const startTime = Date.now();
const logger = createRenderTelemetryLogger(
producer.createConsoleLogger?.("info") ?? createNoopProducerLogger(),
);
// Pass the resolved browser path to the producer via env var so
// resolveConfig() picks it up. This bridges the CLI's ensureBrowser()
// (which knows about system Chrome on macOS) with the engine's
// acquireBrowser() (which only checks the puppeteer cache).
if (options.browserPath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
process.env.PRODUCER_HEADLESS_SHELL_PATH = options.browserPath;
}
const job = producer.createRenderJob({
fps: options.fps,
quality: options.quality,
+5 -1
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file complexity
import { spawn } from "node:child_process";
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
@@ -7,6 +8,7 @@ import { resolveProject } from "../utils/project.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { findFFmpeg } from "../browser/ffmpeg.js";
import type { Example } from "./_examples.js";
/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
@@ -28,6 +30,8 @@ async function extractVideoFrameToBuffer(
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
const outPath = join(tmp, "frame.png");
try {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) return null;
const result = await new Promise<{ code: number | null; stderr: string; timedOut: boolean }>(
(resolvePromise) => {
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
@@ -48,7 +52,7 @@ async function extractVideoFrameToBuffer(
"-y",
outPath,
);
const ff = spawn("ffmpeg", args);
const ff = spawn(ffmpegPath, args);
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
+3 -14
View File
@@ -1,7 +1,9 @@
// fallow-ignore-file code-duplication complexity
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { findFFmpeg } from "../browser/ffmpeg.js";
import { downloadFile } from "../utils/download.js";
const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models");
@@ -205,20 +207,7 @@ export async function ensureModel(
}
export function hasFFmpeg(): boolean {
return hasBinary("ffmpeg");
}
export function hasFFprobe(): boolean {
return hasBinary("ffprobe");
}
function hasBinary(name: string): boolean {
try {
execFileSync(name, ["-version"], { stdio: "ignore", timeout: 5000 });
return true;
} catch {
return false;
}
return findFFmpeg() !== undefined;
}
export { MODELS_DIR, DEFAULT_MODEL };
+18 -4
View File
@@ -1,7 +1,9 @@
// fallow-ignore-file complexity
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
import { join, extname } from "node:path";
import { tmpdir } from "node:os";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { ensureWhisper, ensureModel, hasFFmpeg, DEFAULT_MODEL } from "./manager.js";
/**
@@ -124,9 +126,15 @@ function isVideoFile(filePath: string): boolean {
* Extract audio from a video file as 16kHz mono WAV (whisper requirement).
*/
function extractAudio(videoPath: string): string {
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) {
throw new Error(
`ffmpeg is required to extract audio from video. Install: ${getFFmpegInstallHint()}`,
);
}
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
execFileSync(
"ffmpeg",
ffmpegPath,
["-i", videoPath, "-vn", "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
{ stdio: "ignore", timeout: 120_000 },
);
@@ -138,8 +146,10 @@ function extractAudio(videoPath: string): string {
*/
function isWav16kMono(filePath: string): boolean {
try {
const ffprobePath = findFFprobe();
if (!ffprobePath) return false;
const raw = execFileSync(
"ffprobe",
ffprobePath,
["-v", "quiet", "-print_format", "json", "-show_streams", filePath],
{ encoding: "utf-8", timeout: 10_000 },
);
@@ -166,9 +176,13 @@ function prepareAudio(audioPath: string): string {
}
// Convert to whisper-compatible WAV
const ffmpegPath = findFFmpeg();
if (!ffmpegPath) {
throw new Error(`ffmpeg is required to prepare audio. Install: ${getFFmpegInstallHint()}`);
}
const wavPath = join(tmpdir(), `hyperframes-audio-${Date.now()}.wav`);
execFileSync(
"ffmpeg",
ffmpegPath,
["-i", audioPath, "-ar", "16000", "-ac", "1", "-f", "wav", "-y", wavPath],
{ stdio: "ignore", timeout: 120_000 },
);
@@ -205,7 +219,7 @@ export async function transcribe(
} else if (isVideoFile(inputPath)) {
if (!hasFFmpeg()) {
throw new Error(
"ffmpeg is required to extract audio from video. Install: brew install ffmpeg",
`ffmpeg is required to extract audio from video. Install: ${getFFmpegInstallHint()}`,
);
}
options?.onProgress?.("Extracting audio from video...");