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