Files
rm-circle-team-router/tools/captions.mjs
T
martbost b3385ca3f8 Captions: translated subtitles for the training videos, no re-render, no re-voicing
Manson asked whether the training videos could be in other languages. A real dub
means re-rendering every video per language: 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. That turns 15 files into 75 and makes
every future lesson edit a five-way job. Captions keep ONE video and add small text
tracks beside it, so editing a lesson re-captions that lesson only.

tools/captions.mjs: ffmpeg pulls the audio, ElevenLabs Scribe transcribes 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 is why most auto-captions
read badly at cue boundaries. Translation goes through our own /api/public/translate,
so every phrase caches forever in translations.json and costs once across the site.
The STT response is cached on disk because it costs money; never pay for it twice.

Language choice is evidence, not instinct. The translation cache shows real member
demand: Italian and French far ahead, then Spanish, then GERMAN - which beats
Portuguese by more than double, the opposite of what we assumed. Proof of concept is
Italian on the 5-minute overview.

Captions are deliberately NOT on by default. An English reader does not want them
forced over the picture; someone who already switched the site to Italian almost
certainly does. public/vtt-lang.js shows the track matching their 🌐 choice and
leaves the player's CC button to do the rest.

Two bugs this caught in my own code, both found by reading the output:
- the line wrapper truncated each cue to two lines and SILENTLY DELETED the overflow,
  so "the whole plan fits in one sentence" shipped as "fits in" then "sentence". It
  now chunks by the real wrapped line count and never drops a word.
- a one or two word tail ("sentence." alone on screen) folds back into the previous
  cue.
The suite asserts all 780 transcript words survive into the English track.

Also: .vtt had no Content-Type mapping, so it served as octet-stream and browsers
silently ignore such a track. qa/captions-e2e.mjs (12 assertions) reads the parsed
cues back out of the player rather than trusting the markup, which is the only way to
catch that class of failure. profiles-unit 28 and gate-e2e 33 still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:06:34 -05:00

195 lines
10 KiB
JavaScript

// 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 <video.mp4> [--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 <video.mp4> [--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');