perfect-postcode/video/src/verify.ts
2026-05-13 12:12:11 +01:00

107 lines
3.2 KiB
TypeScript

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 <storyboard> [videoPath] [posterPath]
// Defaults: videoPath=output/<storyboard>/recording.mp4,
// posterPath=output/<storyboard>/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 <storyboard> 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);