← back to Crcp Pitch Video

build-walkthrough-data.js

44 lines

/* Generate per-step cloned-voice VO, measure durations, copy shots to public/, write src/walkthrough-data.json */
const fs = require('fs'), path = require('path'), { execSync } = require('child_process');
const https = require('https');

const M = JSON.parse(fs.readFileSync(path.join(__dirname, 'walkthrough/manifest.json'), 'utf8'));
const VOICE = 'Xa9qV4wNbvSkdUWsYLzq';
let KEY = '';
for (const p of [process.env.HOME + '/Projects/secrets-manager/.env', process.env.HOME + '/.claude/skills/clone-voice/.env']) {
  try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) if (l.includes('ELEVENLABS_API_KEY')) { KEY = l.split('=')[1].trim().replace(/"/g, ''); break; } } catch {}
  if (KEY) break;
}
const AUD = path.join(__dirname, 'walkthrough/audio'); fs.mkdirSync(AUD, { recursive: true });
const PUB = path.join(__dirname, 'public/walkthrough'); fs.mkdirSync(PUB, { recursive: true });

function tts(text, out) {
  return new Promise((res, rej) => {
    const body = JSON.stringify({ text, model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.15 } });
    const req = https.request(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}`, { method: 'POST', headers: { 'xi-api-key': KEY, 'Content-Type': 'application/json', Accept: 'audio/mpeg' } }, r => {
      const c = []; r.on('data', d => c.push(d)); r.on('end', () => { fs.writeFileSync(out, Buffer.concat(c)); res(); });
    });
    req.on('error', rej); req.write(body); req.end();
  });
}
const dur = f => parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${f}"`).toString().trim());

(async () => {
  const FPS = 30; const steps = []; let chars = 0;
  for (const m of M) {
    const mp3 = path.join(AUD, `step${String(m.i).padStart(2, '0')}.mp3`);
    chars += m.vo.length;
    await tts(m.vo, mp3);
    const d = dur(mp3);
    fs.copyFileSync(path.join(__dirname, 'walkthrough', m.shot), path.join(PUB, m.shot));
    const durFrames = Math.ceil((d + 1.35) * FPS);
    steps.push({ shot: `walkthrough/${m.shot}`, box: m.box, label: m.label, cap: m.cap, durFrames, audioStartSec: null });
    console.log(`  step ${m.i}: ${d.toFixed(1)}s -> ${durFrames}f  ${m.label}`);
  }
  // compute audio start offsets (cumulative)
  let acc = 0;
  for (const s of steps) { s.audioStartSec = acc / FPS; acc += s.durFrames; }
  fs.writeFileSync(path.join(__dirname, 'src/walkthrough-data.json'), JSON.stringify({ fps: FPS, total: acc, steps }, null, 1));
  console.log(`✔ ${steps.length} steps, ${(acc / FPS).toFixed(0)}s total, ${chars} chars (~$${(chars / 1000 * 0.30).toFixed(2)})`);
})();