diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts
index f88fcab76..bd8be250f 100644
--- a/packages/cli/src/commands/snapshot.ts
+++ b/packages/cli/src/commands/snapshot.ts
@@ -11,6 +11,12 @@ import type { Example } from "./_examples.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
+/** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the
+ * default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological
+ * clip (corrupt media, stalled network mount, codec edge case) cannot wedge
+ * `hyperframes snapshot` indefinitely. */
+const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000;
+
/**
* Extract a single frame from a video file at `timeSeconds` via FFmpeg.
* Used to work around Chrome-headless's inability to reliably seek
@@ -23,32 +29,45 @@ async function extractVideoFrameToBuffer(
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
const outPath = join(tmp, "frame.png");
try {
- const result = await new Promise<{ code: number | null; stderr: string }>((resolvePromise) => {
- // `-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 ff = spawn("ffmpeg", [
- "-hide_banner",
- "-loglevel",
- "error",
- "-ss",
- String(Math.max(0, timeSeconds)),
- "-i",
- videoPath,
- "-frames:v",
- "1",
- "-q:v",
- "2",
- "-y",
- outPath,
- ]);
- let stderr = "";
- ff.stderr.on("data", (d: Buffer) => {
- stderr += d.toString();
- });
- ff.on("close", (code) => resolvePromise({ code, stderr }));
- ff.on("error", () => resolvePromise({ code: null, stderr: "ffmpeg spawn failed" }));
- });
- if (result.code !== 0 || !existsSync(outPath)) return null;
+ const result = await new Promise<{ code: number | null; stderr: string; timedOut: boolean }>(
+ (resolvePromise) => {
+ // `-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 ff = spawn("ffmpeg", [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-ss",
+ String(Math.max(0, timeSeconds)),
+ "-i",
+ videoPath,
+ "-frames:v",
+ "1",
+ "-q:v",
+ "2",
+ "-y",
+ outPath,
+ ]);
+ let stderr = "";
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ ff.kill("SIGTERM");
+ }, FFMPEG_EXTRACT_TIMEOUT_MS);
+ ff.stderr.on("data", (d: Buffer) => {
+ stderr += d.toString();
+ });
+ ff.on("close", (code) => {
+ clearTimeout(timer);
+ resolvePromise({ code, stderr, timedOut });
+ });
+ ff.on("error", () => {
+ clearTimeout(timer);
+ resolvePromise({ code: null, stderr: "ffmpeg spawn failed", timedOut });
+ });
+ },
+ );
+ if (result.code !== 0 || result.timedOut || !existsSync(outPath)) return null;
return readFileSync(outPath);
} finally {
try {
@@ -228,12 +247,20 @@ async function captureSnapshots(
// already extracts each frame via FFmpeg and injects it as an
sibling
// over the