Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/scripts/check-video.js
What the file says about itself
V8 Check: look at the rendered file and the sound stems and report what can be decided by measurement. node check-video.js --sheet <beat-sheet.json> [--video <final.mp4>] Hard gates: Q1 black or frozen or silent stretches, Q3 cuts where…
#!/usr/bin/env node
// V8 Check: look at the rendered file and the sound stems and report what can be decided by measurement.
// node check-video.js --sheet <beat-sheet.json> [--video <final.mp4>]
// Hard gates: Q1 black or frozen or silent stretches, Q3 cuts where the timeline says, Q4 drift, A1 loudness and peak,
// S2 sound onsets on the frame, T2 text size, T3 contrast, T4 safe area and overlaps. It never says "BAR MET":
// that needs the user's eyes and ears.
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const D = require('./lib/dsp');
const { sheet: makeSheet } = require('./contact-sheet');
const ff = (args) => U.run('ffmpeg', ['-hide_banner', '-nostats', ...args]);
function probe(file) {
const r = U.mustRun('ffprobe', ['-v', 'error', '-count_frames', '-show_entries', 'stream=codec_type,codec_name,width,height,r_frame_rate,nb_read_frames,duration,sample_rate,channels', '-of', 'json', file]);
return JSON.parse(r.stdout).streams;
}
async function main() {
const sheetPath = U.fromRoot(U.arg('sheet', ''));
const sheet = U.readJSON(sheetPath);
const vdir = path.dirname(sheetPath);
const bdir = U.buildDir(sheet.slug, sheet.beat);
const video = U.arg('video', path.join(bdir, `${sheet.beat}.mp4`));
const tl = U.readJSON(path.join(vdir, `timeline-${sheet.beat}.json`));
const rows = [];
const hard = [], warn = [];
const row = (id, name, ok, detail, kind = 'H') => { rows.push({ id, name, ok, detail, kind }); if (!ok) (kind === 'H' ? hard : warn).push(`${id} ${name}: ${detail}`); };
// container, frame count, drift (Q4)
const st = probe(video);
const v = st.find((s) => s.codec_type === 'video'), a = st.find((s) => s.codec_type === 'audio');
row('Q0', 'video stream is H.264 1920x1080 at 30 fps', v.codec_name === 'h264' && v.width === 1920 && v.height === 1080 && v.r_frame_rate === '30/1', `${v.codec_name} ${v.width}x${v.height} ${v.r_frame_rate}`);
row('Q0', 'audio stream is AAC stereo 48 kHz', a && a.codec_name === 'aac' && a.channels === 2 && a.sample_rate === '48000', a ? `${a.codec_name} ${a.channels}ch ${a.sample_rate}` : 'no audio');
row('Q0', 'frame count equals the timeline', +v.nb_read_frames === tl.total_frames, `${v.nb_read_frames} frames, timeline ${tl.total_frames}`);
const drift = Math.abs(parseFloat(v.duration) - parseFloat(a.duration));
row('Q4', 'audio and video lengths agree (drift under 100 ms)', drift < 0.1, `${(drift * 1000).toFixed(0)} ms`);
// Q1: black, freeze, silence
const bl = ff(['-i', video, '-vf', 'blackdetect=d=0.05:pix_th=0.08', '-an', '-f', 'null', '-']).stderr;
const blacks = [...bl.matchAll(/black_start:([\d.]+) black_end:([\d.]+)/g)].map((m) => [+m[1], +m[2]]);
const decl = tl.declared_black.map(([f0, f1]) => [f0 / tl.fps - 0.12, (f1 + 1) / tl.fps + 0.05]);
const undeclared = blacks.filter(([s, e]) => !decl.some(([d0, d1]) => s >= d0 && e <= d1 + 0.5));
row('Q1', 'no undeclared black frames', undeclared.length === 0, undeclared.length ? undeclared.map((b) => b.join('-')).join(', ') : `${blacks.length} declared (the end fade)`);
const fz = ff(['-i', video, '-vf', 'freezedetect=n=0.002:d=3', '-an', '-f', 'null', '-']).stderr;
const freezes = [...fz.matchAll(/freeze_duration: ([\d.]+)/g)].map((m) => +m[1]);
row('Q1', 'no freeze over 8 s', freezes.every((d) => d < 8), freezes.length ? `${freezes.length} still stretch(es) over 3 s: ${freezes.map((d) => d.toFixed(1)).join(', ')} s` : 'none over 3 s');
const sl = ff(['-i', video, '-af', 'silencedetect=n=-50dB:d=1.5', '-vn', '-f', 'null', '-']).stderr;
const silences = [...sl.matchAll(/silence_duration: ([\d.]+)/g)].map((m) => +m[1]);
row('Q1', 'no unplanned silence over 1.5 s', silences.length === 0, silences.length ? silences.join(', ') + ' s' : 'none');
// A1: loudness and true peak of the final file's audio
const eb = ff(['-i', video, '-af', 'ebur128=peak=true', '-vn', '-f', 'null', '-']).stderr;
const sum = eb.slice(eb.lastIndexOf('Summary:'));
const I = parseFloat((sum.match(/I:\s+(-?[0-9.]+) LUFS/) || [])[1]), LRA = parseFloat((sum.match(/LRA:\s+(-?[0-9.]+) LU/) || [])[1]), TP = parseFloat((sum.match(/Peak:\s+(-?[0-9.]+) dBFS/) || [])[1]);
row('A1', 'integrated loudness -14 LUFS (+-1)', Math.abs(I + 14) <= 1, `${I} LUFS, loudness range ${LRA} LU`);
row('A1', 'true peak at most -1 dBTP', TP <= -1, `${TP} dBTP`);
const clip = ff(['-i', video, '-af', 'astats=metadata=0', '-vn', '-f', 'null', '-']).stderr;
const flat = (clip.match(/Flat factor: ([\d.]+)/) || [])[1];
row('A1', 'no clipping (flat factor 0)', flat == null || +flat < 1, `flat factor ${flat}`);
// Q3: a visible change at every hard cut
const sc = ff(['-i', video, '-vf', "select='gte(scene,0)',metadata=print:key=lavfi.scene_score:file=-", '-an', '-f', 'null', '-']);
const scores = [...sc.stdout.matchAll(/lavfi\.scene_score=([\d.]+)/g)].map((m) => +m[1]);
const cuts = tl.scenes.filter((s, i) => i > 0 && s.enter.type === 'cut');
const cutRes = cuts.map((s) => ({ id: s.id, score: Math.max(...[-1, 0, 1, 2, 3, 4, 5, 6, 8, 10, 12].map((d) => scores[s.start + d] || 0)) }));
const dead = cutRes.filter((c) => c.score < 0.003);
const weak = cutRes.filter((c) => c.score < 0.02);
row('Q3', 'every hard cut shows a change within 12 frames (a scene may fade in from plain ground)', dead.length === 0, dead.length ? 'no change at ' + dead.map((c) => c.id).join(', ') : `${cuts.length} cuts, weakest ${Math.min(...cutRes.map((c) => c.score)).toFixed(3)}`);
if (weak.length) row('Q3', 'cuts with a weak change (dark-to-dark)', false, weak.map((c) => `${c.id} ${c.score.toFixed(3)}`).join(', '), 'W');
// S2: each sound begins within one frame of its event (read from the effects stem)
const mixRep = U.readJSON(path.join(bdir, 'audio', 'mix-report.json'));
const sfxBuf = ff2f32(path.join(bdir, 'audio', 'sfx.wav'));
let worst = 0, misses = [];
for (const e of mixRep.sfx_table) {
const s0 = Math.round(e.sample);
const win = (a, b) => { let m = 0; for (let i = Math.max(0, a); i < Math.min(sfxBuf.length, b); i++) m = Math.max(m, Math.abs(sfxBuf[i])); return m; };
const thr = Math.max(win(s0, s0 + 1200) * 0.08, 1e-5);
let first = -1;
for (let i = s0 - 4800; i < s0 + 4800; i++) if (Math.abs(sfxBuf[i]) > thr) { first = i; break; }
const dms = first < 0 ? 999 : ((first - s0) / D.SR) * 1000;
worst = Math.max(worst, Math.abs(dms));
if (Math.abs(dms) > 34) misses.push(`${e.event} ${dms.toFixed(0)} ms`);
}
row('S2', 'every sound starts within one frame (33 ms) of its event', misses.length === 0, misses.length ? misses.join('; ') : `${mixRep.sfx_table.length} sounds, worst ${worst.toFixed(1)} ms`);
const density = tl.report.sfx_per_min;
row('A3', 'sound effects a minute at most 12', density <= 12, `${density} a minute (target 5 to 8)`);
row('A2', 'bed sits well under the voice (measured while speech is active)', !mixRep.bed_enabled || mixRep.bed_level_db_below_voice_rms <= -12, mixRep.bed_enabled ? `${mixRep.bed_level_db_below_voice_rms} dB below the voice RMS` : 'no bed');
// T2 T3 T4: layout on the rendered scenes (audit pass of the renderer)
const auditFile = path.join(bdir, 'layout-audit.json');
const ra = U.run('node', [path.join(U.SKILL, 'render', 'render.js'), '--timeline', path.join(vdir, `timeline-${sheet.beat}.json`), '--assets', path.join(bdir, 'assets'), '--audit', auditFile]);
if (ra.status !== 0) throw new Error('layout audit failed: ' + ra.stderr);
const audit = U.readJSON(auditFile);
const minPx = { number: 0.065 * 1080, label: 0.035 * 1080, name: 0.035 * 1080, keyword: 0.035 * 1080, evidence: 0.035 * 1080, source: 0.03 * 1080, chapter: 0.03 * 1080 };
const bad = { T2: [], T3: [], T4: [], OVERLAP: [] };
let nBoxes = 0;
for (const s of audit) {
s.boxes.forEach((b, i) => {
nBoxes++;
if (b.size + 0.5 < (minPx[b.role] || 0)) bad.T2.push(`${s.id} "${b.text.slice(0, 24)}" ${Math.round(b.size)}px < ${Math.round(minPx[b.role])}px (${b.role})`);
if (b.contrast_min < 4.5) bad.T3.push(`${s.id} "${b.text.slice(0, 24)}" contrast ${b.contrast_min}`);
if (b.x < 96 || b.x + b.w > 1824 || b.y < 76 || b.y + b.h > 1004 || (b.role !== 'source' && b.y + b.h > 950)) bad.T4.push(`${s.id} "${b.text.slice(0, 24)}" at ${Math.round(b.x)},${Math.round(b.y)} w ${Math.round(b.w)}`);
for (let j = i + 1; j < s.boxes.length; j++) {
const c = s.boxes[j];
const ox = Math.min(b.x + b.w, c.x + c.w) - Math.max(b.x, c.x), oy = Math.min(b.y + b.h * 0.9, c.y + c.h * 0.9) - Math.max(b.y + b.h * 0.1, c.y + c.h * 0.1);
if (ox > 4 && oy > 4) bad.OVERLAP.push(`${s.id} "${b.text.slice(0, 18)}" over "${c.text.slice(0, 18)}"`);
}
});
}
row('T2', 'text is large enough (6.5% numbers, 3.5% labels, 3% source lines of frame height)', bad.T2.length === 0, bad.T2.length ? bad.T2.join('; ') : `${nBoxes} text boxes`);
row('T3', 'text contrast at least 4.5:1 against the sampled background', bad.T3.length === 0, bad.T3.length ? bad.T3.join('; ') : 'all pass');
row('T4', 'text inside the safe area and out of the bottom 12%', bad.T4.length === 0, bad.T4.length ? bad.T4.join('; ') : 'all inside');
row('T4', 'no overlapping text boxes', bad.OVERLAP.length === 0, bad.OVERLAP.length ? bad.OVERLAP.join('; ') : 'none');
// honesty trail: every third-party asset has a record, hash and licence or source line
const adir = path.join(bdir, 'assets');
const recs = fs.readdirSync(adir).filter((f) => f.endsWith('.json')).map((f) => U.readJSON(path.join(adir, f)));
const used = new Set(tl.scenes.map((s) => s.props.asset).filter(Boolean));
const missing = [...used].filter((id) => !recs.find((r) => r.id === id));
row('H3', 'every asset on screen has a capture or licence record', missing.length === 0, missing.length ? missing.join(', ') : `${used.size} assets, all recorded`);
const typeset = recs.filter((r) => r.kind === 'capture' && r.captured === false && !r.typeset_by_choice);
const chosen = recs.filter((r) => r.typeset_by_choice);
if (chosen.length) row('H3', 'quotes typeset on purpose, each with a source URL and hash', chosen.every((r) => r.source_url && r.sha256), chosen.map((r) => r.id).join(', '));
if (typeset.length) row('H3', 'article passages shown typeset instead of captured', false, typeset.map((r) => r.id + ' (' + r.error + ')').join('; '), 'W');
const credits = fs.readFileSync(path.join(vdir, `credits-${sheet.beat}.txt`), 'utf8');
row('H9', 'credit-bearing assets appear in credits.txt', recs.filter((r) => r.kind === 'commons').every((r) => credits.includes(r.title.replace(/^File:/, ''))), 'credits file lists every Commons file');
// contact sheet and spectrogram
const frames = [];
const sd = path.join(bdir, 'sheet-frames');
fs.rmSync(sd, { recursive: true, force: true }); fs.mkdirSync(sd, { recursive: true });
tl.scenes.forEach((s, i) => {
const t = (s.start + Math.min(s.end - s.start - 3, Math.round((s.end - s.start) * 0.78))) / tl.fps;
const f = path.join(sd, `${String(i).padStart(2, '0')}.png`);
U.mustRun('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(3), '-i', video, '-frames:v', '1', f]);
frames.push([f, `${s.id.slice(3)} ${s.type} ${(s.start / tl.fps).toFixed(1)}-${(s.end / tl.fps).toFixed(1)}s`]);
});
const sheetOut = path.join(bdir, 'contact-sheet.png');
await makeSheet(frames.map((x) => x[0]), frames.map((x) => x[1]), sheetOut, 4, 480);
U.run('ffmpeg', ['-y', '-v', 'error', '-i', path.join(bdir, 'audio', 'mix.wav'), '-lavfi', 'showspectrumpic=s=1600x500:legend=1:scale=log', path.join(bdir, 'spectrogram.png')]);
const verdict = hard.length ? 'HARD GATES FAILING' : 'HARD GATES PASS, BAR NOT YET MET (needs the user\'s eyes and ears)';
const md = [
`# STATUS: video ${sheet.beat} ${sheet.title}`, '', `**Verdict: ${verdict}**`, '',
`Checked ${new Date().toISOString()} on \`${video}\`. Measured by script; nothing here says the video is good.`, '',
'| Rule | Check | Result | Detail |', '|---|---|---|---|',
...rows.map((r) => `| ${r.id} | ${r.name} | ${r.ok ? 'pass' : r.kind === 'H' ? '**FAIL**' : 'warn'} | ${String(r.detail).replace(/\|/g, '/')} |`), '',
`Contact sheet: \`${sheetOut}\`. Spectrogram: \`${path.join(bdir, 'spectrogram.png')}\`.`, '',
].join('\n');
fs.writeFileSync(path.join(vdir, `STATUS-video-${sheet.beat}.md`), md);
U.say(md);
process.exit(hard.length ? 1 : 0);
}
function ff2f32(file) {
const r = U.run('ffmpeg', ['-v', 'error', '-i', file, '-ar', String(D.SR), '-ac', '1', '-f', 'f32le', '-'], { encoding: 'buffer' });
return D.f32FromBuffer(r.stdout);
}
main().catch((e) => U.die(e.stack || e.message));
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.