Docs/Skills and code/skill video-visual-production

.claude/skills/video-visual-production/render/render.js

Code170 lines9.6 KBSkill: video-visual-production

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.