Skip to content
Closed
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
19 changes: 18 additions & 1 deletion packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
});
});
12 changes: 9 additions & 3 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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"];
Expand Down
Loading