/** A timed piece of a transcript, in milliseconds from the start of the media. */ export interface SubtitleSegment { startMs: number; endMs: number; text: string; } /** Readable on a phone held upright and the common broadcast limit for one subtitle line. */ export const SUBTITLE_MAX_LINE_CHARS = 42; const SUBTITLE_MAX_LINES = 2; /** SubRip (`.srt`): numbered cues with `HH:MM:SS,mmm` times. */ export function toSrt(segments: readonly SubtitleSegment[]): string { return subtitleCues(segments) .map( (cue, index) => `${index + 1}\n${timestamp(cue.startMs, ",")} --> ${timestamp(cue.endMs, ",")}\n${cue.lines.join("\n")}\n`, ) .join("\n"); } /** WebVTT (`.vtt`): the format browsers play in ``, with cue text escaped. */ export function toVtt(segments: readonly SubtitleSegment[]): string { const cues = subtitleCues(segments).map( (cue) => `${timestamp(cue.startMs, ".")} --> ${timestamp(cue.endMs, ".")}\n${cue.lines.map(escapeVtt).join("\n")}\n`, ); return ["WEBVTT\n", ...cues].join("\n"); } interface SubtitleCue { startMs: number; endMs: number; lines: string[]; } /** * Turns transcript segments into cues of at most two short lines. A long segment becomes * several cues that share its time span in proportion to their length, so subtitles stay * readable instead of covering the frame. */ function subtitleCues(segments: readonly SubtitleSegment[]): SubtitleCue[] { const cues: SubtitleCue[] = []; for (const segment of segments) { // A blank line ends a cue in both formats, and "-->" would read as a timing line. const text = segment.text.replaceAll("-->", "→").replace(/\s+/g, " ").trim(); if (!text) continue; const startMs = Math.max(0, Math.round(segment.startMs)); const endMs = Math.max(startMs, Math.round(segment.endMs)); const lines = wrapLines(text, SUBTITLE_MAX_LINE_CHARS); const groups: string[][] = []; for (let index = 0; index < lines.length; index += SUBTITLE_MAX_LINES) { groups.push(lines.slice(index, index + SUBTITLE_MAX_LINES)); } const total = groups.reduce((sum, group) => sum + group.join(" ").length, 0); let cursor = startMs; let consumed = 0; for (const [index, group] of groups.entries()) { consumed += group.join(" ").length; const groupEnd = index === groups.length - 1 ? endMs : startMs + Math.round(((endMs - startMs) * consumed) / total); cues.push({ startMs: cursor, endMs: groupEnd, lines: group }); cursor = groupEnd; } } return cues; } function wrapLines(text: string, maxChars: number): string[] { const lines: string[] = []; let line = ""; for (const word of text.split(" ")) { if (!line) { line = word; } else if (line.length + 1 + word.length <= maxChars) { line = `${line} ${word}`; } else { lines.push(line); line = word; } } if (line) lines.push(line); return lines; } function timestamp(ms: number, separator: "," | "."): string { const hours = Math.floor(ms / 3_600_000); const minutes = Math.floor((ms % 3_600_000) / 60_000); const seconds = Math.floor((ms % 60_000) / 1000); const millis = ms % 1000; return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${separator}${String(millis).padStart(3, "0")}`; } function escapeVtt(line: string): string { return line.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); }