import { createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; import { existsSync, mkdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); /** On-disk cache for extracted loose-deliverable poster frames. */ const THUMB_DIR = join(homedir(), '.videoclaw-monitor', 'thumbs'); /** Concurrency cap for first-run extraction so ~60 videos don't serialize. */ const CONCURRENCY = 4; /** ffmpeg extraction timeout (ms) — a hung/unreadable video can't block forever. */ const FFMPEG_TIMEOUT_MS = 20_000; /** * Deterministic, collision-safe cache path for a video's poster frame. * A short sha256 prefix of the absolute video path keys the .jpg, so the same * video always maps to the same cache file (and distinct videos never collide). */ export function looseThumbPath(videoPath: string): string { const hash = createHash('sha256').update(videoPath).digest('hex').slice(0, 20); return join(THUMB_DIR, `${hash}.jpg`); } /** * Ensure a cached poster frame exists for `videoPath`, returning its path. * * Cache hit: returns the existing thumb path when it is present AND newer than * the source video (mtime compare) — so a re-rendered video re-extracts. * Otherwise shells out to ffmpeg to grab a single frame at t=1s, scaled to * 480px wide. Returns `undefined` on ANY failure (no ffmpeg, unreadable video, * timeout, empty output) — never throws. */ export async function ensureLooseThumbnail(videoPath: string): Promise { const thumbPath = looseThumbPath(videoPath); try { // Cache hit only when the thumb is newer than the source video. if (existsSync(thumbPath)) { const vst = statSync(videoPath); const tst = statSync(thumbPath); if (tst.mtimeMs >= vst.mtimeMs && tst.size > 0) return thumbPath; } mkdirSync(THUMB_DIR, { recursive: true }); await execFileAsync( 'ffmpeg', ['-y', '-ss', '1', '-i', videoPath, '-frames:v', '1', '-vf', 'scale=480:-1', thumbPath], { timeout: FFMPEG_TIMEOUT_MS }, ); // Verify the output actually landed and is non-empty before returning it. if (existsSync(thumbPath) && statSync(thumbPath).size > 0) return thumbPath; return undefined; } catch { return undefined; } } /** * Warm the thumbnail cache for many videos with a bounded concurrency window * (CONCURRENCY at a time). Individual failures are ignored — a missing or * unreadable video simply has no cached thumb and falls back to the gradient * placeholder in the UI. */ export async function ensureLooseThumbnails(videos: string[]): Promise { let next = 0; async function worker(): Promise { while (next < videos.length) { const i = next++; await ensureLooseThumbnail(videos[i]); } } const workers = Array.from({ length: Math.min(CONCURRENCY, videos.length) }, () => worker()); await Promise.all(workers); }