Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/scripts/timeline.js
What the file says about itself
V5 Timeline: beat sheet + aligned word times -> integer frames at 30 fps, cue times per scene, sound events on frames. node timeline.js --sheet <beat-sheet.json> [--out <timeline.json>]
#!/usr/bin/env node
// V5 Timeline: beat sheet + aligned word times -> integer frames at 30 fps, cue times per scene, sound events on frames.
// node timeline.js --sheet <beat-sheet.json> [--out <timeline.json>]
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const CONTACTS = require('../render/contacts');
function compile(sheet, allWords, audioDur, opts = {}) {
const fadeOut = opts.fade_out_s != null ? opts.fade_out_s : (sheet.fade_out_s != null ? sheet.fade_out_s : 0.6); // 0 when the next beat dissolves in (stitching)
const fps = sheet.fps || U.FPS;
const lead = sheet.lead_in_s, tail = sheet.tail_s, leadFrames = sheet.lead_frames == null ? 2 : sheet.lead_frames;
const words = allWords.filter((w) => w.beat === sheet.beat);
const total = Math.round((lead + audioDur + tail) * fps);
const wFrame = (i) => Math.round((words[i].start + lead) * fps);
const errors = [];
const scenes = sheet.scenes.map((s, i) => {
const start = i === 0 ? 0 : wFrame(s.span[0]) - leadFrames + (s.start_shift_frames || 0);
return { id: s.id, type: s.type, class: s.class, black: !!s.black, span: s.span, start, enter: s.enter || { type: 'cut' }, props: s.props, text: s.text || [], ledger: s.ledger || [], source: s.source || null };
});
scenes.forEach((s, i) => { s.end = i + 1 < scenes.length ? scenes[i + 1].start : total; s.dur_s = (s.end - s.start) / fps; });
const sfx = [];
const cueInfo = {};
sheet.scenes.forEach((s, i) => {
const sc = scenes[i];
sc.cues = { enter: 0 };
cueInfo[s.id] = {};
for (const [name, idx] of Object.entries(s.cues || {})) {
if (idx < s.span[0] || idx > s.span[1]) errors.push(`${s.id}: cue ${name} word ${idx} outside span ${s.span}`);
if (!words[idx]) { errors.push(`${s.id}: cue ${name} word ${idx} does not exist`); continue; }
const f = wFrame(idx);
sc.cues[name] = Math.max(0, (f - sc.start) / fps);
cueInfo[s.id][name] = { word: idx, text: words[idx].text, interpolated: words[idx].source === 'interpolated', frame: f };
}
for (const e of s.sfx || []) {
const base = e.cue === 'enter' ? sc.start : cueInfo[s.id][e.cue] && cueInfo[s.id][e.cue].frame;
if (base == null) { errors.push(`${s.id}: sfx cue ${e.cue} unknown`); continue; }
if (CONTACTS[e.contact] == null) { errors.push(`${s.id}: sfx contact ${e.contact} unknown`); continue; }
const n = e.ticks || 1;
for (let k = 0; k < n; k++) {
sfx.push({ scene: s.id, sound: e.sound, role: e.role, event: e.event + (n > 1 ? ` #${k + 1}` : ''), frame: base + Math.round(CONTACTS[e.contact] * fps) + k * fps, step: e.step || 0, k, level_db: -3 * k, texture: e.role === 'texture' });
}
}
});
sfx.sort((a, b) => a.frame - b.frame);
// tiling checks
scenes.forEach((s, i) => {
if (s.end <= s.start) errors.push(`${s.id}: empty or reversed (${s.start}..${s.end})`);
if (i > 0 && s.span[0] !== scenes[i - 1].span[1] + 1) errors.push(`${s.id}: span ${s.span} does not follow ${scenes[i - 1].span}`);
});
if (scenes[0].span[0] !== 0 || scenes[scenes.length - 1].span[1] !== words.length - 1) errors.push(`spans do not cover words 0..${words.length - 1}`);
const speech = words.map((w) => [w.start + lead, w.end + lead]);
const durs = scenes.map((s) => s.dur_s).sort((a, b) => a - b);
const mean = durs.reduce((a, b) => a + b, 0) / durs.length;
const report = {
scenes: scenes.length, median_s: durs[Math.floor(durs.length / 2)], mean_s: +mean.toFixed(2),
stdev_s: +Math.sqrt(durs.reduce((a, b) => a + (b - mean) ** 2, 0) / durs.length).toFixed(2), longest_s: durs[durs.length - 1], shortest_s: durs[0],
sfx_events: sfx.length, sfx_per_min: +(sfx.filter((e) => !e.texture).length / (total / fps / 60)).toFixed(1),
interpolated_cues: Object.entries(cueInfo).flatMap(([id, c]) => Object.entries(c).filter(([, v]) => v.interpolated).map(([n]) => `${id}.${n}`)),
};
return {
version: 1, slug: sheet.slug, beat: sheet.beat, fps, width: 1920, height: 1080, lead_in_s: lead, tail_s: tail, fade_out_s: fadeOut,
audio_duration_s: audioDur, total_frames: total, voice_start_s: lead, declared_black: [...(fadeOut > 0 ? [[total - Math.round(fadeOut * fps), total - 1]] : []), ...scenes.filter((x) => x.black).map((x) => [x.start, x.end - 1])],
style: sheet.style || {}, scenes, sfx, speech, cue_info: cueInfo, report, errors,
};
}
function audioDuration(file) {
const r = U.mustRun('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file]);
return parseFloat(r.stdout);
}
if (require.main === module) {
const sheetPath = U.fromRoot(U.arg('sheet', ''));
if (!fs.existsSync(sheetPath)) U.die('--sheet required');
const sheet = U.readJSON(sheetPath);
const words = U.loadWords(sheet);
const dur = audioDuration(U.fromRoot(sheet.audio));
const tl = compile(sheet, words, dur, U.arg('fade-out', null) != null ? { fade_out_s: parseFloat(U.arg('fade-out')) } : {});
const out = U.arg('out', path.join(path.dirname(sheetPath), `timeline-${sheet.beat}.json`));
U.writeJSON(out, tl);
if (tl.errors.length) { console.error(tl.errors.join('\n')); U.die(`${tl.errors.length} timeline error(s)`); }
U.say(`timeline: ${tl.scenes.length} scenes, ${tl.total_frames} frames (${(tl.total_frames / tl.fps).toFixed(2)} s), ${tl.sfx.length} sound events -> ${out}`);
U.say(` scene length median ${tl.report.median_s.toFixed(1)} s, longest ${tl.report.longest_s.toFixed(1)} s; ${tl.report.sfx_per_min} events/min; interpolated cues: ${tl.report.interpolated_cues.join(', ') || 'none'}`);
}
module.exports = { compile, audioDuration };
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.