#!/usr/bin/env node // Fidelity check for a reveal clip, with ffmpeg: prints length, size, frame rate and audio, then writes // - a board with the product photo beside the first, middle and last frames (compare shape, label, colour); the "first" // frame is taken after the fade from black (--first-at, default 1.4 s), and // - a contact sheet of the whole clip, up to 24 frames spread evenly over its length (look for morphing, flicker, jumps). // Usage: // node scripts/check-frames.mjs --video reveal.mp4 --product product.jpg --board board.jpg --sheet sheet.jpg [--first-at 1.4] [--expect-duration 8] [--expect-resolution 720p] import { spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' const usage = 'Usage: node scripts/check-frames.mjs --video reveal.mp4 --product product.jpg --board board.jpg --sheet sheet.jpg [--first-at 1.4] [--expect-duration 8] [--expect-resolution 720p]' const VALUE_FLAGS = new Set(['video', 'product', 'board', 'sheet', 'first-at', 'expect-duration', 'expect-resolution']) const fail = (message) => { console.error(`${message}\n${usage}`); process.exit(1) } const options = {} const args = process.argv.slice(2) for (let i = 0; i < args.length; i += 1) { const arg = args[i] if (!arg.startsWith('--')) fail(`Unexpected argument "${arg}".`) const key = arg.slice(2) if (!VALUE_FLAGS.has(key)) fail(`Unknown flag --${key}.`) const value = args[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) options[key] = value i += 1 } for (const key of ['video', 'product', 'board', 'sheet']) if (!options[key]) fail(`--${key} is required.`) for (const key of ['video', 'product']) if (!existsSync(options[key])) fail(`Not found: ${options[key]}`) const ff = (argv) => { const run = spawnSync('ffmpeg', ['-y', '-v', 'error', ...argv], { encoding: 'utf8' }) if (run.status !== 0) fail(`ffmpeg failed: ${run.stderr}`) } const probe = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', options.video], { encoding: 'utf8' }) if (probe.error) fail('ffprobe was not found. Install ffmpeg 5 or newer (it includes ffprobe).') if (probe.status !== 0) fail(`${options.video} is not a video ffmpeg can read. Pass the MP4 clip.`) const info = JSON.parse(probe.stdout) const video = (info.streams ?? []).find((s) => s.codec_type === 'video') const audio = (info.streams ?? []).find((s) => s.codec_type === 'audio') const duration = Number(info.format?.duration) if (!video || ['mjpeg', 'png', 'webp', 'bmp', 'gif'].includes(video.codec_name) || !(duration >= 1)) { fail(`${options.video} is a still image or too short. Pass the video clip as --video and the product photo as --product.`) } if (!/\.(jpe?g|png|webp)$/i.test(options.product)) fail('--product must be the product photo (.jpg, .png or .webp).') const [top, bottom = '1'] = (video.r_frame_rate ?? '0/1').split('/') const shorter = Math.min(video.width, video.height) const resolution = shorter >= 1080 ? '1080p' : shorter >= 720 ? '720p' : shorter >= 480 ? '480p' : `${shorter}p` const sizeMb = Number(info.format.size) / 1024 / 1024 console.log(`Duration ${duration.toFixed(2)} s · ${video.width} x ${video.height} (${resolution}) · ${video.codec_name} @ ${(Number(top) / Number(bottom)).toFixed(2)} fps · audio ${audio ? audio.codec_name : 'none'} · ${sizeMb.toFixed(1)} MB`) const issues = [] if (options['expect-duration'] && Math.abs(duration - Number(options['expect-duration'])) > 0.5) issues.push(`duration ${duration.toFixed(2)} s, expected ${options['expect-duration']} s`) if (options['expect-resolution'] && options['expect-resolution'].toLowerCase() !== resolution) issues.push(`resolution ${resolution}, expected ${options['expect-resolution']}`) const work = mkdtempSync(join(tmpdir(), 'reveal-check-')) const height = 360 const firstAt = Number(options['first-at'] ?? 1.4) if (!Number.isFinite(firstAt) || firstAt < 0) fail('--first-at must be a number of seconds, 0 or more.') // The finished reveal starts on black, so the "first" frame is sampled after the fade (a raw clip can use --first-at 0). const times = [Math.min(firstAt, duration * 0.25), duration / 2, Math.max(0, duration - 0.6)] times.forEach((t, i) => ff(['-ss', t.toFixed(3), '-i', options.video, '-frames:v', '1', '-vf', `scale=-2:${height}`, join(work, `f${i}.png`)])) ff(['-i', options.product, '-i', join(work, 'f0.png'), '-i', join(work, 'f1.png'), '-i', join(work, 'f2.png'), '-filter_complex', `[0]scale=-2:${height}[p];[p][1][2][3]hstack=4`, options.board]) // Up to 24 frames spread over the whole clip: 2 per second for short clips, fewer per second for long ones. const frames = Math.min(24, Math.max(4, Math.round(duration * 2))) const cols = frames > 16 ? 6 : 4 const rows = Math.ceil(frames / cols) ff(['-i', options.video, '-vf', `fps=${(frames / duration).toFixed(4)},scale=320:-2,tile=${cols}x${rows}`, '-frames:v', '1', options.sheet]) rmSync(work, { recursive: true, force: true }) console.log(`Board (product | ${times.map((t) => `${t.toFixed(1)} s`).join(' | ')}): ${options.board}`) console.log(`Contact sheet (${frames} frames over ${duration.toFixed(1)} s): ${options.sheet}`) if (issues.length) { console.log(`Issues: ${issues.join('; ')}`) process.exit(1) } console.log('Open both images and check shape, label, colour and motion by eye before delivering.')