Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/src/browser/ffmpeg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/browser/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
Loading