#!/usr/bin/env node // Checks a finished teaser: 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 (one place, same people, no extra people, no morphing, no text). // // Usage: // node scripts/check-teaser.mjs --file teaser.mp4 [--edl teaser.mp4.edl.json] [--sheet teaser-sheet.jpg] // [--aspect 16:9|9:16|1:1|4:5] [--resolution 720p|1080p|480p] [--min-seconds 15] [--max-seconds 20] // [--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-teaser.mjs --file teaser.mp4 [--edl teaser.mp4.edl.json] [--aspect 16:9] [--resolution 720p] [--min-seconds 15] [--max-seconds 20] [--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-teaser.mjs 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-teaser.mjs 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 (only a final fade-out 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 (final fade-out).`) } // 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-- Teaser 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 place looks like one place in every shot, any people stay') console.log('the same, no extra or duplicated people, no melted hands or faces, no morphing buildings, no text, signs or logos,') console.log('one grade throughout, nothing important in the top 12% or bottom 20% of a 9:16 frame.') if (issues.length) { console.log('\nFAIL:') for (const i of issues) console.log(` - ${i}`) process.exit(1) } console.log('\nPASS: technical checks.\n')