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