From 3d7e26aabfa34d1a96cc66b7b59cd646711bf216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 12:55:46 -0400 Subject: [PATCH] fix(render): diagnose unlaunchable Windows FFmpeg (#2430) --- packages/cli/src/browser/preflight.test.ts | 31 ++++++++++++++++++ packages/cli/src/browser/preflight.ts | 35 ++++++++++++++++----- packages/engine/src/utils/runFfmpeg.test.ts | 17 ++++++++++ packages/engine/src/utils/runFfmpeg.ts | 8 +++++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/browser/preflight.test.ts b/packages/cli/src/browser/preflight.test.ts index 0d1d56b40..0b4afc081 100644 --- a/packages/cli/src/browser/preflight.test.ts +++ b/packages/cli/src/browser/preflight.test.ts @@ -4,6 +4,13 @@ import { parseToolVersion, runEnvironmentChecks } from "./preflight.js"; import * as manager from "./manager.js"; import * as linuxDeps from "./linuxDeps.js"; +const execFileSync = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync, +})); + describe("runEnvironmentChecks", () => { const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH; const originalFfprobePath = process.env.HYPERFRAMES_FFPROBE_PATH; @@ -11,6 +18,7 @@ describe("runEnvironmentChecks", () => { beforeEach(() => { process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath; process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath; + execFileSync.mockReturnValue("ffmpeg version 7.1.1\n"); }); afterEach(() => { @@ -57,6 +65,29 @@ describe("runEnvironmentChecks", () => { }); }); + it("blocks rendering when the selected FFmpeg binary cannot launch", async () => { + execFileSync.mockImplementation((binaryPath: string) => { + if (binaryPath !== process.env.HYPERFRAMES_FFMPEG_PATH) return "ffprobe version 7.1.1\n"; + throw Object.assign(new Error("Command failed with exit code 3221225781"), { + status: 3221225781, + }); + }); + + const result = await runEnvironmentChecks(); + const ffmpeg = result.outcomes.find((outcome) => outcome.name === "FFmpeg"); + + expect(ffmpeg).toMatchObject({ + ok: false, + level: "error", + title: "FFmpeg cannot start", + path: process.execPath, + }); + expect(ffmpeg?.detail).toContain(process.execPath); + expect(ffmpeg?.detail).toContain("3221225781"); + expect(ffmpeg?.hint).toContain("working 64-bit FFmpeg build"); + expect(result.ffmpegPath).toBeUndefined(); + }); + it("validates an explicit browser path without needing browser discovery", async () => { const result = await runEnvironmentChecks({ includeBrowser: true, diff --git a/packages/cli/src/browser/preflight.ts b/packages/cli/src/browser/preflight.ts index 712ae9c16..8b68e2c74 100644 --- a/packages/cli/src/browser/preflight.ts +++ b/packages/cli/src/browser/preflight.ts @@ -55,15 +55,23 @@ function configuredMissingDetail(envName: string): string | undefined { return `Configured path does not exist: ${envName}="${configured}"`; } -function readToolVersion(binaryPath: string): string { +type ToolVersionResult = { ok: true; detail: string } | { ok: false; detail: string }; + +function readToolVersion(binaryPath: string): ToolVersionResult { 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; + return { ok: true, detail: version ? `${version} at ${binaryPath}` : binaryPath }; + } catch (error) { + const status = + typeof error === "object" && error !== null && "status" in error ? error.status : undefined; + const exitDetail = typeof status === "number" ? ` (exit code ${status})` : ""; + return { + ok: false, + detail: `Failed to run "${binaryPath}" -version${exitDetail}.`, + }; } } @@ -82,7 +90,19 @@ function checkFFmpeg(): EnvironmentCheckOutcome { const path = findFFmpeg(); if (path) { - return { name: "FFmpeg", ok: true, level: "ok", detail: readToolVersion(path), path }; + const version = readToolVersion(path); + if (!version.ok) { + return { + name: "FFmpeg", + ok: false, + level: "error", + title: "FFmpeg cannot start", + detail: version.detail, + hint: "Install a working 64-bit FFmpeg build with all required runtime DLLs.", + path, + }; + } + return { name: "FFmpeg", ok: true, level: "ok", detail: version.detail, path }; } return { @@ -110,7 +130,8 @@ function checkFFprobe(): EnvironmentCheckOutcome { const path = findFFprobe(); if (path) { - return { name: "FFprobe", ok: true, level: "ok", detail: readToolVersion(path), path }; + const version = readToolVersion(path); + return { name: "FFprobe", ok: true, level: "ok", detail: version.detail, path }; } return { @@ -270,7 +291,7 @@ export async function runEnvironmentChecks( return { outcomes, - ...(ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}), + ...(ffmpeg.ok && ffmpeg.path ? { ffmpegPath: ffmpeg.path } : {}), ...(ffprobe.path ? { ffprobePath: ffprobe.path } : {}), ...(browser ? { browser } : {}), }; diff --git a/packages/engine/src/utils/runFfmpeg.test.ts b/packages/engine/src/utils/runFfmpeg.test.ts index bb702c6fb..21e427787 100644 --- a/packages/engine/src/utils/runFfmpeg.test.ts +++ b/packages/engine/src/utils/runFfmpeg.test.ts @@ -6,9 +6,12 @@ import { formatFfmpegError } from "./runFfmpeg.js"; describe("formatFfmpegError", () => { const originalPlatform = process.platform; + const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH; afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + if (originalFfmpegPath === undefined) delete process.env.HYPERFRAMES_FFMPEG_PATH; + else process.env.HYPERFRAMES_FFMPEG_PATH = originalFfmpegPath; }); it("reports exit code alone when stderr is empty", () => { @@ -57,6 +60,20 @@ describe("formatFfmpegError", () => { expect(formatFfmpegError(3221225595, "")).toContain("wrong architecture"); }); + + it.each([3221225781, -1073741515])( + "maps Windows DLL-not-found exit code %s to selected-path guidance", + (exitCode) => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.HYPERFRAMES_FFMPEG_PATH = "/tools/ffmpeg.exe"; + + const message = formatFfmpegError(exitCode, ""); + + expect(message).toContain("0xC0000135 (STATUS_DLL_NOT_FOUND)"); + expect(message).toContain("/tools/ffmpeg.exe"); + expect(message).toContain("working 64-bit Windows FFmpeg build"); + }, + ); }); function createSpawnSpy() { diff --git a/packages/engine/src/utils/runFfmpeg.ts b/packages/engine/src/utils/runFfmpeg.ts index 418422f99..5fac74dbb 100644 --- a/packages/engine/src/utils/runFfmpeg.ts +++ b/packages/engine/src/utils/runFfmpeg.ts @@ -29,6 +29,14 @@ const DEFAULT_STDERR_TAIL_LINES = 15; function formatWindowsFfmpegExit(exitCode: number | null): string | undefined { if (process.platform !== "win32" || exitCode === null) return undefined; + if (exitCode === 3221225781 || exitCode === -1073741515) { + const ffmpegPath = getFfmpegBinary(); + return ( + `[FFmpeg] Windows could not start "${ffmpegPath}": ` + + "0xC0000135 (STATUS_DLL_NOT_FOUND). A required DLL could not be loaded. " + + "Install a working 64-bit Windows FFmpeg build with all required runtime DLLs." + ); + } if (exitCode === 3221225595 || exitCode === -1073741701) { return ( "[FFmpeg] Windows could not start ffmpeg.exe (STATUS_INVALID_IMAGE_FORMAT). " +