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

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

Code54 lines4.4 KBSkill: video-visual-production

What the file says about itself

Write a brief for one aligned beat: what the voice says sentence by sentence with word indexes and times, the graphic note and the plan section for the beat, and candidate fact-ledger rows.

#!/usr/bin/env node
// Write a brief for one aligned beat: what the voice says sentence by sentence with word indexes and times, the graphic note and
// the plan section for the beat, and candidate fact-ledger rows. The storyboarder starts here instead of searching.
//   node brief-beat.js --beat B3 [--topic the-fall-of-moser-baer]
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');

const beat = U.arg('beat', null);
if (!beat) U.die('usage: brief-beat.js --beat B3');
const topic = U.arg('topic', 'the-fall-of-moser-baer');
const vdir = path.join(U.ROOT, 'research', topic, 'video');
const wordsFile = path.join(vdir, 'timing', `words-${beat}.json`);
if (!fs.existsSync(wordsFile)) U.die(`no ${wordsFile}: run align-beat.js --beat ${beat} first`);
const words = U.readJSON(wordsFile).words;

// sentences
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 scriptDir = path.join(U.ROOT, 'research', topic, 'script');
const hing = fs.readFileSync(path.join(scriptDir, 'SCRIPT-v7-hinglish.md'), 'utf8');
const sec = hing.split(/^### \[/m).find((b) => b.startsWith(beat + ']')) || '';
const graphic = (sec.match(/\*\(Graphic:[^)]*\)\*/g) || []).join('\n') || '(none)';
const plan = fs.readFileSync(path.join(U.ROOT, 'research', 'video-production', '05-moser-baer-plan.md'), 'utf8');
const pm = plan.split(/^### /m).find((b) => b.startsWith(beat + ' ')) || '(no plan entry)';

// candidate ledger rows: rows of section 5 that share numerals or capitalised names with this beat's voice-over
const ledgerText = fs.readFileSync(path.join(scriptDir, 'SCRIPT-v7.md'), 'utf8').split(/^## 5\./m)[1] || '';
const rows = ledgerText.split('\n').filter((l) => l.startsWith('|') && !l.startsWith('|---') && !l.startsWith('| Claim'));
const voice = words.map((w) => w.text).join(' ');
const toks = [...new Set((voice.match(/[A-Z][a-zA-Z]{3,}|\d[\d,.]*/g) || []).map((t) => t.replace(/[.,]$/, '')))].filter((t) => !['Aapne', 'Lekin', 'Yeh', 'Wahi'].includes(t));
const scored = rows.map((r) => ({ r, n: toks.filter((t) => r.includes(t)).length })).filter((x) => x.n > 0).sort((a, b) => b.n - a.n).slice(0, 16);

const fmt = (n) => n.toFixed(2);
const L = [`# Brief: ${beat} (generated by brief-beat.js; facts come from the script ledger, verify before drawing)`, '',
  `Words: ${words.length}, measured ${words.filter((w) => w.source === 'whisper').length}, duration ${fmt(words[words.length - 1].end)} s. Words file: \`research/${topic}/video/timing/words-${beat}.json\` (times local to the beat, offset 0). Add the video lead-in (0.5 s) to get video time.`, '',
  '## The voice-over, sentence by sentence', '', '| # | words | seconds | pause before (s) | says (~ marks an estimated word time) |', '|---|---|---|---|---|'];
sents.forEach((idx, n) => {
  const a = words[idx[0]], z = words[idx[idx.length - 1]], prev = n ? words[sents[n - 1][sents[n - 1].length - 1]] : null;
  L.push(`| S${n + 1} | ${idx[0]}-${idx[idx.length - 1]} | ${fmt(a.start)}-${fmt(z.end)} | ${prev ? fmt(a.start - prev.end) : '-'} | ${idx.map((i) => words[i].text + (words[i].source === 'interpolated' ? '~' : '') + (words[i].number ? ' [NUM]' : '')).join(' ').replace(/\|/g, '/')} |`);
});
const nums = words.filter((w) => w.number);
L.push('', `Numerals the voice speaks (each must be shown in its scene, or waived with a reason): ${nums.length ? nums.map((w) => `${w.text} (word ${w.i})`).join(', ') : 'none'}.`);
L.push(`Questions: ${words.filter((w) => w.question).map((w) => w.text + ' (word ' + w.i + ')').join(', ') || 'none'}. Stressed words: ${words.filter((w) => w.stressed).map((w) => w.text + ' (' + w.i + ')').join(', ') || 'none'}.`);
L.push('', '## Graphic note from the Hinglish script', '', graphic, '', '## Plan section (research/video-production/05-moser-baer-plan.md)', '', pm.trim(), '',
  '## Candidate fact-ledger rows (matched on names and numbers in the voice-over; the ledger is SCRIPT-v7.md section 5)', '');
scored.forEach((x) => L.push('- ' + x.r.slice(0, 600)));
L.push('', 'Statuses: `[V]` may be drawn; `[V-search]` and `[CHECK]` may not (the lint refuses). `derived` and `analysis` may be drawn and are labelled as such.');
const out = path.join(vdir, `brief-${beat}.md`);
fs.writeFileSync(out, L.join('\n'));
U.say(`brief: ${sents.length} sentences, ${nums.length} spoken numerals, ${scored.length} candidate ledger rows -> ${out}`);

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.