import { execFileSync } from 'node:child_process'; import { existsSync, statSync } from 'node:fs'; import { OUTPUT_DIR } from './config.js'; import { recordedSizeFor, type Storyboard } from './script.js'; import { getStoryboard } from './storyboard.js'; interface Probe { streams?: { width?: number; height?: number; avg_frame_rate?: string; r_frame_rate?: string; }[]; format?: { duration?: string; size?: string; }; } function fail(message: string): never { console.error(`[verify] FAIL: ${message}`); process.exit(1); } function parseRate(rate: string | undefined): number { if (!rate) return 0; const [num, den] = rate.split('/').map(Number); if (!num || !den) return Number(rate) || 0; return num / den; } function probe(path: string): Probe { const raw = execFileSync( 'ffprobe', [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate', '-show_entries', 'format=duration,size', '-of', 'json', path, ], { encoding: 'utf8' } ); return JSON.parse(raw) as Probe; } function verifyVideo(path: string, storyboard: Storyboard) { if (!existsSync(path)) fail(`${path} is missing`); if (statSync(path).size === 0) fail(`${path} is empty`); const data = probe(path); const stream = data.streams?.[0]; if (!stream) fail(`${path} has no video stream`); const expectedSize = recordedSizeFor(storyboard.video); const { minDurationS, maxDurationS, outputFps } = storyboard.video; const duration = Number(data.format?.duration ?? 0); const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate); if (stream.width !== expectedSize.width || stream.height !== expectedSize.height) { fail( `${path} is ${stream.width}x${stream.height}, expected ${expectedSize.width}x${expectedSize.height}` ); } if (duration < minDurationS || duration > maxDurationS) { fail( `${path} duration is ${duration.toFixed(2)}s, expected ${minDurationS}-${maxDurationS}s` ); } if (Math.abs(fps - outputFps) > 0.1) { fail(`${path} is ${fps.toFixed(2)}fps, expected ${outputFps}fps`); } console.log( `[verify] ${path}: ${stream.width}x${stream.height}, ${duration.toFixed(2)}s, ${fps.toFixed(2)}fps` ); } function verifyImage(path: string) { if (!existsSync(path)) fail(`${path} is missing`); if (statSync(path).size === 0) fail(`${path} is empty`); console.log(`[verify] ${path}: ${statSync(path).size} bytes`); } // Usage: // node dist/verify.js [videoPath] [posterPath] // Defaults: videoPath=output//recording.mp4, // posterPath=output//poster.jpg. // If videoPath is given but posterPath is not, the poster check is skipped. const storyboardName = process.argv[2]; if (!storyboardName) { fail('verify: missing argument (e.g. `node dist/verify.js recording`)'); } const storyboard = getStoryboard(storyboardName); const videoPath = process.argv[3] ?? `${OUTPUT_DIR}/${storyboard.name}/recording.mp4`; const posterPath = process.argv[4] ?? (process.argv[3] ? undefined : `${OUTPUT_DIR}/${storyboard.name}/poster.jpg`); verifyVideo(videoPath, storyboard); if (posterPath) verifyImage(posterPath);