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

.claude/skills/video-visual-production/scripts/beatsheet-lint.js

Code223 lines15.5 KBSkill: video-visual-production

What the file says about itself

V3 Beat-sheet lint: the hard gates (H) and warnings (W) that can be decided from the beat sheet, the word times and the ledger.

#!/usr/bin/env node
// V3 Beat-sheet lint: the hard gates (H) and warnings (W) that can be decided from the beat sheet, the word times and the ledger.
//   node beatsheet-lint.js --sheet <beat-sheet.json> [--damage]
// Rule ids follow research/video-production/BUILD-SPEC.md section 4. Layout rules (T2 to T4) run on the rendered frames in check-video.js.
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const { compile, audioDuration } = require('./timeline');

const SCENE_TYPES = ['date', 'cast', 'doc', 'crowd', 'timeline', 'slam', 'route', 'ladder', 'photo', 'merge', 'ceiling', 'cocktail', 'disc', 'black', 'plant', 'workers', 'person', 'chips3', 'dots', 'loop', 'pivotno', 'stat', 'bars', 'line', 'photo2', 'chips', 'race', 'stalls', 'treadmill', 'unitgrid', 'stamps', 'paths', 'rungs', 'calendar', 'deadline', 'layers', 'objects', 'cards', 'queue', 'stopbtn', 'nested'];
const CLASSES = ['E', 'D', 'M', 'T', 'A'];
const OK_STATUS = ['V', 'V-1source', 'derived', 'analysis', 'alleged', 'estimate'];
const ASSET_KINDS = ['capture', 'commons', 'quote'];
const FREE = /^(CC0|Public domain|PD[- ]|CC BY(-SA)? [0-9.]+|GODL)/i;
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9ऀ-ॿ ]/g, '').trim();

function lint(sheet, ctx) {
  const hard = [], warn = [];
  const H = (rule, msg) => hard.push({ rule, msg });
  const W = (rule, msg) => warn.push({ rule, msg });
  const words = ctx.words.filter((w) => w.beat === sheet.beat);
  const fps = sheet.fps || 30;

  // timeline (compiles the cue frames and checks that spans tile the words)
  let tl;
  try { tl = compile(sheet, ctx.words, ctx.audioDur); } catch (e) { H('STRUCT', 'timeline failed to compile: ' + e.message); return { hard, warn, tl: null }; }
  tl.errors.forEach((e) => H('STRUCT', e));

  const assetIds = new Set();
  (sheet.assets || []).forEach((a) => { if (a.crops) a.crops.forEach((c) => assetIds.add(c.id)); else assetIds.add(a.id); });
  for (const a of sheet.assets || []) {
    if (!ASSET_KINDS.includes(a.kind)) H('H4', `asset ${a.id}: kind "${a.kind}" is not allowed (news video, logos and stock are off by default)`);
    if (/logo|face|portrait|headshot/i.test(JSON.stringify([a.id, a.title, a.use]))) H('H4', `asset ${a.id}: looks like a logo or a face`);
  }

  sheet.scenes.forEach((s, i) => {
    const sc = tl.scenes[i];
    const span = words.slice(s.span[0], s.span[1] + 1);
    const spanWords = span.map((w) => norm(w.text));
    // H1
    if (!CLASSES.includes(s.class)) H('H1', `${s.id}: class "${s.class}" missing or not one of E D M T A`);
    if (!SCENE_TYPES.includes(s.type)) H('H1', `${s.id}: unknown scene type "${s.type}"`);
    if (/video|news|clip|logo/i.test(s.type)) H('H4', `${s.id}: scene type ${s.type} is not allowed`);
    // H2 / H5
    const rows = ctx.ledger.rows || {};
    for (const id of s.ledger || []) {
      if (!rows[id]) { H('H2', `${s.id}: ledger id "${id}" has no row`); continue; }
      if (!OK_STATUS.includes(rows[id].status)) H('H5', `${s.id}: ledger row "${id}" has status ${rows[id].status}; only ${OK_STATUS.join(', ')} may be drawn`);
      if (rows[id].status === 'alleged' && !(s.text || []).some((t) => /alleged, not proved/i.test(t.value))) H('H6', `${s.id}: alleged claim without the "alleged, not proved" chip`);
      if (rows[id].status === 'estimate' && !(s.text || []).some((t) => /estimate/i.test(t.value))) H('H6', `${s.id}: an estimate without the "estimate" chip`);
    }
    if (s.class === 'D' && !(s.ledger || []).length) H('H2', `${s.id}: class D scene with no ledger row`);
    // H3: evidence scenes
    if (s.class === 'E') {
      const aid = s.props && s.props.asset;
      if (!aid || !assetIds.has(aid)) H('H3', `${s.id}: class E scene names no known asset (${aid})`);
      else {
        const rec = ctx.assets[aid];
        if (!rec) H('H3', `${s.id}: asset ${aid} has no record (run assets.js)`);
        else if (rec.kind === 'capture' && rec.captured === false && !rec.fallback_text) H('H3', `${s.id}: asset ${aid} was not captured and has no fallback text`);
        else if (rec.kind === 'commons' && !FREE.test(rec.licence || '')) H('H3', `${s.id}: asset ${aid} has no allowed licence`);
        else if (!rec.source_url) H('H3', `${s.id}: asset ${aid} has no source URL`);
      }
      if (!(s.text || []).some((t) => t.role === 'source')) H('H3', `${s.id}: class E scene without an on-screen source line`);
    }
    // text: numbers spoken, cues inside the span, holds
    const durLeft = (cue) => sc.dur_s - (cue === 'enter' ? 0 : sc.cues[cue]);
    for (const t of s.text || []) {
      const cueWord = (s.cues || {})[t.cue];
      if (t.cue !== 'enter' && cueWord == null) { H('STRUCT', `${s.id}: text "${t.value}" uses cue ${t.cue} which the scene does not define`); continue; }
      // a number is one token even with commas or a point (1,101 is 1101, as the voice-over words are normalised)
      const digits = (String(t.value).match(/\d[\d,.]*\d|\d{2,}/g) || []).map((d) => d.replace(/[,.]/g, ''));
      if (t.role === 'source' || t.role === 'chapter') { /* dates in a source line are the outlet's, not the voice-over's */ }
      else if (t.number || digits.length) {
        for (const d of digits) {
          const spoken = spanWords.includes(d) || (t.recall_from != null && norm(words[t.recall_from].text) === d && t.recall_from < s.span[0]);
          if (!spoken && !(t.spoken_as && spanWords.some((w) => norm(t.spoken_as).split(' ').includes(w)))) H('H8', `${s.id}: "${t.value}" is on screen but ${d} is not spoken in the scene (words ${s.span})`);
        }
        if (!digits.length && t.spoken_as && !spanWords.some((w) => norm(t.spoken_as).split(' ').includes(w))) H('H8', `${s.id}: "${t.value}" claims it is spoken as "${t.spoken_as}", which is not in the span`);
        // S1: the number appears on its word, within -0.2 s to +0.3 s
        if (cueWord != null && t.recall_from == null) {
          const w = words[cueWord], ok = digits.some((d) => norm(w.text) === d) || (t.spoken_as && norm(t.spoken_as).split(' ').includes(norm(w.text)));
          if (!ok) W('S1', `${s.id}: number "${t.value}" is cued to word ${cueWord} ("${w.text}"), not to the word that says it`);
        }
      } else if (t.role !== 'source' && t.role !== 'chapter' && t.role !== 'evidence' && !t.recall_from) {
        const toks = norm(t.spoken_as || t.value).split(' ');
        if (!toks.some((x) => x && spanWords.includes(x))) W('LABEL', `${s.id}: "${t.value}" has no word in the voice-over span (add spoken_as or recall_from)`);
      }
      if (t.recall_from != null && !(t.recall_from < s.span[0] || t.recall_from > s.span[1])) W('LABEL', `${s.id}: recall_from ${t.recall_from} is inside the span`);
      // T1: hold
      if (t.role !== 'source') {
        const nWords = String(t.value).split(/\s+/).length;
        const need = Math.max(1.2, 0.35 * nWords);
        const hold = durLeft(t.cue);
        if (hold + 1e-6 < need) H('T1', `${s.id}: "${t.value}" is held ${hold.toFixed(2)} s, needs ${need.toFixed(2)} s (${nWords} words)`);
      }
      // L3
      const n = String(t.value).split(/\s+/).length;
      if (t.role === 'keyword' && n > 7) W('L3', `${s.id}: keyword "${t.value}" has ${n} words (max 7)`);
      if (t.role === 'evidence' && n > 14) W('L3', `${s.id}: quoted evidence has ${n} words (max 14)`);
    }
    // H8 (other direction): every spoken numeral in the span is on screen in this scene
    for (const w of span.filter((x) => x.number)) {
      if (s.waive_numerals && s.waive_numerals[norm(w.text)]) { W('H8-WAIVED', `${s.id}: numeral "${w.text}" is spoken but not shown. Waiver: ${s.waive_numerals[norm(w.text)]}`); continue; }
      const shown = (s.text || []).some((t) => String(t.value).replace(/,/g, '').includes(norm(w.text)) || (t.spoken_as && norm(t.spoken_as).split(' ').includes(norm(w.text))));
      if (!shown) H('H8', `${s.id}: "${w.text}" is spoken but not shown on screen`);
    }
    // sentence-length scenes: a scene must not be empty of change for more than 8 s (P3)
    const changes = [0, ...Object.entries(sc.cues).filter(([k]) => k !== 'enter').map(([, v]) => v), sc.dur_s].sort((a, b) => a - b);
    for (let k = 1; k < changes.length; k++) if (changes[k] - changes[k - 1] > 8) W('P3', `${s.id}: ${(changes[k] - changes[k - 1]).toFixed(1)} s without a visible change`);
  });

  // C2: a person is tagged where they arrive
  const first = words.findIndex((w) => /^puri\b/i.test(w.text));
  if (first >= 0) {
    const scn = sheet.scenes.find((s) => first >= s.span[0] && first <= s.span[1]);
    if (!scn || !(scn.text || []).some((t) => t.role === 'name')) W('C2', `Puri is first spoken at word ${first} in a scene without a name card`);
  }
  // P1: scene starts at sentence starts (a 'start_shift_frames' scene starts mid-sentence on purpose)
  const sentStarts = new Set([0]);
  words.forEach((w, i) => { if (/[.?!]["')]*$/.test(w.text) && i + 1 < words.length) sentStarts.add(i + 1); });
  const onSent = sheet.scenes.filter((s) => sentStarts.has(s.span[0]) && !s.start_shift_frames).length;
  if (onSent / sheet.scenes.length < 0.9) W('P1', `only ${onSent} of ${sheet.scenes.length} scene starts are on a sentence start`);
  // P2 / P4 / P5
  const durs = tl.scenes.map((s) => s.dur_s);
  if (Math.max(...durs) > 20) W('P2', `a scene is ${Math.max(...durs).toFixed(1)} s long (max 20)`);
  if (tl.report.median_s < 2 || tl.report.median_s > 7) W('P2', `median scene length ${tl.report.median_s.toFixed(1)} s is outside 2 to 7 s`);
  let run = 1;
  sheet.scenes.forEach((s, i) => { run = i && s.type === sheet.scenes[i - 1].type ? run + 1 : 1; if (run > 3) W('P4', `${s.id}: more than 3 scenes of type ${s.type} in a row`); });
  if (new Set(sheet.scenes.map((s) => s.type)).size < 4) W('P4', 'fewer than 4 scene types');
  if (tl.report.stdev_s < 1.5) W('P5', `scene length stdev ${tl.report.stdev_s} s is under 1.5 s`);
  // L1: numbers per 30 s
  const nums = tl.scenes.flatMap((sc) => sc.text.filter((t) => t.number).map((t) => (sc.start / fps) + (sc.cues[t.cue] || 0)));
  nums.forEach((t0) => { const c = nums.filter((x) => x >= t0 && x < t0 + 30).length; if (c > 3) W('L1', `${c} numbers within 30 s from ${t0.toFixed(0)} s`); });
  // A3/A4: sound events
  const ev = tl.sfx.filter((e) => !e.texture);
  tl.sfx.forEach((e) => { if (!e.role || !e.event) H('A4', `sfx at frame ${e.frame} has no role or event id`); });
  ev.forEach((e, i) => {
    if (i && (e.frame - ev[i - 1].frame) / fps < 0.3) H('A3', `sfx "${e.event}" is ${(((e.frame - ev[i - 1].frame) / fps) * 1000).toFixed(0)} ms after "${ev[i - 1].event}" (min 300 ms)`);
    const t0 = e.frame / fps;
    if (ev.filter((x) => x.frame / fps >= t0 && x.frame / fps < t0 + 10).length > 3) H('A3', `more than 3 sfx within 10 s from ${t0.toFixed(1)} s`);
    if (ev.filter((x) => x.frame / fps >= t0 && x.frame / fps < t0 + 60).length > 12) H('A3', `more than 12 sfx within 60 s from ${t0.toFixed(1)} s`);
    const vt = e.frame / fps - sheet.lead_in_s;
    for (let k = 1; k < words.length; k++) if (words[k].start - words[k - 1].end > 0.6 && vt > words[k - 1].end && vt < words[k].start) W('A3', `sfx "${e.event}" falls inside a ${(words[k].start - words[k - 1].end).toFixed(2)} s pause`);
  });
  if (tl.report.sfx_per_min > 12) H('A3', `${tl.report.sfx_per_min} sfx a minute`);
  if (tl.report.sfx_per_min > 8) W('A3', `${tl.report.sfx_per_min} sfx a minute is above the 5 to 8 target`);
  for (const w of tl.report.interpolated_cues) W('SYNC', `cue ${w} sits on an interpolated word time (not measured)`);
  return { hard, warn, tl };
}

function loadCtx(sheetPath) {
  const sheet = U.readJSON(sheetPath);
  const words = U.loadWords(sheet);
  const ledger = U.readJSON(U.fromRoot(sheet.ledger));
  const adir = path.join(U.buildDir(sheet.slug, sheet.beat), 'assets');
  const assets = {};
  if (fs.existsSync(adir)) for (const f of fs.readdirSync(adir)) if (f.endsWith('.json')) { const r = U.readJSON(path.join(adir, f)); assets[r.id] = r; }
  return { sheet, ctx: { words, ledger, assets, audioDur: audioDuration(U.fromRoot(sheet.audio)) } };
}

// ---- damage test: each mutation must trip at least one hard gate; the unmutated sheet must have none
const MUTATIONS = [
  ['H1 class removed', (s) => { delete s.scenes[2].class; }, 'H1'],
  ['H1 unknown scene type', (s) => { s.scenes[1].type = 'hologram'; }, 'H1'],
  ['H2 ledger id with no row', (s) => { s.scenes[2].ledger = ['made-up-fact']; }, 'H2'],
  ['H3 source line removed from an evidence scene', (s) => { s.scenes[2].text = s.scenes[2].text.filter((t) => t.role !== 'source'); }, 'H3'],
  ['H3 evidence scene names no asset', (s) => { s.scenes[2].props.asset = 'nope'; }, 'H3'],
  ['H4 news video asset', (s) => { s.assets.push({ id: 'tv', kind: 'video', url: 'x' }); }, 'H4'],
  ['H4 logo asset', (s) => { s.assets.push({ id: 'company-logo', kind: 'commons', title: 'File:Logo.png' }); }, 'H4'],
  ['H5 CHECK status drawn', (s, c) => { c.ledger.rows['early-career'].status = 'CHECK'; }, 'H5'],
  ['H8 number on screen that is not spoken', (s) => { s.scenes[0].text[0].value = '1987'; }, 'H8'],
  ['H8 spoken numeral not shown', (s) => { s.scenes[4].text = s.scenes[4].text.filter((t) => t.value !== '1964'); }, 'H8'],
  ['STRUCT cue outside its span', (s) => { s.scenes[1].cues.age = 99; }, 'STRUCT'],
  ['STRUCT gap between spans', (s) => { s.scenes[3].span[0] += 1; }, 'STRUCT'],
  ['T1 text held too briefly', (s) => { s.scenes[13].cues.worked = 279; s.scenes[13].sfx = []; }, 'T1'],
  ['A3 two effects 100 ms apart', (s) => { s.scenes[0].sfx.push({ sound: 'tick', cue: 'year', contact: 'stamp', role: 'number', event: 'extra' }); }, 'A3'],
  ['A4 effect without a role', (s) => { delete s.scenes[0].sfx[0].role; }, 'A4'],
];

function damage(sheet0, ctx0) {
  const base = lint(sheet0, ctx0);
  const out = { base_hard: base.hard.length, results: [] };
  for (const [name, mut, rule] of MUTATIONS) {
    const sheet = JSON.parse(JSON.stringify(sheet0)), ctx = { ...ctx0, ledger: JSON.parse(JSON.stringify(ctx0.ledger)) };
    mut(sheet, ctx);
    let r;
    try { r = lint(sheet, ctx); } catch (e) { r = { hard: [{ rule: 'CRASH', msg: e.message }] }; }
    const caught = r.hard.some((h) => h.rule === rule);
    out.results.push({ name, expected: rule, caught, fired: [...new Set(r.hard.map((h) => h.rule))] });
  }
  out.all_caught = out.base_hard === 0 && out.results.every((x) => x.caught);
  return out;
}

function report(sheet, res) {
  const L = [`# Beat-sheet lint: ${sheet.beat} ${sheet.title}`, '', `Hard gates failing: ${res.hard.length}. Warnings: ${res.warn.length}.`, ''];
  if (res.hard.length) { L.push('## Hard', ...res.hard.map((h) => `- **${h.rule}** ${h.msg}`), ''); }
  if (res.warn.length) { L.push('## Warnings', ...res.warn.map((h) => `- ${h.rule}: ${h.msg}`), ''); }
  return L.join('\n');
}

module.exports = { lint, loadCtx, damage, report };

if (require.main === module) {
  const sheetPath = U.fromRoot(U.arg('sheet', ''));
  if (!fs.existsSync(sheetPath)) U.die('--sheet required');
  const { sheet, ctx } = loadCtx(sheetPath);
  const res = lint(sheet, ctx);
  const md = report(sheet, res);
  fs.writeFileSync(path.join(path.dirname(sheetPath), `lint-${sheet.beat}.md`), md);
  U.say(md);
  if (U.flag('damage')) {
    // the mutations are written against the B1 fixture (scene indexes, ledger ids), so the damage test always runs on it
    const fx = loadCtx(path.join(U.ROOT, 'research', 'the-fall-of-moser-baer', 'video', 'beat-sheet-B1.json'));
    const d = damage(fx.sheet, fx.ctx);
    d.results.forEach((r) => U.say(`${r.caught ? 'caught ' : 'MISSED '} ${r.name}  [expected ${r.expected}; fired ${r.fired.join(', ') || 'nothing'}]`));
    U.say(d.all_caught ? `damage test: all ${d.results.length} mutations caught` : 'damage test: FAILED');
    if (!d.all_caught) process.exit(1);
  }
  process.exit(res.hard.length ? 1 : 0);
}

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.