Repository Public
1import type { CharTiming } from "../providers/types";23export interface Cue { start: number; end: number; text: string }45/** Group per-character timings into words. */6export function wordsFromChars(chars: CharTiming[]): Cue[] {7 const words: Cue[] = [];8 let cur: Cue | null = null;9 for (const c of chars) {10 if (/\s/.test(c.char)) { if (cur) { words.push(cur); cur = null; } continue; }11 if (!cur) cur = { start: c.start, end: c.end, text: c.char };12 else { cur.text += c.char; cur.end = Math.max(cur.end, c.end); }13 }14 if (cur) words.push(cur);15 return words;16}1718/** Build readable caption cues from word timings: short lines, breaks at punctuation, no overlaps. */19export function cuesFromWords(words: Cue[], offset = 0, maxChars = 30, maxDur = 3.2): Cue[] {20 const cues: Cue[] = [];21 let cur: Cue | null = null;22 for (const w of words) {23 const wouldBe = cur ? `${cur.text} ${w.text}` : w.text;24 if (cur && (wouldBe.length > maxChars || w.end - cur.start > maxDur)) { cues.push(cur); cur = null; }25 cur = cur ? { start: cur.start, end: w.end, text: `${cur.text} ${w.text}` } : { ...w };26 if (/[.!?…:;]["»”)]?$/.test(w.text) && cur.text.length >= 12) { cues.push(cur); cur = null; }27 }28 if (cur) cues.push(cur);29 return tidy(cues.map((c) => ({ start: c.start + offset, end: c.end + offset, text: c.text })));30}3132/** Translation captions: no word timings exist, so spread chunks over the spoken interval by length. */33export function cuesFromText(text: string, start: number, end: number, maxChars = 38): Cue[] {34 const tokens = text.trim().split(/\s+/).filter(Boolean);35 if (!tokens.length || end <= start) return [];36 const chunks: string[] = [];37 let cur = "";38 for (const t of tokens) {39 if (cur && `${cur} ${t}`.length > maxChars) { chunks.push(cur); cur = t; } else cur = cur ? `${cur} ${t}` : t;40 }41 if (cur) chunks.push(cur);42 const total = chunks.reduce((n, c) => n + c.length, 0);43 let t = start;44 const cues = tidy(chunks.map((c) => { const d = ((end - start) * c.length) / total; const cue = { start: t, end: t + d, text: c }; t += d; return cue; }));45 // The readable-minimum rule must not push a caption past the end of the spoken interval.46 return cues.map((c) => ({ ...c, end: Math.max(c.start + 0.1, Math.min(c.end, end)) }));47}4849function tidy(cues: Cue[]): Cue[] {50 const out = cues.filter((c) => c.text.trim()).sort((a, b) => a.start - b.start).map((c) => ({ ...c }));51 for (let i = 0; i < out.length; i++) {52 const next = out[i + 1];53 if (out[i].end - out[i].start < 0.7) out[i].end = out[i].start + 0.7; // readable minimum54 if (next && out[i].end > next.start) out[i].end = Math.max(out[i].start + 0.1, next.start - 0.02);55 }56 return out;57}5859const ts = (s: number) => {60 const ms = Math.max(0, Math.round(s * 1000));61 const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000), sec = Math.floor((ms % 60000) / 1000);62 return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${String(ms % 1000).padStart(3, "0")}`;63};64export function toVtt(cues: Cue[]): string {65 return `WEBVTT\n\n${cues.map((c, i) => `${i + 1}\n${ts(c.start)} --> ${ts(c.end)}\n${c.text.replace(/-->/g, "→")}`).join("\n\n")}\n`;66}67export function parseVtt(vtt: string): Cue[] {68 const cues: Cue[] = [];69 const sec = (t: string) => { const [h, m, s] = t.split(":"); return Number(h) * 3600 + Number(m) * 60 + Number(s); };70 for (const block of vtt.split(/\n\n+/)) {71 const lines = block.split("\n");72 const i = lines.findIndex((l) => l.includes("-->"));73 if (i < 0) continue;74 const [a, b] = lines[i].split("-->").map((x) => x.trim().split(" ")[0]);75 cues.push({ start: sec(a), end: sec(b), text: lines.slice(i + 1).join("\n").trim() });76 }77 return cues;78}79