From 37413320778db8817d20a2c68b592cbc2a8811ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Jul 2026 05:04:10 +0000 Subject: [PATCH] fix(cli): reject unsupported MP4 encoders --- packages/cli/src/browser/ffmpeg.test.ts | 3 ++- packages/cli/src/browser/ffmpeg.ts | 9 ++++++- packages/cli/src/commands/render.test.ts | 30 ++++++++++++++++++++++++ packages/cli/src/commands/render.ts | 14 ++++++++++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 0ffe39a770..8e98d1f83b 100644 --- a/packages/cli/src/browser/ffmpeg.test.ts +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -47,11 +47,12 @@ describe("resolveH264EncoderMode", () => { }); it("does not treat a compiled Linux hardware encoder as usable", async () => { - const { resolveH264EncoderMode } = await import("./ffmpeg.js"); + const { H264EncoderUnavailableError, resolveH264EncoderMode } = await import("./ffmpeg.js"); const encoders = ` V....D h264_vaapi H.264/AVC (VAAPI) `; + expect(() => resolveH264EncoderMode(encoders, false)).toThrow(H264EncoderUnavailableError); expect(() => resolveH264EncoderMode(encoders, false)).toThrow( "neither libx264 nor VideoToolbox", ); diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index bcc51a8631..972ecd74c3 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -6,6 +6,13 @@ export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hyperframes/parsers/ff-binar export type H264EncoderMode = "software" | "gpu"; +export class H264EncoderUnavailableError extends Error { + constructor() { + super("This FFmpeg build has neither libx264 nor VideoToolbox H.264 encoding."); + this.name = "H264EncoderUnavailableError"; + } +} + /** * Select the H.264 encoder class supported by an FFmpeg build. * @@ -20,7 +27,7 @@ export function resolveH264EncoderMode( if (gpuRequested) return "gpu"; if (/\blibx264\b/.test(ffmpegEncodersOutput)) return "software"; if (/\bh264_videotoolbox\b/.test(ffmpegEncodersOutput)) return "gpu"; - throw new Error("This FFmpeg build has neither libx264 nor VideoToolbox H.264 encoding."); + throw new H264EncoderUnavailableError(); } export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean): H264EncoderMode { diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 48f0ce7c20..21f71beb48 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -148,6 +148,12 @@ vi.mock("../telemetry/events.js", () => ({ })); vi.mock("../browser/ffmpeg.js", () => ({ + H264EncoderUnavailableError: class H264EncoderUnavailableError extends Error { + constructor() { + super("This FFmpeg build has neither libx264 nor VideoToolbox H.264 encoding."); + this.name = "H264EncoderUnavailableError"; + } + }, detectH264EncoderMode: vi.fn(() => { if (ffmpegEncoderState.error) throw ffmpegEncoderState.error; return ffmpegEncoderState.mode; @@ -398,6 +404,30 @@ describe("renderLocal browser GPU config", () => { expect(producerState.createdJobs[0]?.useGpu).toBe(true); }); + it("rejects MP4 before producer startup when FFmpeg has no supported H.264 encoder", async () => { + const { H264EncoderUnavailableError } = await import("../browser/ffmpeg.js"); + ffmpegEncoderState.error = new H264EncoderUnavailableError(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "high", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "force-sdr", + quiet: true, + }), + ).rejects.toThrow("Command failed"); + + expect(producerState.createdJobs).toHaveLength(0); + const output = error.mock.calls.flat().join("\n"); + expect(output).toContain("MP4 H.264 encoder unavailable"); + expect(output).toContain("brew install ffmpeg"); + expect(output).toContain("--format webm"); + }); + it("lets the encoder surface its own error when capability detection fails", async () => { ffmpegEncoderState.error = new Error("encoder probe timed out"); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 9b3d067e4c..65610669b8 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -75,7 +75,11 @@ import { isDevMode } from "../utils/env.js"; import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { runEnvironmentChecks } from "../browser/preflight.js"; -import { detectH264EncoderMode } from "../browser/ffmpeg.js"; +import { + detectH264EncoderMode, + getFFmpegInstallHint, + H264EncoderUnavailableError, +} from "../browser/ffmpeg.js"; import { chromeLaunchRemediation } from "../browser/linuxDeps.js"; import { macosOldChromeCrashRemediation } from "../browser/macosOldChromeCrash.js"; import { killOrphanedProcesses } from "../utils/orphanCleanup.js"; @@ -817,6 +821,14 @@ export async function renderLocal( try { encoderMode = detectH264EncoderMode(preflight.ffmpegPath, false); } catch (error) { + if (error instanceof H264EncoderUnavailableError) { + errorBox( + "MP4 H.264 encoder unavailable", + error.message, + `Install an FFmpeg build with libx264 support (${getFFmpegInstallHint()}), or render WebM instead: hyperframes render --format webm --output output.webm`, + ); + failCommand(); + } // Capability probing is advisory. Let the real encode surface the // authoritative FFmpeg error instead of failing here with a bare stack. if (!options.quiet) {