Skip to content

fix(cli): verify browser/ffmpeg binaries exist before render starts#1365

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/windows-env-preflight
Jun 12, 2026
Merged

fix(cli): verify browser/ffmpeg binaries exist before render starts#1365
miguel-heygen merged 2 commits into
mainfrom
fix/windows-env-preflight

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

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.

Renders on Windows failed at runtime with 'Browser was not found at the
configured executablePath' when the cached chrome-headless-shell binary had
been removed (AV quarantine, partial download) but the cache manifest still
referenced it, and with ffprobe ENOENT when ffmpeg was installed without
ffprobe.

- Gate cache-manifest executablePath on existsSync and re-download when the
  binary is missing; same guard on the engine env-var path.
- Shared environment preflight (preflight.ts) used by both render and doctor:
  checks ffmpeg, ffprobe, browser, disk, and UNC paths before starting.
- Resolve absolute ffmpeg/ffprobe paths once and pass them to engine spawns
  instead of relying on bare PATH strings.
- Map opaque Windows ffmpeg exit codes to actionable messages.
@miguel-heygen miguel-heygen force-pushed the fix/windows-env-preflight branch from 67138ec to 94f14f3 Compare June 12, 2026 04:13

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve-with-nits. Solid Windows-pain PR with real teeth — preflight + binary-path env vars + Windows exit-code mapping + cache self-heal all land cleanly. CI is fully green including Render on windows-latest and the full regression matrix. The findings below are scope/edge-case calls, not blockers.

Concerns

1. executeRenderJob is also called from studioServer.ts and benchmark.ts — preflight is bypassed there. packages/cli/src/server/studioServer.ts:359 and packages/cli/src/commands/benchmark.ts:130 both invoke producer.executeRenderJob(...) directly, but neither calls runEnvironmentChecks() first. assertConfiguredFfmpegBinariesExist() inside executeRenderJob only fires when HYPERFRAMES_FFMPEG_PATH / HYPERFRAMES_FFPROBE_PATH are set to a missing path — if the env var is unset and ffmpeg simply isn't on PATH, the assert is a no-op and we still hit the mid-render spawn ffmpeg ENOENT the PR is trying to eliminate. A Windows user hitting "Render" in Studio reproduces the original bug. Either (a) lift the preflight into executeRenderJob so all in-process render paths share it, or (b) call runEnvironmentChecks() from studioServer.ts and benchmark.ts before the job starts.

2. Bare "ffmpeg"/"ffprobe" strings still in several spawn sites. PR catches the render hot path, but these survive:

  • packages/cli/src/commands/init.ts:79, 138hyperframes init --video clip.mp4 probes + transcodes with bare execFileSync("ffprobe", ...) / spawn("ffmpeg", ...). Same Windows ENOENT cliff that motivates this PR.
  • packages/core/src/studio-api/helpers/waveform.ts:33 and mediaValidation.ts:29 — studio dev-server runtime helpers (waveform decode, upload validation).
  • packages/producer/src/parity-harness.ts:105, regression-harness.ts:505, utils/audioRegression.ts:178, 308 — CI harnesses, dev-only; safe to defer.
  • packages/player/tests/perf/scenarios/06-parity.ts — test scenario; safe to defer.

Two clear scope bumps (init.ts + studio-api waveform.ts/mediaValidation.ts) are user-facing on Windows; the rest are internal. Worth at least a follow-up issue noting the surface.

3. pathCache in engine/utils/ffmpegBinaries.ts:9 is module-scoped + never invalidated. findOnPath() caches the resolved path for the lifetime of the Node process. In one-shot hyperframes render invocations this is fine. But the studio dev server (hyperframes serve) is long-lived and a user reinstalling ffmpeg mid-session would keep getting the cached (possibly stale) resolution. The cache also doesn't observe the env vars — if a caller sets HYPERFRAMES_FFMPEG_PATH mid-process, getConfiguredBinary re-reads process.env correctly (good) but findOnPath results survive the env change. Low priority — likely fine for CLI render — but worth a comment noting the assumption, or invalidating the cache when the env var changes.

4. findOnPath (engine/utils/ffmpegBinaries.ts:11-30) uses execFileSync("which" | "where", [name]) which silently fails on Windows in subtle ways. where returns matches from PATH-EXT-resolved candidates without honoring PATHEXT, and which won't exist on bare Windows shells (no Git Bash) — but the PR is in the win32 branch already so it's where, which is fine. The CLI-side equivalent in packages/cli/src/browser/ffmpeg.ts:10 instead uses execSync with a shell-string ${cmd} ${name} — slightly less safe (if name ever became dynamic, command injection vector) but currently fine since name is a literal union type. Nit-level, but the two implementations of the same function (CLI side and engine side) drift in style. Worth consolidating to one — see also the fallow-ignore-file code-duplication markers the PR itself sprinkles, which acknowledge this.

5. Preflight runEnvironmentChecks doesn't include includeWindowsUnc or includeDisk in doctor. doctor.ts:201 calls runEnvironmentChecks({ includeBrowser: true }) only. Disk is covered by the legacy checkDisk already, but the new UNC-path warning fires in render and not in doctor — meaning a Windows user running hyperframes doctor from a \\server\share checkout won't be warned. Cheap to fix: runEnvironmentChecks({ includeBrowser: true, includeWindowsUnc: true }).

6. UNC warning is gated on projectDir.startsWith("\\\\") (preflight.ts:160) but runEnvironmentChecks defaults projectDir = process.cwd(). In render.ts:970 you pass projectDir (the resolved project dir), not process.cwd(). A user with a local cwd but --project \\server\share\thing is fine. A user with cwd on a UNC share but projectDir local skips the warning — and Chromium will refuse to launch with --user-data-dir under a UNC cwd too. Marginal but worth at least documenting which path is being checked.

Nits

  • packages/engine/src/utils/ffmpegBinaries.ts:1-3import { existsSync } from "fs" / from "path" / from "child_process" use bare-spec node imports instead of the node: prefix that the rest of the codebase (e.g. preflight.ts:1-3) uses. Style consistency.
  • getConfiguredBinary returns the bare binary name ("ffmpeg") as a fallback when neither env var nor PATH yields anything. This means a downstream spawn(getFfmpegBinary(), …) will still produce spawn ffmpeg ENOENT on Windows even though runFfmpeg then wraps it through formatFfmpegError. The wrapping is fine — but the getFfmpegBinary() contract being "returns a guess if nothing found" rather than string | undefined quietly defeats the "verify exists before render" invariant in the PR title. A docstring noting the fallback intent would help future readers.
  • packages/cli/src/commands/render.ts:1000 mutates process.env.HYPERFRAMES_FFMPEG_PATH to thread the resolved path into the engine — works, but it's a process-global side-effect from inside a render function. Mostly OK for the CLI's one-shot lifecycle; conflicts with studioServer.ts calling renderLocal-equivalent paths in-process across multiple renders, where the env mutation persists into subsequent renders. (Not load-bearing today, just brittle.)
  • parseToolVersion is now exported from both commands/doctor.ts (re-export) and browser/preflight.ts (definition). Pick one home; the re-export through doctor was for backwards-compat with existing tests, but if no external caller depends on it, drop the re-export. (packages/cli/src/commands/doctor.ts:33.)
  • formatWindowsFfmpegExit (runFfmpeg.ts:30-44) covers two NTSTATUS codes — STATUS_INVALID_IMAGE_FORMAT and STATUS_DLL_INIT_FAILED. Worth a comment naming the NTSTATUS constants so the magic numbers are searchable.

Questions

  • Was init.ts's --video path intentionally out of scope, or did it fall off the list? Same Windows-PATHEXT/ENOENT failure mode, same one-line fix (findFFmpeg() instead of bare string).
  • Is the studio dev server's "Render" button (studioServer.ts:359) considered part of "render starts" UX? If yes — same preflight gap as benchmark.ts. If no — fine, but worth a note in the PR body so reviewers know it's deliberate.

What I didn't verify

  • I didn't run the Windows render verification job locally; I'm trusting the green CI signal on Render on windows-latest (passed 5m38s).
  • The Cached binary missing — re-downloading… path in manager.ts re-downloads via downloadBrowser() which can take 30-90s. I didn't trace whether errorBox or progress UI surfaces to the user during that window — if not, the CLI will appear silent during the re-download.
  • I didn't probe whether the formatWindowsFfmpegExit exit codes are the actual codes Windows surfaces for these scenarios — happy to trust the author's reproduction.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — Via reviewed, stamp applied.

— Vai

- Replace hardcoded 'brew install ffmpeg' with getFFmpegInstallHint()
  in transcribe.ts and init.ts (Via's feedback — cross-platform hints)
- Use findFFprobe()/findFFmpeg() in init.ts instead of bare "ffprobe"/
  "ffmpeg" strings (Rames' feedback — unguarded spawn sites)
- Use HYPERFRAMES_FFMPEG_PATH env in waveform.ts (Rames' feedback —
  bare "ffmpeg" in core package)
- Show all failed preflight checks in render.ts, not just the first
  (Via's feedback — first-failure-only reporting)
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 3deb0f8:

Via's feedback:

  • ✅ Replaced all hardcoded brew install ffmpeg strings with getFFmpegInstallHint() in transcribe.ts (3 sites) and init.ts (3 sites) — now shows platform-appropriate install hints on macOS/Linux/Windows
  • render.ts now shows all failed preflight checks, not just the first — a user missing both ffmpeg and Chrome sees both errors in one run

Rames' feedback:

  • init.ts bare "ffprobe"findFFprobe() with early return if not found
  • init.ts bare "ffmpeg" in transcodeToMp4findFFmpeg() with graceful fallback
  • waveform.ts bare "ffmpeg" → reads HYPERFRAMES_FFMPEG_PATH env var (same pattern as ffmpegBinaries.ts in engine; can't import engine from core, so inlined the env lookup)

Not addressed (out of scope for this PR):

  • Studio executeRenderJob entry point + benchmark.ts bypass — these need their own preflight wiring, tracked separately
  • Engine pathCache staleness — correct for CLI one-shot; the studio dev server concern is valid but separate
  • doctor UNC warning — includeWindowsUnc flag needs to be threaded from the doctor command

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-stamping at new HEAD — nit fixes applied (cross-platform install hints, all-failed-checks render output, bare ffmpeg strings in init.ts/waveform.ts).

— Vai

@github-actions

Copy link
Copy Markdown

Fallow audit report

Found 8 findings.

Duplication (4)
Severity Rule Location Description
minor fallow/code-duplication packages/cli/src/commands/init.ts:303 Code clone group 1 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/helpers/waveform.ts:21 Code clone group 2 (26 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/AudioWaveform.tsx:24 Code clone group 2 (26 lines, 2 instances)
minor fallow/code-duplication scripts/generate-template-previews.ts:60 Code clone group 1 (5 lines, 2 instances)
Health (4)
Severity Rule Location Description
major fallow/high-crap-score packages/cli/src/commands/init.ts:77 'probeVideo' has CRAP score 88.0 (threshold: 30.0, cyclomatic 18)
major fallow/high-crap-score packages/cli/src/commands/init.ts:339 'handleVideoFile' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)
minor fallow/high-cognitive-complexity packages/cli/src/commands/init.ts:449 'applyResolutionPreset' has cognitive complexity 23 (threshold: 15)
critical fallow/high-crap-score packages/cli/src/commands/init.ts:645 'run' has CRAP score 810.9 (threshold: 30.0, cyclomatic 59)

Generated by fallow.

@miguel-heygen miguel-heygen merged commit cee6fd0 into main Jun 12, 2026
46 of 47 checks passed

Copy link
Copy Markdown
Collaborator Author

Merge activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants