Docs/Skills and code/skill video-script-production
.claude/skills/video-script-production/scripts/compare.js
What the file says about itself
Compare two linted script versions on the measures that are computed from the spoken text alone, and show how each beat opens. node compare.js --a <lint-vA.json> --b <lint-vB.json> --sa <SCRIPT-vA.md> --sb <SCRIPT-vB.md> --out <compare.md>
Mentioned on: page 09, page 12
#!/usr/bin/env node
// Compare two linted script versions on the measures that are computed from the spoken text alone, and show how each beat opens.
// node compare.js --a <lint-vA.json> --b <lint-vB.json> --sa <SCRIPT-vA.md> --sb <SCRIPT-vB.md> --out <compare.md>
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];
for (const k of ['a', 'b', 'sa', 'sb', 'out']) if (!args[k]) { console.error('usage: node compare.js --a <lint.json> --b <lint.json> --sa <script.md> --sb <script.md> --out <compare.md>'); process.exit(2); }
const A = JSON.parse(fs.readFileSync(args.a, 'utf8')).summary, B = JSON.parse(fs.readFileSync(args.b, 'utf8')).summary;
const openers = (file) => {
const out = []; let cur = null;
for (const l of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
if (/^###\s/.test(l)) { cur = { first: null }; out.push(cur); continue; }
const m = l.match(/^\*\*VO:\*\*\s*(.*)$/);
if (m && cur && !cur.first) cur.first = m[1].replace(/\*\*\[[^\]]*\]\*\*/g, '').replace(/\*+/g, '').split(/(?<=[.?!])\s/)[0];
}
return out.filter((o) => o.first);
};
const oa = openers(args.sa), ob = openers(args.sb);
const row = (n, x, y) => `| ${n} | ${x} | ${y} |`;
const cell = (o) => (o ? o.first.replace(/\|/g, '/') : '');
const md = [
`# ${A.label} vs ${B.label}: what the linter measures`, '',
`Both scripts were linted with \`.claude/skills/video-script-production/scripts/lint.js\`. **Read the measures, not the scores.** A script with no declared chain cannot run the structure rules (loops, ladder, hero numbers, hinge quotes), and those count as failures, so the score gap partly reflects that one script never declared the structure the pipeline demands. The measures below are computed from the spoken text alone and compare fairly.`, '',
`| Measure (text only) | ${A.label} | ${B.label} |`, '|---|---|---|',
row('Spoken words (minutes at 150 wpm)', `${A.words} (${A.minutes})`, `${B.words} (${B.minutes})`),
row('Spoken quantities', A.quantities, B.quantities),
row('Quantities per minute (budget 5)', A.quantities_per_min, B.quantities_per_min),
row('Most quantities in any 30 seconds (budget 4)', A.max_quantities_in_30s, B.max_quantities_in_30s),
row('Quantities in the hook (budget 1)', A.hook_quantities, B.hook_quantities),
row('Beat joins that open on an additive or "next topic" marker', A.additive_openers, B.additive_openers),
row('Additive connectives inside beats', A.additive_inline, B.additive_inline),
row('Negation pivots ("not X, it is Y"); budget 1', A.voice ? A.voice.negation_pivots : 'n/a', B.voice ? B.voice.negation_pivots : 'n/a'),
row('Staged run-ups and script-talk; budget 3', A.voice ? A.voice.run_ups : 'n/a', B.voice ? B.voice.run_ups : 'n/a'),
row('Contractions per 100 words (spoken English runs about 1.5)', A.voice ? A.voice.contractions_per_100 : 'n/a', B.voice ? B.voice.contractions_per_100 : 'n/a'),
row('Comparison markers ("say you", "think of it as"); budget 4 to 16', A.texture ? A.texture.figure_markers : 'n/a', B.texture ? B.texture.figure_markers : 'n/a'),
row('Hard 30 s windows (2+ figures), share; winners median about 0.30', A.texture ? A.texture.hard_share : 'n/a', B.texture ? B.texture.hard_share : 'n/a'),
row('Quiet 30 s windows (no figure), share; winners median about 0.46', A.texture ? A.texture.quiet_share : 'n/a', B.texture ? B.texture.quiet_share : 'n/a'),
row('Longest run of hard windows; winners median 3', A.texture ? A.texture.longest_hard_run : 'n/a', B.texture ? B.texture.longest_hard_run : 'n/a'),
row('Most new people or organisations in any one beat (budget 5)', A.texture && A.texture.new_names_busiest_beat != null ? A.texture.new_names_busiest_beat : 'n/a', B.texture && B.texture.new_names_busiest_beat != null ? B.texture.new_names_busiest_beat : 'n/a'),
row('Longest stretch with no person and no "you" (s; needs declared humans)', A.texture && A.texture.person_gap_sec != null ? A.texture.person_gap_sec : 'n/a', B.texture && B.texture.person_gap_sec != null ? B.texture.person_gap_sec : 'n/a'),
'', `| Rule outcome | ${A.label} | ${B.label} |`, '|---|---|---|',
row('FAIL / WARN / PASS', `${A.fails} / ${A.warns} / ${A.passes}`, `${B.fails} / ${B.warns} / ${B.passes}`),
row('Score (pass 1, warn 0.5, fail 0)', `${(A.score * 100).toFixed(0)}`, `${(B.score * 100).toFixed(0)}`),
'', '## How each beat starts', '',
`| ${A.label} opener | ${B.label} opener |`, '|---|---|',
...Array.from({ length: Math.max(oa.length, ob.length) }, (_, i) => `| ${cell(oa[i])} | ${cell(ob[i])} |`), '',
'## Caveats', '',
'- The linter checks declared structure and counts. It cannot judge whether a care line lands, whether a BUT really reverses something the viewer expected, or whether the script is good. A blind reader pass is still needed.',
'- Its thresholds (4 quantities in 30 s, 7 hero numbers, a 75-second hook, and so on) are design budgets from working-memory research and from the project corpus. They are not validated against audience retention.',
'- `scripts/selftest.js` shows each rule fires when its property is broken.', '',
].join('\n');
fs.writeFileSync(args.out, md, 'utf8');
console.log(`wrote ${args.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.