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
+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(() => {