← 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)})`);
})();