mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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,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,
|
||||
|
||||
Reference in New Issue
Block a user