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

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

Code87 lines5.5 KBSkill: video-visual-production

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.