Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/scripts/build-beat.js
What the file says about itself
Build (or rebuild) a video beat from its beat sheet. Scenes that did not change come from a cache, so a small edit is cheap.
#!/usr/bin/env node
// Build (or rebuild) a video beat from its beat sheet. Scenes that did not change come from a cache, so a small edit is cheap.
// node build-beat.js --sheet <beat-sheet.json> [--preview] [--refresh-assets] [--damage] [--skip-check] [--no-cache]
// Stages: preflight, assets, lint, timeline, render (per-scene cache), sound, mux, captions, review sheet, check.
// It stops at the first broken stage and prints which one.
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
if (!U.arg('sheet', null)) U.die('usage: build-beat.js --sheet research/<slug>/video/beat-sheet-<beat>.json');
const SHEET = U.fromRoot(U.arg('sheet'));
const sheet = U.readJSON(SHEET);
const vdir = path.dirname(SHEET);
const bdir = U.buildDir(sheet.slug, sheet.beat);
const S = (f) => path.join(__dirname, f);
const timings = [];
function stage(name, fn) {
const t0 = Date.now();
U.say(`\n== ${name}`);
const out = fn();
const s = ((Date.now() - t0) / 1000).toFixed(1);
timings.push([name, s]);
U.say(` (${s} s)`);
return out;
}
const node = (script, args, opt = {}) => {
const r = U.run(process.execPath, [script, ...args], { stdio: ['ignore', 'pipe', 'pipe'], ...opt });
if (r.stdout) process.stdout.write(r.stdout.split('\n').map((l) => ' ' + l).join('\n') + '\n');
if (r.status !== 0) { if (r.stderr) process.stderr.write(r.stderr); throw new Error(`${path.basename(script)} failed (exit ${r.status})`); }
return r;
};
(async () => { try { await U.withSlot(sheet.beat, async () => {
stage('preflight', () => {
const major = +process.versions.node.split('.')[0];
if (major < 22) throw new Error('Node 22 or newer is needed (built-in WebSocket); found ' + process.version);
U.mustRun('ffmpeg', ['-version']); U.mustRun('ffprobe', ['-version']);
const enc = U.run('ffmpeg', ['-hide_banner', '-encoders']).stdout;
if (!/libx264/.test(enc)) throw new Error('ffmpeg has no libx264');
try { require(path.join(U.SKILL, 'render', 'node_modules', '@napi-rs', 'canvas')); } catch (e) { throw new Error('canvas is not installed: run cd .claude/skills/video-visual-production/render && npm install'); }
for (const f of [sheet.audio, sheet.words, sheet.ledger]) if (!fs.existsSync(U.fromRoot(f))) throw new Error('missing input ' + f);
U.say(` node ${process.version}, ffmpeg with libx264, canvas loads; inputs present`);
});
stage('assets (Commons photo, article capture)', () => node(S('assets.js'), ['--sheet', SHEET, ...(U.flag('refresh-assets') ? ['--force'] : [])]));
stage('lint' + (U.flag('damage') ? ' and damage test' : ''), () => node(S('beatsheet-lint.js'), ['--sheet', SHEET, ...(U.flag('damage') ? ['--damage'] : [])]));
stage('timeline', () => node(S('timeline.js'), ['--sheet', SHEET]));
const silent = path.join(bdir, `${sheet.beat}-silent.mp4`);
const cacheDir = path.join(bdir, U.flag('preview') ? 'cache-preview' : 'cache');
stage('render frames to ffmpeg' + (U.flag('preview') ? ' (preview 960x540)' : ''), () => node(path.join(U.SKILL, 'render', 'render.js'),
['--timeline', path.join(vdir, `timeline-${sheet.beat}.json`), '--assets', path.join(bdir, 'assets'), '--out', silent,
...(U.flag('preview') ? ['--preview'] : []), ...(U.flag('no-cache') ? [] : ['--cache', cacheDir])]));
stage('sound: effects, bed, mix, loudness', () => node(S('mix.js'), ['--sheet', SHEET]));
const final = path.join(bdir, `${sheet.beat}.mp4`);
stage('mux video and mix', () => U.mustRun('ffmpeg', ['-y', '-v', 'error', '-i', silent, '-i', path.join(bdir, 'audio', 'mix.wav'), '-c:v', 'copy', '-c:a', 'aac', '-b:a', '256k', '-shortest', '-movflags', '+faststart', final]));
stage('captions sidecar', () => node(S('captions.js'), ['--sheet', SHEET]));
stage('review sheet', () => node(S('review.js'), ['--sheet', SHEET]));
if (U.flag('preview')) { U.say('\npreview built (not checked, 960x540): ' + final); return; }
if (U.flag('skip-check')) { U.say('\nbuilt WITHOUT the check (--skip-check): ' + final + '\nrun check-video.js before calling it done'); return; }
stage('check the rendered file', () => node(S('check-video.js'), ['--sheet', SHEET, '--video', final]));
U.say('\n' + timings.map(([n, s]) => `${s.padStart(6)} s ${n}`).join('\n'));
U.say(`\nBUILD PASSED (hard gates pass; not judged by eye or ear)\nvideo: ${final}\nsheet: ${path.join(bdir, 'contact-sheet.png')}\nreview: ${path.join(vdir, 'REVIEW-' + sheet.beat + '.md')}\nstatus: ${path.join(vdir, 'STATUS-video-' + sheet.beat + '.md')}\ncaptions: ${path.join(bdir, sheet.beat + '.srt')}`);
}); } catch (e) {
U.say('\n' + timings.map(([n, s]) => `${s.padStart(6)} s ${n}`).join('\n'));
U.die('BUILD FAILED: ' + 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.