mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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:
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user