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

.claude/skills/video-script-production/scripts/extract-vo.js

Code48 lines2.5 KBSkill: video-script-production

What the file says about itself

Prints the spoken text of a script, for the read-aloud pass and for blind readers. node extract-vo.js --script <SCRIPT-vN.md> [--mode timed|blind|plain] [--out <file>] timed (default) beat headings with computed time ranges, numbered…

Mentioned on: page 06, page 09, page 10, page 12, page 13

#!/usr/bin/env node
// Prints the spoken text of a script, for the read-aloud pass and for blind readers.
//   node extract-vo.js --script <SCRIPT-vN.md> [--mode timed|blind|plain] [--out <file>]
//   timed (default)  beat headings with computed time ranges, numbered paragraphs, word counts
//   blind            numbered paragraphs only: no headings, no graphics, no ids. This is what a blind reader gets.
//   plain            the voice-over as running text, one paragraph per line (for pasting into a teleprompter or a tool)
// Same parsing rules as lint.js: beats are "### [Bn] ..." headings, spoken text is every line starting "**VO:**".

const fs = require('fs');

const args = {};
for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
if (!args.script) { console.error('usage: node extract-vo.js --script <SCRIPT-vN.md> [--mode timed|blind|plain] [--out file] [--wpm 150]'); process.exit(2); }
const WPM = Number(args.wpm || 150);
const mode = args.mode || 'timed';

const clean = (s) => s
  .replace(/\*\*\[[^\]]*\]\*\*/g, ' ').replace(/\[(?:V|CHECK)[^\]]*\]/gi, ' ')
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*+/g, '').replace(/\s+/g, ' ').trim();
const fmt = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, '0')}`;

const beats = [];
let cur = null;
for (const l of fs.readFileSync(args.script, 'utf8').split(/\r?\n/)) {
  const h = l.match(/^###\s*\[(B\d+)\]\s*(.*?)(?:\s*\(\d+:\d+ to \d+:\d+\))?\s*$/);
  if (h) { cur = { id: h[1], title: h[2], vo: [] }; beats.push(cur); continue; }
  if (/^#{1,3}\s/.test(l)) { cur = null; continue; }
  const m = l.match(/^\*\*VO:\*\*\s*(.*)$/);
  if (m && cur) cur.vo.push(clean(m[1]));
}
const wc = (s) => (s.match(/\S+/g) || []).length;
let words = 0, p = 0;
const out = [];
for (const b of beats.filter((x) => x.vo.length)) {
  const w = b.vo.reduce((a, s) => a + wc(s), 0);
  const t0 = (words / WPM) * 60, t1 = ((words + w) / WPM) * 60;
  if (mode === 'timed') out.push(`\n### ${b.id} ${b.title} (${fmt(t0)} to ${fmt(t1)}, ${w} words)`);
  for (const s of b.vo) {
    p++;
    out.push(mode === 'plain' ? s : `[P${p}] ${s}`);
  }
  words += w;
}
const total = `\n(${p} paragraphs, ${words} words, about ${(words / WPM).toFixed(1)} min at ${WPM} wpm)`;
const text = out.join('\n').replace(/^\n/, '') + (mode === 'timed' ? total : '');
if (args.out) { fs.writeFileSync(args.out, text + '\n', 'utf8'); console.log(`wrote ${args.out} (${p} paragraphs, ${words} words)`); }
else console.log(text);

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.