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

.claude/skills/video-visual-production/scripts/edit.js

Code113 lines6.5 KBSkill: video-visual-production

What the file says about itself

Cheap, safe edits to a beat sheet. Every change is snapshotted, linted (and undone if it breaks a hard gate) and logged. node edit.js words <from> <to> print word indexes with times, to find the word a cue should sit on node edit.js show…

#!/usr/bin/env node
// Cheap, safe edits to a beat sheet. Every change is snapshotted, linted (and undone if it breaks a hard gate) and logged.
//   node edit.js words <from> <to>                    print word indexes with times, to find the word a cue should sit on
//   node edit.js show [sceneId]                       list scenes, or print one scene
//   node edit.js set <sceneId|sheet> <path> <value>   e.g. set B1.06 props.highlights.0.phrase "Frequent strikes"
//   node edit.js cue <sceneId> <name> <wordIndex>     move a build to another spoken word
//   node edit.js level <sound> <dB>                   change one sound's level for the whole beat (tick -14, swipe -18, ...)
//   node edit.js sfx-remove <sceneId> <index>         drop one sound event from a scene
//   node edit.js undo                                 restore the previous snapshot
//   node edit.js history                              print the edit log
// Options: --sheet <beat-sheet.json> (default: the only beat-sheet-*.json under research/*/video/), --note "what the user said"
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const { lint, loadCtx } = require('./beatsheet-lint');

const args = process.argv.slice(2).filter((a, i, all) => !a.startsWith('--') && !(i > 0 && ['--sheet', '--note'].includes(all[i - 1])));
const cmd = args[0];

function findSheet() {
  const given = U.arg('sheet', null);
  if (given) return U.fromRoot(given);
  const found = [];
  const base = path.join(U.ROOT, 'research');
  for (const d of fs.readdirSync(base)) { const v = path.join(base, d, 'video'); if (fs.existsSync(v)) for (const f of fs.readdirSync(v)) if (/^beat-sheet-.*\.json$/.test(f)) found.push(path.join(v, f)); }
  if (found.length !== 1) U.die('pass --sheet <beat-sheet.json>; found: ' + (found.join(', ') || 'none'));
  return found[0];
}
const SHEET = findSheet();
const vdir = path.dirname(SHEET);
const sheet0 = U.readJSON(SHEET);
const histDir = path.join(vdir, 'history');
const logFile = path.join(vdir, `edits-${sheet0.beat}.md`);

function setPath(obj, p, value) {
  const keys = p.split('.');
  let o = obj;
  keys.slice(0, -1).forEach((k, i) => { if (o[k] == null) o[k] = /^\d+$/.test(keys[i + 1]) ? [] : {}; o = o[k]; });
  o[keys[keys.length - 1]] = value;
}
const parse = (v) => { try { return JSON.parse(v); } catch (_) { return v; } };
const sceneOf = (sheet, id) => { const s = sheet.scenes.find((x) => x.id === id || x.id === `${sheet.beat}.${id}` || x.id.endsWith('.' + id)); if (!s) U.die('no scene ' + id + '; scenes: ' + sheet.scenes.map((x) => x.id).join(', ')); return s; };

function mutate(desc, fn) {
  fs.mkdirSync(histDir, { recursive: true });
  const snap = path.join(histDir, `${sheet0.beat}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
  fs.copyFileSync(SHEET, snap);
  const sheet = U.readJSON(SHEET);
  fn(sheet);
  fs.writeFileSync(SHEET, JSON.stringify(sheet, null, 2) + '\n');
  const { sheet: s2, ctx } = loadCtx(SHEET);
  const res = lint(s2, ctx);
  if (res.hard.length) {
    fs.copyFileSync(snap, SHEET); fs.unlinkSync(snap);
    U.say('REVERTED: the edit breaks hard gates:\n' + res.hard.map((h) => `  ${h.rule} ${h.msg}`).join('\n'));
    process.exit(1);
  }
  const note = U.arg('note', '');
  fs.appendFileSync(logFile, `- ${new Date().toISOString().slice(0, 16).replace('T', ' ')}  ${desc}${note ? '  |  ' + note : ''}\n`);
  U.say(`ok: ${desc}\n  lint: 0 hard gates failing, ${res.warn.length} warnings. Snapshot: ${path.relative(U.ROOT, snap)}\n  next: node .claude/skills/video-visual-production/scripts/build-beat.js --sheet ${path.relative(U.ROOT, SHEET)}   (only changed scenes re-render)\n  or look first: node .claude/skills/video-visual-production/scripts/preview-scene.js --sheet ${path.relative(U.ROOT, SHEET)} --scene <id>`);
}

switch (cmd) {
  case 'words': {
    const words = U.loadWords(sheet0).filter((w) => w.beat === sheet0.beat);
    const a = +args[1], b = +args[2];
    U.say(words.slice(a, b + 1).map((w) => `${w.i}:${w.text}@${(w.start + sheet0.lead_in_s).toFixed(2)}${w.source === 'interpolated' ? '~' : ''}`).join('  '));
    U.say('(times are video seconds; ~ means interpolated, not measured)');
    break;
  }
  case 'show': {
    if (args[1]) { U.say(JSON.stringify(sceneOf(sheet0, args[1]), null, 2)); break; }
    const tlp = path.join(vdir, `timeline-${sheet0.beat}.json`);
    const tl = fs.existsSync(tlp) ? U.readJSON(tlp) : null;
    sheet0.scenes.forEach((s, i) => U.say(`${s.id}  words ${s.span[0]}-${s.span[1]}  ${tl ? (tl.scenes[i].start / 30).toFixed(1) + '-' + (tl.scenes[i].end / 30).toFixed(1) + ' s' : ''}  ${s.type}/${s.class}  cues ${Object.entries(s.cues || {}).map(([k, v]) => k + ':' + v).join(' ')}`));
    break;
  }
  case 'set': {
    const [, id, p, v] = args;
    if (!id || !p || v == null) U.die('usage: set <sceneId|sheet> <path> <value>');
    mutate(`set ${id} ${p} = ${v}`, (sheet) => setPath(id === 'sheet' ? sheet : sceneOf(sheet, id), p, parse(v)));
    break;
  }
  case 'cue': {
    const [, id, name, w] = args;
    if (!id || !name || w == null) U.die('usage: cue <sceneId> <name> <wordIndex>');
    mutate(`cue ${id}.${name} -> word ${w}`, (sheet) => { const s = sceneOf(sheet, id); if (!(name in (s.cues || {}))) U.die(`scene ${s.id} has no cue ${name}; it has ${Object.keys(s.cues || {}).join(', ')}`); s.cues[name] = +w; });
    break;
  }
  case 'level': {
    const [, sound, dbv] = args;
    if (!sound || dbv == null) U.die('usage: level <sound> <dB>');
    mutate(`level ${sound} = ${dbv} dB`, (sheet) => { sheet.levels = sheet.levels || {}; sheet.levels[sound] = +dbv; });
    break;
  }
  case 'sfx-remove': {
    const [, id, idx] = args;
    mutate(`sfx-remove ${id} #${idx}`, (sheet) => { const s = sceneOf(sheet, id); if (!s.sfx || !s.sfx[+idx]) U.die('no such sound event'); s.sfx.splice(+idx, 1); });
    break;
  }
  case 'undo': {
    const snaps = fs.existsSync(histDir) ? fs.readdirSync(histDir).filter((f) => f.startsWith(sheet0.beat + '-')).sort() : [];
    if (!snaps.length) U.die('nothing to undo');
    const last = path.join(histDir, snaps[snaps.length - 1]);
    fs.copyFileSync(last, SHEET); fs.unlinkSync(last);
    fs.appendFileSync(logFile, `- ${new Date().toISOString().slice(0, 16).replace('T', ' ')}  undo (restored ${snaps[snaps.length - 1]})\n`);
    U.say('restored ' + snaps[snaps.length - 1]);
    break;
  }
  case 'history': U.say(fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '(no edits yet)'); break;
  default: U.die('commands: words, show, set, cue, level, sfx-remove, undo, history (see the header of edit.js)');
}

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.