From a2676d8329ef59f5b81524e727416f0bb5728be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 12 Jul 2026 18:33:09 +0000 Subject: [PATCH] fix(cli): fail video snapshots without FFmpeg --- packages/cli/src/commands/snapshot.test.ts | 19 ++++++++++++++++++- packages/cli/src/commands/snapshot.ts | 12 +++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 4cb0d394c7..ce57044abb 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js"; +import { + computeSnapshotTimes, + parseZoomScale, + requireSnapshotFfmpeg, + tailFrameTime, +} from "./snapshot.js"; // --zoom's crop-region math (selector bbox + padding + clamp, exact region // form, no-match error) is owned by and tested in @@ -79,3 +84,15 @@ describe("parseZoomScale (--zoom-scale)", () => { expect(parseZoomScale("-1")).toBe(3); }); }); + +describe("requireSnapshotFfmpeg", () => { + it("rejects video snapshot extraction when FFmpeg is unavailable", () => { + expect(() => requireSnapshotFfmpeg(undefined)).toThrow( + /FFmpeg is required to extract video frames for snapshots/, + ); + }); + + it("preserves the resolved FFmpeg executable", () => { + expect(requireSnapshotFfmpeg("C:\\tools\\ffmpeg.exe")).toBe("C:\\tools\\ffmpeg.exe"); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index e1f34f9615..0aefee547c 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -17,7 +17,7 @@ import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { c } from "../ui/colors.js"; -import { findFFmpeg } from "../browser/ffmpeg.js"; +import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { parseAngle, type Camera } from "./motionShotLayout.js"; import type { Example } from "./_examples.js"; @@ -60,6 +60,13 @@ function orbitStageSource(): string { * `hyperframes snapshot` indefinitely. */ const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000; +export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { + if (ffmpegPath) return ffmpegPath; + throw new Error( + `FFmpeg is required to extract video frames for snapshots. ${getFFmpegInstallHint()}`, + ); +} + /** * Extract a single frame from a video file at `timeSeconds` via FFmpeg. * Used to work around Chrome-headless's inability to reliably seek @@ -73,8 +80,7 @@ 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 ffmpegPath = requireSnapshotFfmpeg(findFFmpeg()); // `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy // (±1 frame) and orders of magnitude faster than the decode-and-scan alternative. const args = ["-hide_banner", "-loglevel", "error"];