SPB Git

Repository Public

captions.ts 3.7 KB 79 lines typescript

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
Files main
Menu

Theme