Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/render/render.js
What the file says about itself
V6 Render: draw every frame of a timeline on a canvas and pipe raw RGBA straight into ffmpeg (no frame files). node render.js --timeline <timeline.json> --assets <dir> --out <silent.mp4> [--preview] [--audit <file.json>] [--stills 10,200…
#!/usr/bin/env node
// V6 Render: draw every frame of a timeline on a canvas and pipe raw RGBA straight into ffmpeg (no frame files).
// node render.js --timeline <timeline.json> --assets <dir> --out <silent.mp4> [--preview] [--audit <file.json>] [--stills 10,200 --stills-dir <dir>]
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const crypto = require('crypto');
const { loadImage, createCanvas } = require('@napi-rs/canvas');
const D = require('./draw');
const { REGISTRY } = require('./scenes');
const FPS = 30;
const arg = (n, d) => { const i = process.argv.indexOf('--' + n); return i > -1 && i + 1 < process.argv.length && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : d; };
const flag = (n) => process.argv.includes('--' + n);
async function loadAssets(tl, assetsDir) {
const out = {};
const ids = new Set();
tl.scenes.forEach((s) => { if (s.props && s.props.asset) ids.add(s.props.asset); });
for (const id of ids) {
const rec = JSON.parse(fs.readFileSync(path.join(assetsDir, id + '.json'), 'utf8'));
const forceTypeset = process.env.VVP_FORCE_TYPESET === '1' && rec.kind === 'capture';
if (rec.kind === 'capture' && (rec.captured === false || forceTypeset)) {
const text = rec.fallback_text || tl.fallback_text && tl.fallback_text[id];
if (!text) throw new Error(`asset ${id} was not captured and has no fallback text`);
const phrases = rec.highlights || tl.scenes.filter((s) => s.props.asset === id).flatMap((s) => s.props.highlights.map((h) => h.phrase));
const ts = D.typesetPassage(text, phrases, 1500, text.length < 110 ? 62 : 44);
for (const [p, v] of Object.entries(ts.boxes)) if (!v) throw new Error(`typeset: phrase not found in fallback text of ${id}: ${p}`);
out[id] = { rec: { ...rec, captured: false }, typeset: ts };
} else if (rec.kind === 'capture') {
out[id] = { rec, img: await loadImage(path.join(assetsDir, rec.file)) };
} else {
out[id] = { rec, img: await loadImage(path.join(assetsDir, rec.file)) };
}
}
return out;
}
function contrast(hexA, rgbB) {
const lum = (r, g, b) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
const a = hexA[0] === '#' ? [parseInt(hexA.slice(1, 3), 16), parseInt(hexA.slice(3, 5), 16), parseInt(hexA.slice(5, 7), 16)] : (hexA.match(/\d+/g) || [0, 0, 0]).slice(0, 3).map(Number);
const l1 = lum(...a), l2 = lum(...rgbB);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
async function main() {
const tlPath = arg('timeline');
const assetsDir = arg('assets');
const outPath = arg('out');
if (!tlPath || !assetsDir) { console.error('usage: render.js --timeline T --assets DIR --out FILE'); process.exit(2); }
const tl = JSON.parse(fs.readFileSync(tlPath, 'utf8'));
D.registerFonts();
Object.assign(D.C, tl.style || {});
const preview = flag('preview');
const scale = preview ? 0.5 : 1;
const cw = Math.round(D.W * scale), ch = Math.round(D.H * scale);
const canvas = createCanvas(cw, ch), off = createCanvas(cw, ch);
const env = D.makeEnv(canvas, scale), env2 = D.makeEnv(off, scale);
env.assets = env2.assets = await loadAssets(tl, assetsDir);
const total = tl.total_frames;
const sceneAt = (f) => { for (let i = tl.scenes.length - 1; i >= 0; i--) if (f >= tl.scenes[i].start) return i; return 0; };
const drawScene = (e, sc, t) => {
e.ctx.setTransform(scale, 0, 0, scale, 0, 0);
e.ctx.globalAlpha = 1; e.ctx.globalCompositeOperation = 'source-over';
const S = { cues: sc.cues, dur: sc.dur_s, assets: e.assets };
REGISTRY[sc.type](e, t, sc.props, S);
e.ctx.globalAlpha = 1;
};
const drawFrame = (f) => {
const si = sceneAt(f), sc = tl.scenes[si];
const t = (f - sc.start) / FPS;
const k = f - sc.start;
if (sc.enter && sc.enter.type === 'fade' && si > 0 && k < sc.enter.frames) {
const prev = tl.scenes[si - 1];
drawScene(env, prev, prev.dur_s);
drawScene(env2, sc, t);
env.ctx.setTransform(1, 0, 0, 1, 0, 0);
env.ctx.globalAlpha = (k + 1) / (sc.enter.frames + 1);
env.ctx.drawImage(off, 0, 0);
env.ctx.globalAlpha = 1;
} else drawScene(env, sc, t);
const fadeFrames = Math.round(tl.fade_out_s * FPS);
if (f >= total - fadeFrames) {
env.ctx.setTransform(1, 0, 0, 1, 0, 0);
env.ctx.globalAlpha = Math.min(1, (f - (total - fadeFrames) + 1) / fadeFrames);
env.ctx.fillStyle = '#000'; env.ctx.fillRect(0, 0, cw, ch); env.ctx.globalAlpha = 1;
}
};
// layout audit: every scene at its fully built moment, every drawn text box with its contrast
if (arg('audit')) {
const audit = [];
for (const sc of tl.scenes) {
env.boxes = [];
drawScene(env, sc, Math.max(0, sc.dur_s - 0.1));
const px = (x, y) => { const c = Math.min(cw - 1, Math.max(0, Math.round(x * scale))), r = Math.min(ch - 1, Math.max(0, Math.round(y * scale))); const d = env.ctx.getImageData(c, r, 1, 1).data; return [d[0], d[1], d[2]]; };
const boxes = env.boxes.map((b) => {
const samples = [px(b.x - 10, b.y - 8), px(b.x + b.w + 10, b.y - 8), px(b.x - 10, b.y + b.h + 8), px(b.x + b.w + 10, b.y + b.h + 8)];
const cs = samples.map((s) => contrast(b.color, s));
return { ...b, contrast_min: Math.round(cs.sort((x, y) => x - y)[Math.floor(cs.length / 2)] * 100) / 100 };
});
audit.push({ id: sc.id, type: sc.type, boxes });
env.boxes = null;
}
fs.writeFileSync(arg('audit'), JSON.stringify(audit, null, 1));
console.log('audit written: ' + arg('audit'));
}
const stills = arg('stills');
if (stills) {
const dir = arg('stills-dir', path.dirname(outPath || tlPath));
fs.mkdirSync(dir, { recursive: true });
for (const s of stills.split(',')) {
const f = Math.min(total - 1, Math.max(0, parseInt(s, 10)));
drawFrame(f);
fs.writeFileSync(path.join(dir, 'f_' + String(f).padStart(5, '0') + '.png'), canvas.toBuffer('image/png'));
}
console.log('stills written: ' + dir);
}
if (!outPath) return;
const encArgs = (file) => ['-y', '-v', 'error', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', cw + 'x' + ch, '-r', String(FPS), '-i', '-',
'-vf', 'scale=in_range=full:out_range=tv:out_color_matrix=bt709,format=yuv420p',
'-c:v', 'libx264', '-preset', preview ? 'ultrafast' : 'veryfast', '-crf', preview ? '26' : '17', '-g', '60',
'-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-color_range', 'tv', '-movflags', '+faststart', file];
let peak = 0;
const encode = async (f0, f1, file) => {
const ff = spawn('ffmpeg', encArgs(file), { stdio: ['pipe', 'inherit', 'inherit'] });
const closed = new Promise((res) => ff.on('close', res));
for (let f = f0; f < f1; f++) {
drawFrame(f);
if (!ff.stdin.write(canvas.data())) await new Promise((r) => ff.stdin.once('drain', r));
if (f % 300 === 0) peak = Math.max(peak, process.memoryUsage().rss);
}
ff.stdin.end();
if ((await closed) !== 0) { console.error('ffmpeg exited with an error'); process.exit(1); }
};
const t0 = Date.now();
const cacheDir = arg('cache');
if (!cacheDir) {
await encode(0, total, outPath);
const s2 = (Date.now() - t0) / 1000;
console.log('rendered ' + total + ' frames in ' + s2.toFixed(1) + ' s (' + (total / s2).toFixed(1) + ' fps), node peak RSS ' + (peak / 1048576).toFixed(0) + ' MB -> ' + outPath);
return;
}
// Per-scene cache: a scene is re-rendered only if its definition, its timing, its assets, the style or the renderer code changed.
fs.mkdirSync(cacheDir, { recursive: true });
const sha = (x) => crypto.createHash('sha256').update(x).digest('hex').slice(0, 20);
const code = sha(['draw.js', 'scenes.js', 'scenes-b0.js', 'scenes-data.js', 'scenes-more.js', 'contacts.js', 'render.js'].map((n) => fs.readFileSync(path.join(__dirname, n), 'utf8')).join(String.fromCharCode(10)));
const brief = (sc) => ({ type: sc.type, props: sc.props, cues: sc.cues, start: sc.start, end: sc.end, dur_s: sc.dur_s, enter: sc.enter });
const assetSha = (sc) => { const id = sc.props && sc.props.asset; return id ? (env.assets[id].rec.sha256 || 'typeset') + (env.assets[id].typeset ? 'T' : '') : ''; };
const parts = [];
let hits = 0, made = 0;
for (let i = 0; i < tl.scenes.length; i++) {
const sc = tl.scenes[i];
const prev = i > 0 && sc.enter && sc.enter.type === 'fade' ? brief(tl.scenes[i - 1]) : null;
const key = sha(JSON.stringify({ code, preview, style: tl.style || {}, sc: brief(sc), prev, prevAsset: prev ? assetSha(tl.scenes[i - 1]) : '', asset: assetSha(sc), last: i === tl.scenes.length - 1 ? [total, tl.fade_out_s] : 0 }));
const file = path.join(cacheDir, key + '.mp4');
if (fs.existsSync(file)) { hits++; console.log(' ' + sc.id + ' cached'); }
else { await encode(sc.start, sc.end, file); made++; console.log(' ' + sc.id + ' rendered (' + (sc.end - sc.start) + ' frames)'); }
parts.push(file);
}
const list = path.join(cacheDir, 'concat-' + path.basename(outPath) + '.txt');
fs.writeFileSync(list, parts.map((p) => "file '" + p.split(String.fromCharCode(92)).join('/') + "'").join(String.fromCharCode(10)));
const cc = require('child_process').spawnSync('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', '-movflags', '+faststart', outPath]);
if (cc.status !== 0) { console.error(String(cc.stderr)); process.exit(1); }
console.log('scenes: ' + made + ' rendered, ' + hits + ' from cache, in ' + ((Date.now() - t0) / 1000).toFixed(1) + ' s -> ' + outPath);
}
main().catch((e) => { console.error(e.stack || e.message); process.exit(1); });
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.