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

.claude/skills/video-visual-production/scripts/compose-beat.js

Code187 lines14.1 KBSkill: video-visual-production

What the file says about itself

Compile a compact storyboard plan into the full beat sheet and ledger, so the storyboarder writes props, not bookkeeping. node compose-beat.js --beat B6 [--topic the-fall-of-moser-baer] [--no-lint] Reads…

#!/usr/bin/env node
// Compile a compact storyboard plan into the full beat sheet and ledger, so the storyboarder writes props, not bookkeeping.
//   node compose-beat.js --beat B6 [--topic the-fall-of-moser-baer] [--no-lint]
// Reads  research/<topic>/video/plan-<beat>.json  (format: .claude/skills/video-visual-production/references/plan-format.md)
// Writes beat-sheet-<beat>.json and ledger-<beat>.json beside it, then runs the lint.
// What it derives for you: the word span from sentence numbers; cue word indexes from "@word" markers; the on-screen text list
// (value, cue, role, number flag, spoken_as) from the props of each scene type; default sound effects for each type, thinned to
// the spacing and density limits; the ledger rows from the plan's facts. What it never does: invent facts, pictures or numbers.
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');

const beat = U.arg('beat', null);
if (!beat) U.die('usage: compose-beat.js --beat B6');
const topic = U.arg('topic', 'the-fall-of-moser-baer'), slug = U.arg('slug', 'moser-baer-v7-hinglish');
const realDir = path.join(U.ROOT, 'research', topic, 'video');
const vdir = U.arg('dir', null) ? path.resolve(U.arg('dir')) : realDir; // --dir: write the plan's outputs somewhere else (testing)
const planPath = path.join(vdir, `plan-${beat}.json`);
if (!fs.existsSync(planPath)) U.die('no ' + planPath);
const plan = U.readJSON(planPath);
const wordsRel = `research/${topic}/video/timing/words-${beat}.json`;
const words = U.readJSON(path.join(U.ROOT, wordsRel)).words;

const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9ऀ-ॿ]/g, '');
const sents = []; let cur = [];
words.forEach((w, i) => { cur.push(i); if (/[.?!]["')]*$/.test(w.text)) { sents.push(cur); cur = []; } });
if (cur.length) sents.push(cur);

const errors = [];
const err = (m) => errors.push(m);

function spanOf(s, id) {
  const [a, b] = Array.isArray(s) ? s : [s, s];
  if (!sents[a - 1] || !sents[b - 1]) { err(`${id}: sentence ${a}-${b} does not exist (the beat has ${sents.length} sentences)`); return [0, 0]; }
  return [sents[a - 1][0], sents[b - 1][sents[b - 1].length - 1]];
}
// "@word", "@word#2" (second occurrence), "@start", "@end", "@+3" (3 words after the start), or a plain word index
function cueIndex(spec, span, id, name) {
  if (typeof spec === 'number') return spec;
  const m = String(spec).match(/^@(.+?)(?:#(\d+))?$/);
  if (!m) { err(`${id}: cue ${name} = ${spec} is not a number or @word`); return span[0]; }
  const key = m[1], nth = m[2] ? +m[2] : 1;
  if (key === 'start') return span[0];
  if (key === 'end') return span[1];
  if (/^[+-]\d+$/.test(key)) return key[0] === '+' ? span[0] + +key.slice(1) : span[1] + +key;
  let seen = 0;
  for (let i = span[0]; i <= span[1]; i++) if (norm(words[i].text) === norm(key) && ++seen === nth) return i;
  err(`${id}: cue ${name} = "${spec}": no such word in the scene's words. They are: ${words.slice(span[0], span[1] + 1).map((w, k) => (span[0] + k) + ':' + w.text).join(' ')}`);
  return span[0];
}

// ---- on-screen text, derived per scene type: [value, cueName, role]
const isNum = (v) => /\d/.test(String(v));
const T = {
  date: (p) => [[p.year, 'year', 'number'], p.chapter && [p.chapter, 'enter', 'chapter']],
  slam: (p) => p.lines.map((l) => [l.text, l.cue, 'keyword']),
  stat: (p, c) => [[p.count ? (p.count.prefix || '') + p.count.to.toLocaleString('en-US') + (p.count.suffix || '') : p.value, 'stat', 'number'], p.label && [p.label, c.label != null ? 'label' : 'stat', 'label'], p.sub && [p.sub, c.label != null ? 'label' : 'stat', 'label'], p.source && [p.source, 'enter', 'source'], (p.status === 'alleged' || p.status === 'estimate') && [p.status === 'alleged' ? 'alleged, not proved' : 'estimate', 'stat', 'label']],
  doc: (p) => [...p.highlights.map((h) => [h.phrase, 'enter', 'evidence']), [p.tag, 'enter', 'source']],
  bars: (p) => [...p.bars.flatMap((b) => [[b.text, b.cue, isNum(b.text) ? 'number' : 'label'], b.unit && [b.unit, b.cue, 'label'], [b.label, b.cue, 'label']]), p.gap && [p.gap.text, p.gap.cue, 'number'], p.chip && [p.chip.text, p.chip.cue, 'label'], p.source && [p.source, 'enter', 'source']],
  line: (p) => [...p.series.flatMap((s) => [s.label && [s.label, s.points[s.points.length - 1].cue, 'label'], ...s.points.flatMap((pt) => [pt.text && [pt.text, pt.cue, isNum(pt.text) ? 'number' : 'label'], pt.sub && [pt.sub, pt.cue, 'label'], pt.x && [pt.x, pt.cue, 'label']])]), p.source && [p.source, 'enter', 'source']],
  photo2: (p) => [[p.caption, 'photo', 'label'], p.caption2 && [p.caption2, 'photo', 'label'], [p.credit1, 'photo', 'source'], p.credit2 && [p.credit2, 'photo', 'source'], ...(p.notes || []).map((n) => [n.text, n.cue, n.number ? 'number' : 'label'])],
  chips: (p) => [p.title && [p.title, 'enter', 'label'], ...p.items.map((i) => [i.text, i.cue, 'label'])],
  race: (p) => [p.track && [p.track, 'enter', 'label'], ...p.lanes.map((l) => [l.label, l.cue, 'label'])],
  stalls: (p) => [...p.stalls.flatMap((s) => [[s.label, s.boards[0].cue, 'label'], ...s.boards.map((b) => [b.text, b.cue, isNum(b.text) ? 'number' : 'label'])]), p.fill && p.fill.text && [p.fill.text, p.fill.cue, 'keyword']],
  treadmill: (p) => [p.label && [p.label, p.labelCue || p.speeds[0].cue, 'keyword']],
  unitgrid: (p) => [p.legend && [p.legend, 'legend', 'label'], p.value && [p.value, 'value', isNum(p.value) ? 'number' : 'keyword']],
  stamps: (p) => [[p.text, 'stamps', 'label'], p.title && [p.title, 'title', 'label'], ...(p.labels || []).map((l) => [l, 'stamps', 'label'])],
  paths: (p) => [[p.from, 'from', 'label'], ...p.options.map((o) => [o.text, o.cue, 'label'])],
  rungs: (p) => [...p.rungs.filter((r) => r.label).map((r) => [r.label, r.cue || 'enter', 'label']), p.title && [p.title, p.titleCue, 'label']],
  calendar: (p) => [[p.month, 'page', 'label'], p.day && [p.day, 'page', 'number'], p.year && [p.year, 'page', 'number'], p.stamp && [p.stamp, 'stamp', 'label']],
  deadline: (p) => [[p.value, 'value', 'number'], [p.unit, 'value', 'label'], p.fromLabel && [p.fromLabel, 'from', 'label'], p.toLabel && [p.toLabel, 'to', 'label']],
  layers: (p) => [p.title && [p.title, 'title', 'label'], ...p.layers.map((l) => [l.label, l.cue, 'label'])],
  objects: (p) => p.items.flatMap((i) => [[i.label, i.cue, 'label'], i.tag && [i.tag, i.tagCue || i.cue, 'number']]),
  cards: (p) => p.cards.flatMap((c) => [[c.title, c.cue, 'label'], c.sub && [c.sub, c.cue, 'label'], c.source && [c.source, c.cue, 'source']]),
  queue: (p) => [[p.counter, 'counter', 'label'], p.label && [p.label, 'label', 'keyword']],
  stopbtn: (p) => [[p.label, 'button', 'keyword']],
  nested: (p) => [[p.outer.text, 'outer', 'number'], p.outer.label && [p.outer.label, 'outer', 'label'], [p.inner.text, 'inner', 'number'], p.inner.label && [p.inner.label, 'inner', 'label'], p.rest && [p.rest.label, 'rest', 'label'], p.chip && [p.chip.text, p.chip.cue, 'label']],
  person: (p) => [[p.name, 'enter', 'name'], [p.role, 'enter', 'label'], p.land && [p.land, 'land', 'label']],
};

// ---- default sounds per type: [sound, cueName, contact, extra]; thinned afterwards
const SFX = {
  date: (p) => [['stamp', 'year', 'stamp']],
  stat: () => [['tick', 'stat', 'pop']],
  bars: (p, c) => p.bars.slice(0, 2).map((b) => ['tick', b.cue, 'pop']),
  line: (p) => { const pts = p.series[0].points; return [['tick', pts[0].cue, 'pop'], ['tick', pts[pts.length - 1].cue, 'pop']]; },
  doc: (p) => [['swipe', p.highlights[0].cue, 'sweep']],
  unitgrid: () => [['tick', 'grid', 'pop']],
  stamps: () => [['stamp', 'stamps', 'stamp']],
  paths: (p) => [['whoosh', p.options[1].cue, 'arc']],
  rungs: (p) => (p.title ? [['pivot', p.titleCue, 'label']] : []),
  calendar: (p) => (p.stamp ? [['stamp', 'stamp', 'stamp']] : []),
  deadline: () => [['tick', 'value', 'pop']],
  cards: (p) => [['tick', p.cards[0].cue, 'pop']],
  objects: (p) => [['tick', p.items[0].tagCue || p.items[0].cue, 'pop']],
  layers: (p) => [['tick', p.layers[p.layers.length - 1].cue, 'pop']],
};
const ROLE = { tick: 'number', stamp: 'date', swipe: 'doc', whoosh: 'spatial', pivot: 'motif', thud: 'stamp', paper: 'doc', clink: 'motif' };
const CONTACT = require('../render/contacts');
const CLASS = { stat: 'D', bars: 'D', line: 'D', unitgrid: 'D', doc: 'E', photo2: 'E', slam: 'T', date: 'T' };

const cueFixed = new Set(['enter']);
const scenes = plan.scenes.map((ps, i) => {
  const id = `${beat}.${String(i + 1).padStart(2, '0')}`;
  let span = spanOf(ps.s, id);
  if (ps.w) { const wide = span; span = [cueIndex(ps.w[0], wide, id, 'w[0]'), cueIndex(ps.w[1], wide, id, 'w[1]')]; }
  const cues = {};
  for (const [k, v] of Object.entries(ps.cues || {})) cues[k] = cueIndex(v, span, id, k);
  const props = ps.props || {};
  // text: derived unless the plan gives it
  let text = ps.text;
  if (!text || text === 'auto') {
    if (!T[ps.type]) { err(`${id}: type ${ps.type} has no automatic text list; give "text" explicitly`); text = []; }
    else {
      const say = ps.say || {};
      text = T[ps.type](props, cues).filter(Boolean).map(([value, cue, role]) => {
        if (cue !== 'enter' && !(cue in cues)) { err(`${id}: the text "${value}" needs a cue named "${cue}" in this scene's cues`); }
        const e = { value: String(value), cue, role };
        const cw = cues[cue] != null ? cues[cue] : span[0];
        if (isNum(value) && role !== 'source' && role !== 'chapter') { e.number = true; if (say[value]) e.spoken_as = say[value]; }
        else if (role !== 'source' && role !== 'chapter' && role !== 'evidence') e.spoken_as = words.slice(cw, Math.min(span[1], cw + 3) + 1).map((w) => w.text.replace(/[.,;:?!"']+$/g, '')).join(' ');
        if (ps.recall && ps.recall[value] != null) e.recall_from = cueIndex(ps.recall[value], [0, words.length - 1], id, 'recall');
        return e;
      });
    }
  }
  // sounds
  let sfx = ps.sfx;
  if (sfx === 'none') sfx = [];
  else if (!sfx || sfx === 'auto') {
    sfx = (SFX[ps.type] ? SFX[ps.type](props, cues) : []).map(([sound, cue, contact]) => ({ sound, cue, contact, role: ROLE[sound] || 'motif', event: `${id} ${sound} at ${cue}` }));
    if (ps.paper) sfx.unshift({ sound: 'paper', cue: 'enter', contact: 'sweep', role: 'doc', event: `${id} passage slides in` });
  }
  sfx.forEach((e) => { if (e.cue !== 'enter' && !(e.cue in cues)) err(`${id}: sound ${e.sound} uses a cue "${e.cue}" that the scene does not define`); });
  const scene = { id, span, type: ps.type, class: ps.class || CLASS[ps.type] || 'M', enter: ps.enter === 'fade' ? { type: 'fade', frames: 8 } : { type: 'cut' }, props, cues, text, ledger: ps.facts || [], sfx };
  if (ps.waive_numerals) scene.waive_numerals = ps.waive_numerals;
  if (ps.black) scene.black = true;
  if (ps.start_shift_frames) scene.start_shift_frames = ps.start_shift_frames;
  return scene;
});

// the scenes must tile every word of the beat, in order
{
  const sentOf = (w) => sents.findIndex((x) => x.includes(w)) + 1;
  let next = 0;
  scenes.forEach((sc) => {
    if (sc.span[0] > next) err('words ' + next + '-' + (sc.span[0] - 1) + ' (sentence ' + sentOf(next) + (sentOf(sc.span[0] - 1) !== sentOf(next) ? '-' + sentOf(sc.span[0] - 1) : '') + ') are not covered by any scene before ' + sc.id);
    if (sc.span[0] < next) err(sc.id + ' starts at word ' + sc.span[0] + ' but the previous scene already covers up to word ' + (next - 1) + ' (use "w": ["@firstword", "@lastword"] to split a sentence between scenes)');
    next = Math.max(next, sc.span[1] + 1);
  });
  if (next < words.length) err('words ' + next + '-' + (words.length - 1) + ' (sentence ' + sentOf(next) + ' to the end, sentence ' + sents.length + ') are not covered by any scene');
}
// thin the default sounds: at least 0.35 s apart, at most 3 in any 10 s, at most 8 a minute over the beat
const evs = [];
scenes.forEach((sc) => sc.sfx.forEach((e) => evs.push({ sc, e, t: e.cue === 'enter' ? words[sc.span[0]].start : words[sc.cues[e.cue]].start + (CONTACT[e.contact] || 0) })));
evs.sort((a, b) => a.t - b.t);
const kept = [], dropped = [];
const budget = Math.max(2, Math.round((words[words.length - 1].end / 60) * 8));
for (const x of evs) {
  const ok = kept.length < budget && (!kept.length || x.t - kept[kept.length - 1].t >= 0.45) && kept.filter((k) => x.t - k.t < 10).length < 3;
  (ok ? kept : dropped).push(x);
}
scenes.forEach((sc) => { sc.sfx = sc.sfx.filter((e) => kept.some((k) => k.e === e)); });

const ref = U.readJSON(path.join(realDir, 'timing', 'reference-words.json')).beats.find((b) => b.beat === beat);
const wavDir = path.join(U.ROOT, 'research', topic, 'script', 'audio', 'gemini-v7-hinglish', 'beats');
const wav = fs.readdirSync(wavDir).find((f) => f.startsWith(beat + '-') && f.endsWith('.wav'));
const sheet = {
  version: 1, slug, beat, title: plan.title || ref.slug, audio: `research/${topic}/script/audio/gemini-v7-hinglish/beats/${wav}`, words: wordsRel,
  ledger: path.relative(U.ROOT, path.join(vdir, `ledger-${beat}.json`)).split(path.sep).join('/'), time_offset_s: 0, fps: 30, lead_in_s: 0.5, tail_s: 0.9, lead_frames: 2,
  bed: { enabled: true, level_db: -24, label: 'placeholder synthesised drone, not music; replace with a library bed the user auditions' },
  assets: plan.assets || [], scenes,
};
const rows = {};
for (const [id, f] of Object.entries(plan.facts || {})) rows[id] = Array.isArray(f) ? { status: f[0], claim: f[1], source: f[2] || '', url: f[3] || '' } : f;
const used = new Set(scenes.flatMap((s) => s.ledger));
used.forEach((id) => { if (!rows[id]) err(`a scene cites fact "${id}" which the plan's facts do not define`); });

if (errors.length) { console.error(errors.join('\n')); U.die(`${errors.length} problem(s) in plan-${beat}.json; nothing written`); }
U.writeJSON(path.join(vdir, `beat-sheet-${beat}.json`), sheet);
U.writeJSON(path.join(vdir, `ledger-${beat}.json`), { note: `Rows for beat ${beat}, from the fact ledger in script/SCRIPT-v7.md. Compiled from plan-${beat}.json by compose-beat.js.`, rows });
U.say(`composed ${scenes.length} scenes, ${kept.length} sounds (${dropped.length} default sounds dropped for spacing or density: ${dropped.map((d) => d.e.event).join('; ') || 'none'}), ${Object.keys(rows).length} ledger rows`);
if (!U.flag('no-lint')) {
  const r = U.run(process.execPath, [path.join(__dirname, 'beatsheet-lint.js'), '--sheet', path.join(vdir, `beat-sheet-${beat}.json`)]);
  U.say(r.stdout.split('\n').filter((l) => l && !l.startsWith('# ')).join('\n'));
}

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.