#!/usr/bin/env node // Checks a finished film: duration, size, frame rate, codec, audio, file size and +faststart, // looks for black frames and sudden flashes (outside the planned cuts), and writes a contact sheet // that you must open and look at (same person in every shot, no extra people, no morphing, no stray text). // // Usage: // node scripts/check-film.mjs --file film.mp4 [--edl film.mp4.edl.json] [--sheet film-sheet.jpg] // [--aspect 16:9|9:16|1:1|4:5] [--resolution 720p|1080p|480p] [--min-seconds 30] [--max-seconds 45] // [--max-mb 8] [--muted] [--ignore 6.2,11-14.5] // // --ignore takes seconds (each covers +-0.3 s) or ranges a-b where a brightness jump is expected and you // have looked at it yourself: firelight, flickering lanterns, a lightning flash, a camera flash. Ignored // moments are still listed as notes so the report stays honest. // // Requires ffmpeg and ffprobe. Node 18 or newer. Exit code 1 when a check fails. import { spawnSync } from 'node:child_process' import { existsSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs' const FLAGS = new Set(['file', 'edl', 'sheet', 'aspect', 'resolution', 'min-seconds', 'max-seconds', 'max-mb', 'muted', 'ignore']) const SWITCHES = new Set(['muted']) function fail(message) { console.error(message) process.exit(1) } function parse(argv) { const out = {} for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) fail(`Unexpected argument "${a}". Flags look like --name value.`) const key = a.slice(2) if (!FLAGS.has(key)) fail(`Unknown flag --${key}. Allowed: ${[...FLAGS].map((f) => `--${f}`).join(', ')}`) if (SWITCHES.has(key)) { out[key] = true; continue } const value = argv[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) out[key] = value i++ } return out } function number(value, name, min, max) { if (value === undefined) return undefined const n = Number(value) if (!Number.isFinite(n) || n < min || n > max) fail(`--${name} must be a number from ${min} to ${max}, got "${value}".`) return n } function run(cmd, args) { const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }) if (r.error?.code === 'ENOENT') fail(`${cmd} is not installed or not on PATH. Install ffmpeg (it includes ffprobe).`) return r } // True when the moov atom comes before mdat (the file can start playing before it has fully downloaded). function isFaststart(path) { const fd = openSync(path, 'r') const size = statSync(path).size const head = Buffer.alloc(8) let pos = 0 try { while (pos + 8 <= size) { readSync(fd, head, 0, 8, pos) let len = head.readUInt32BE(0) const type = head.toString('ascii', 4, 8) if (type === 'moov') return true if (type === 'mdat') return false if (len === 1) { const big = Buffer.alloc(8) readSync(fd, big, 0, 8, pos + 8) len = Number(big.readBigUInt64BE(0)) } if (len < 8) return false pos += len } } finally { closeSync(fd) } return false } const o = parse(process.argv.slice(2)) if (!o.file) fail('Usage: node scripts/check-film.mjs --file film.mp4 [--edl film.mp4.edl.json] [--aspect 16:9] [--resolution 720p] [--min-seconds 30] [--max-seconds 45] [--max-mb 8] [--muted]') if (!existsSync(o.file)) fail(`File not found: ${o.file}`) if (!/\.(mp4|mov|webm)$/i.test(o.file)) fail(`${o.file} is not a video file. Pass the finished .mp4.`) const aspect = o.aspect if (aspect && !['16:9', '9:16', '1:1', '4:5'].includes(aspect)) fail(`--aspect must be 16:9, 9:16, 1:1 or 4:5, not "${aspect}".`) const resolution = o.resolution if (resolution && !['480p', '720p', '1080p'].includes(resolution)) fail(`--resolution must be 480p, 720p or 1080p, not "${resolution}".`) const minS = number(o['min-seconds'], 'min-seconds', 0, 600) const maxS = number(o['max-seconds'], 'max-seconds', 0, 600) const maxMb = number(o['max-mb'], 'max-mb', 0.1, 2000) const p = run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', o.file]) if (p.status !== 0) fail(`ffprobe could not read ${o.file}. Is it a video file?`) const info = JSON.parse(p.stdout) const v = info.streams.find((s) => s.codec_type === 'video') if (!v) fail(`${o.file} has no video stream.`) const a = info.streams.find((s) => s.codec_type === 'audio') const duration = Number(info.format.duration) const [fn, fd] = (v.r_frame_rate || '0/1').split('/').map(Number) const fps = fd ? fn / fd : 0 const mb = statSync(o.file).size / 1024 / 1024 const issues = [] const notes = [] const short = Math.min(v.width, v.height) const resLabel = short >= 1080 ? '1080p' : short >= 720 ? '720p' : short >= 480 ? '480p' : `${short}p` const ratio = v.width / v.height const ratios = { '16:9': 16 / 9, '9:16': 9 / 16, '1:1': 1, '4:5': 4 / 5 } if (aspect && Math.abs(ratio - ratios[aspect]) > 0.03) issues.push(`Aspect is ${v.width}x${v.height}, expected ${aspect}.`) if (resolution && resLabel !== resolution) issues.push(`Resolution is ${resLabel} (${v.width}x${v.height}), expected ${resolution}.`) if (minS !== undefined && duration < minS - 0.25) issues.push(`Duration ${duration.toFixed(2)} s is under ${minS} s.`) if (maxS !== undefined && duration > maxS + 0.25) issues.push(`Duration ${duration.toFixed(2)} s is over ${maxS} s.`) if (maxMb !== undefined && mb > maxMb) issues.push(`File is ${mb.toFixed(2)} MB, over the ${maxMb} MB limit. Re-run assemble with --max-mb ${maxMb}.`) if (v.codec_name !== 'h264') issues.push(`Video codec is ${v.codec_name}; H.264 plays everywhere, re-export with libx264.`) if (v.pix_fmt !== 'yuv420p') issues.push(`Pixel format is ${v.pix_fmt}; use yuv420p for browser playback.`) if (Math.abs(fps - 24) > 0.05 && Math.abs(fps - 25) > 0.05 && Math.abs(fps - 30) > 0.05) issues.push(`Frame rate ${fps.toFixed(2)} fps is unusual.`) if (o.muted && a) issues.push('The file has an audio track but --muted was asked for (landing-page previews must be silent). Re-run assemble with --audio none and no --music.') if (!isFaststart(o.file)) issues.push('moov atom is at the end: add -movflags +faststart so the video starts before it fully downloads.') // Planned cut and dissolve windows, so real cuts are not reported as flashes. let windows = [] if (o.edl) { if (!existsSync(o.edl)) fail(`Edit list not found: ${o.edl}`) const edl = JSON.parse(readFileSync(o.edl, 'utf8')) windows = (edl.joins || []).map((j) => [j.at - 0.12, j.at + (j.d || 0) + 0.12]) } const ignored = [] if (o.ignore) { for (const part of String(o.ignore).split(',').map((x) => x.trim()).filter(Boolean)) { const m = part.match(/^(\d+(?:\.\d+)?)(?:-(\d+(?:\.\d+)?))?$/) if (!m) fail(`--ignore takes seconds or ranges like 6.2,11-14.5, not "${part}".`) const a = Number(m[1]) const b = m[2] === undefined ? undefined : Number(m[2]) if (b !== undefined && b < a) fail(`--ignore range "${part}" ends before it starts.`) ignored.push(b === undefined ? [a - 0.3, a + 0.3] : [a, b]) } } const planned = (t) => windows.some(([s, e]) => t >= s && t <= e) const dismissed = (t) => ignored.some(([s, e]) => t >= s && t <= e) // Black frames (an end card on near-black is expected; anything else is a defect). const black = run('ffmpeg', ['-hide_banner', '-nostats', '-i', o.file, '-vf', 'blackdetect=d=0.25:pix_th=0.06', '-an', '-f', 'null', '-']) const blacks = [...(black.stderr || '').matchAll(/black_start:([\d.]+) black_end:([\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]) for (const [s, e] of blacks) { const atEnd = e >= duration - 0.3 if (!atEnd && !planned(s) && dismissed(s)) notes.push(`Black ${s.toFixed(2)}-${e.toFixed(2)} s (ignored with --ignore).`) else if (!atEnd && !planned(s)) issues.push(`Black frames from ${s.toFixed(2)} s to ${e.toFixed(2)} s.`) else notes.push(`Black ${s.toFixed(2)}-${e.toFixed(2)} s (end card or planned fade).`) } // Sudden brightness jumps between consecutive frames: flicker, a pop, or an unplanned cut. const stats = run('ffmpeg', ['-hide_banner', '-nostats', '-i', o.file, '-vf', 'scale=160:-2,signalstats,metadata=print:key=lavfi.signalstats.YAVG', '-an', '-f', 'null', '-']) const ys = [...(stats.stderr || '').matchAll(/pts_time:([\d.]+)[\s\S]*?YAVG=([\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]) let flashes = 0 for (let i = 1; i < ys.length; i++) { const [t, y] = ys[i] const jump = Math.abs(y - ys[i - 1][1]) if (jump > 14 && !planned(t) && dismissed(t)) notes.push(`Brightness jump ${jump.toFixed(1)} at ${t.toFixed(2)} s (ignored with --ignore).`) else if (jump > 14 && !planned(t)) { flashes++ if (flashes <= 5) issues.push(`Brightness jumps by ${jump.toFixed(1)} at ${t.toFixed(2)} s (flicker, pop or unplanned cut). Look at this moment; if it is real firelight or a lantern, re-run with --ignore ${t.toFixed(1)}.`) } } if (flashes > 5) issues.push(`${flashes - 5} more brightness jumps not listed.`) // Contact sheet: one frame per second, six across. const sheet = o.sheet || o.file.replace(/\.mp4$/i, '') + '-sheet.jpg' const rows = Math.max(1, Math.ceil(duration / 6)) const tileW = v.width >= v.height ? 320 : 180 const s = run('ffmpeg', ['-y', '-hide_banner', '-v', 'error', '-i', o.file, '-vf', `fps=1,scale=${tileW}:-2,tile=6x${rows}`, '-frames:v', '1', sheet]) if (s.status !== 0) notes.push('Could not write the contact sheet.') console.log('\n-- Film check -------------------------------------------') console.log(` File ${o.file}`) console.log(` Duration ${duration.toFixed(2)} s`) console.log(` Frame ${v.width}x${v.height} (${resLabel}), ${fps.toFixed(2)} fps, ${v.codec_name} ${v.pix_fmt}`) console.log(` Audio ${a ? `${a.codec_name} ${a.channels ?? ''}ch` : 'none'}`) console.log(` Size ${mb.toFixed(2)} MB`) console.log(` Sheet ${sheet}`) for (const n of notes) console.log(` note: ${n}`) console.log('---------------------------------------------------------') console.log('Now open the contact sheet and check by eye: the same person and wardrobe in every shot, no extra or') console.log('duplicated people, no melted hands or faces, no morphing objects, no stray text or logos, one grade throughout.') if (issues.length) { console.log('\nFAIL:') for (const i of issues) console.log(` - ${i}`) process.exit(1) } console.log('\nPASS: technical checks.\n')