// Build WebVTT captions for a training video, in English and in any of the site's // languages, WITHOUT re-rendering or re-voicing anything. // // Why this shape (Marty + Manson, 2026-09-17): Manson asked for the training videos in // other languages. A real dub means re-rendering every video per language, because // translated Romance-language speech runs 15-25% longer than English and these are slide // videos with fixed beat timings, so swapped audio drifts off what is on screen. Captions // keep ONE video file and add small text tracks beside it, so editing a lesson re-captions // that lesson only. // // node tools/captions.mjs [--langs it,fr,es,de] [--out public/captions] // // Steps: ffmpeg pulls the audio, ElevenLabs Scribe transcribes it with word timings // (the same STT we already use to verify voiceovers), words are grouped into SENTENCES, // and only then translated. Translating cue-by-cue produces nonsense at cue boundaries, // which is the usual reason auto-captions read badly. // // Translation goes through the site's own /api/public/translate, so every phrase is // cached forever in translations.json and costs once across the whole site. import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; const args = process.argv.slice(2); const VIDEO = args.find(a => !a.startsWith('--')); const argOf = (n, d) => { const a = args.find(x => x.startsWith('--' + n + '=')); return a ? a.split('=')[1] : d; }; const LANGS = argOf('langs', 'it').split(',').map(s => s.trim()).filter(Boolean); const OUT = argOf('out', 'public/captions'); const SITE = argOf('site', 'https://rmcircle.team'); const FFDIR = argOf('ffmpeg', 'C:/Users/Marty/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0.1-full_build/bin'); const KEYFILE = argOf('key', 'D:/Projects/MarketingAgent/.elevenlabs-key'); if (!VIDEO) { console.error('usage: node tools/captions.mjs [--langs it,fr] [--out dir]'); process.exit(2); } const base = path.basename(VIDEO).replace(/\.mp4$/i, ''); const work = path.join('D:/tmp/captions', base); fs.mkdirSync(work, { recursive: true }); fs.mkdirSync(OUT, { recursive: true }); // Two lines of about 42 characters is the readable ceiling for a video overlay. const MAX_LINE = 42, MAX_LINES = 2, MIN_CUE = 1.0, MAX_CUE = 6.5; // ---------- 1. transcribe (cached: STT costs money, never pay twice) ---------- const sttPath = path.join(work, 'stt.json'); if (!fs.existsSync(sttPath)) { const mp3 = path.join(work, 'audio.mp3'); if (!fs.existsSync(mp3)) { console.log('extracting audio...'); execFileSync(path.join(FFDIR, 'ffmpeg'), ['-y', '-i', VIDEO, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'libmp3lame', '-b:a', '64k', mp3], { stdio: 'ignore' }); } const key = fs.readFileSync(KEYFILE, 'utf8').trim().replace(/^.*=/, ''); console.log('transcribing with scribe_v1...'); const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('timestamps_granularity', 'word'); fd.append('language_code', 'eng'); fd.append('file', new Blob([fs.readFileSync(mp3)]), 'audio.mp3'); const r = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { method: 'POST', headers: { 'xi-api-key': key }, body: fd }); if (!r.ok) { console.error('STT failed', r.status, (await r.text()).slice(0, 300)); process.exit(1); } fs.writeFileSync(sttPath, JSON.stringify(await r.json())); } const stt = JSON.parse(fs.readFileSync(sttPath, 'utf8')); const words = (stt.words || []).filter(w => w.type === 'word' && String(w.text || '').trim()); console.log('words: ' + words.length + ' | audio ' + Math.round(stt.audio_duration_secs || 0) + 's'); // ---------- 2. words -> sentences (the unit we translate) ---------- const sentences = []; let cur = null; for (const w of words) { if (!cur) cur = { start: w.start, end: w.end, words: [] }; cur.words.push(w); cur.end = w.end; const endsSentence = /[.!?]["')\]]?$/.test(w.text); const tooLong = cur.words.map(x => x.text).join(' ').length > 240; if (endsSentence || tooLong) { sentences.push(cur); cur = null; } } if (cur) sentences.push(cur); for (const s of sentences) s.text = s.words.map(w => w.text).join(' ').replace(/\s+([,.!?;:])/g, '$1').trim(); console.log('sentences: ' + sentences.length); // ---------- 3. sentence -> cues, splitting only where a sentence is too long to show ---------- function wrap(text) { const out = []; let line = ''; for (const w of text.split(/\s+/)) { if (!line) line = w; else if ((line + ' ' + w).length <= MAX_LINE) line += ' ' + w; else { out.push(line); line = w; } } if (line) out.push(line); return out; } // Chunk a sentence's words into display-sized pieces, keeping real timings. function cuesFor(sent) { // Chunk by the ACTUAL wrapped line count, not a character guess. A character cap of // MAX_LINE * MAX_LINES lets text through that then wraps to three lines. const fits = t => wrap(t).length <= MAX_LINES; if (fits(sent.text) && (sent.end - sent.start) <= MAX_CUE) return [{ start: sent.start, end: sent.end, text: sent.text, sent }]; const pieces = []; let chunk = []; for (const w of sent.words) { const next = chunk.concat([w]); const dur = w.end - next[0].start; if (chunk.length && (!fits(next.map(x => x.text).join(' ')) || dur > MAX_CUE)) { pieces.push(chunk); chunk = [w]; } else chunk = next; } if (chunk.length) { // A one or two word tail reads as a glitch on screen ("sentence." alone), so fold it // back into the previous cue whenever the result still fits. const prev = pieces[pieces.length - 1]; if (prev && chunk.length <= 2 && fits(prev.concat(chunk).map(x => x.text).join(' '))) pieces[pieces.length - 1] = prev.concat(chunk); else pieces.push(chunk); } return pieces.map(p => ({ start: p[0].start, end: p[p.length - 1].end, text: p.map(x => x.text).join(' ').replace(/\s+([,.!?;:])/g, '$1'), sent })); } let cues = []; for (const s of sentences) cues = cues.concat(cuesFor(s)); // never flash a cue, and never let one overlap the next for (let i = 0; i < cues.length; i++) { if (cues[i].end - cues[i].start < MIN_CUE) cues[i].end = cues[i].start + MIN_CUE; if (i + 1 < cues.length && cues[i].end > cues[i + 1].start) cues[i].end = Math.max(cues[i].start + 0.4, cues[i + 1].start - 0.04); } console.log('cues: ' + cues.length); const ts = s => { const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + x.toFixed(3).padStart(6, '0'); }; // NEVER slice the wrapped lines here. Truncating to MAX_LINES silently DELETED words: // "the whole plan fits in one sentence" shipped as "fits in" then "sentence". Chunking // below keeps cues to two lines almost always; a rare third line beats a lost word. const toVtt = list => 'WEBVTT\n\n' + list.map((c, i) => (i + 1) + '\n' + ts(c.start) + ' --> ' + ts(c.end) + '\n' + wrap(c.text).join('\n') + '\n').join('\n'); fs.writeFileSync(path.join(OUT, base + '.en.vtt'), toVtt(cues)); console.log('wrote ' + base + '.en.vtt'); // ---------- 4. translate whole sentences, then lay them back over the same timings ---------- async function translate(texts, tl) { const out = []; for (let i = 0; i < texts.length; i += 20) { const batch = texts.slice(i, i + 20).map(t => t.slice(0, 300)); let got = null; for (let attempt = 0; attempt < 3 && !got; attempt++) { try { const r = await fetch(SITE + '/api/public/translate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tl, texts: batch }) }); if (r.status === 429) { console.log(' rate limited, waiting 45s'); await new Promise(z => setTimeout(z, 45000)); continue; } const d = await r.json(); // the endpoint answers { t: [...] }, and on failure it passes the ENGLISH back // rather than erroring, so count identical strings and warn instead of shipping // a "translated" track that is really English. if (Array.isArray(d.t)) got = d.t; else { console.error(' unexpected reply', JSON.stringify(d).slice(0, 200)); } } catch (e) { console.error(' translate error', e.message); await new Promise(z => setTimeout(z, 3000)); } } if (!got) { console.error('translation failed for batch at ' + i); process.exit(1); } out.push(...got); process.stdout.write(' ' + Math.min(i + 20, texts.length) + '/' + texts.length + '\r'); } console.log(''); return out; } // Spread one translated sentence across that sentence's cues, in proportion to how much // of the English sentence each cue carried. Word counts differ between languages, so this // is an approximation, but it keeps text on screen while its own audio is playing. function spread(translated, sentCues) { if (sentCues.length === 1) return [translated]; const words = translated.split(/\s+/); const weights = sentCues.map(c => c.text.length); const total = weights.reduce((a, b) => a + b, 0) || 1; const out = []; let idx = 0; sentCues.forEach((c, i) => { const take = i === sentCues.length - 1 ? words.length - idx : Math.max(1, Math.round(words.length * weights[i] / total)); out.push(words.slice(idx, idx + take).join(' ')); idx += take; }); return out; } for (const tl of LANGS) { console.log('translating to ' + tl + '...'); const src = sentences.map(s => s.text); const tr = await translate(src, tl); const same = tr.filter((t, i) => String(t).trim() === String(src[i]).trim()).length; if (same > src.length * 0.2) { console.error('REFUSING ' + tl + ': ' + same + ' of ' + src.length + ' sentences came back identical to English. ' + 'The endpoint returns the original when the model call fails, so this track would be English wearing a ' + tl + ' label.'); continue; } if (same) console.log(' note: ' + same + ' sentence(s) unchanged (short or brand-only lines)'); const bySent = new Map(); cues.forEach(c => { if (!bySent.has(c.sent)) bySent.set(c.sent, []); bySent.get(c.sent).push(c); }); const translatedCues = []; sentences.forEach((s, i) => { const sc = bySent.get(s) || []; const parts = spread(String(tr[i] || s.text), sc); sc.forEach((c, j) => translatedCues.push({ start: c.start, end: c.end, text: parts[j] || '' })); }); fs.writeFileSync(path.join(OUT, base + '.' + tl + '.vtt'), toVtt(translatedCues.filter(c => c.text.trim()))); console.log('wrote ' + base + '.' + tl + '.vtt'); } console.log('done');