Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/extract-vo.js
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.