107 lines
3.2 KiB
TypeScript
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);
|