diff --git a/src/commands/formats.ts b/src/commands/formats.ts index f004148..211c318 100644 --- a/src/commands/formats.ts +++ b/src/commands/formats.ts @@ -19,6 +19,7 @@ const BUILTIN_FORMATS: Format[] = [ { name: "EPUB", extensions: [".epub"], builtin: true }, { name: "Jupyter", extensions: [".ipynb"], builtin: true }, { name: "RSS/Atom", extensions: [".rss", ".atom", ".xml"], builtin: true }, + { name: "WebVTT", extensions: [".vtt"], builtin: true }, { name: "CSV", extensions: [".csv", ".tsv"], builtin: true }, { name: "JSON", extensions: [".json"], builtin: true }, { name: "YAML", extensions: [".yaml", ".yml"], builtin: true }, diff --git a/src/converters/vtt.test.ts b/src/converters/vtt.test.ts new file mode 100644 index 0000000..6d438c8 --- /dev/null +++ b/src/converters/vtt.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { VttConverter } from "./vtt.js"; + +const converter = new VttConverter(); + +describe("VttConverter", () => { + test("accepts .vtt files and WebVTT mimetypes", () => { + expect(converter.accepts({ extension: ".vtt" })).toBe(true); + expect(converter.accepts({ extension: ".VTT" })).toBe(true); + expect(converter.accepts({ mimetype: "text/vtt; charset=utf-8" })).toBe( + true, + ); + expect(converter.accepts({ mimetype: "TEXT/WEBVTT" })).toBe(true); + expect(converter.accepts({ extension: ".txt" })).toBe(false); + }); + + test("converts simple WebVTT cues to markdown", async () => { + const input = Buffer.from(`WEBVTT +Kind: captions +Language: en + +00:00:00.000 --> 00:00:02.000 +Hello world. + +00:00:02.000 --> 00:00:04.000 +This is a caption test. +`); + + const result = await converter.convert(input, { extension: ".vtt" }); + + expect(result.markdown).toContain("# Transcript"); + expect(result.markdown).toContain("Hello world. This is a caption test."); + expect(result.markdown).toContain("- [00:00:00.000] Hello world."); + expect(result.markdown).toContain( + "- [00:00:02.000] This is a caption test.", + ); + }); + + test("deduplicates YouTube rolling captions", async () => { + const input = Buffer.from(`WEBVTT + +00:00:00.400 --> 00:00:02.430 align:start position:0% +You're<00:00:00.560> lying<00:00:00.880> in<00:00:01.040> bed + +00:00:02.430 --> 00:00:02.440 align:start position:0% +You're lying in bed + +00:00:02.440 --> 00:00:05.230 align:start position:0% +You're lying in bed +at<00:00:02.760> 2:00<00:00:02.960> a.m. +`); + + const result = await converter.convert(input, { extension: ".vtt" }); + + expect(result.markdown).toContain("You're lying in bed at 2:00 a.m."); + expect(result.markdown.match(/You're lying in bed/g)?.length).toBe(2); + }); + + test("ignores comments and decodes entities", async () => { + const input = Buffer.from(`WEBVTT + +NOTE this should be ignored + +00:00:00.000 --> 00:00:01.000 +Fish & chips 🐟 +`); + + const result = await converter.convert(input, { extension: ".vtt" }); + + expect(result.markdown).toContain("Fish & chips 🐟"); + expect(result.markdown).not.toContain("NOTE this should be ignored"); + }); +}); diff --git a/src/converters/vtt.ts b/src/converters/vtt.ts new file mode 100644 index 0000000..7f985a4 --- /dev/null +++ b/src/converters/vtt.ts @@ -0,0 +1,231 @@ +import type { ConversionResult, Converter, StreamInfo } from "../types.js"; + +const EXTENSIONS = [".vtt"]; +const MIMETYPES = ["text/vtt", "text/webvtt"]; +const SKIPPED_BLOCK_PREFIXES = ["WEBVTT", "NOTE", "STYLE", "REGION"]; +const TIMESTAMP_TAG = /<\d{2}:\d{2}(?::\d{2})?\.\d{3}>/g; + +const HTML_ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + "#39": "'", + "#x27": "'", +}; + +interface Cue { + start: string; + text: string; +} + +export class VttConverter implements Converter { + name = "vtt"; + + accepts(streamInfo: StreamInfo): boolean { + const extension = streamInfo.extension?.toLowerCase(); + const mimetype = streamInfo.mimetype?.toLowerCase(); + + if (extension && EXTENSIONS.includes(extension)) { + return true; + } + if (mimetype && MIMETYPES.some((m) => mimetype.startsWith(m))) { + return true; + } + return false; + } + + async convert( + input: Buffer, + streamInfo: StreamInfo, + ): Promise { + const text = new TextDecoder(streamInfo.charset || "utf-8").decode(input); + const cues = parseVtt(text); + + if (cues.length === 0) { + return { markdown: "" }; + } + + const transcript = buildIncrementalTranscript(cues); + const lines = ["# Transcript", ""]; + + if (transcript.cleanText) { + lines.push("## Text", "", ...paragraphize(transcript.cleanText), ""); + } + + lines.push("## Timestamped Transcript", ""); + for (const cue of transcript.cues) { + lines.push(`- [${cue.start}] ${cue.text}`); + } + + return { markdown: lines.join("\n") }; + } +} + +function parseVtt(input: string): Cue[] { + const normalized = input.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n"); + const blocks = normalized.split(/\n{2,}/); + const cues: Cue[] = []; + + for (const block of blocks) { + const lines = block + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length === 0) continue; + if (SKIPPED_BLOCK_PREFIXES.some((prefix) => lines[0].startsWith(prefix))) { + continue; + } + + const timingIndex = lines.findIndex((line) => line.includes("-->")); + if (timingIndex === -1) continue; + + const start = lines[timingIndex].split("-->")[0]?.trim(); + const cueText = cleanCueText(lines.slice(timingIndex + 1).join(" ")); + + if (start && cueText) { + cues.push({ start, text: cueText }); + } + } + + return cues; +} + +function cleanCueText(input: string): string { + return input + .replace(TIMESTAMP_TAG, "") + .replace(/<[^>]+>/g, "") + .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (entity, name: string) => + decodeHtmlEntity(entity, name), + ) + .replace(/\s+/g, " ") + .trim(); +} + +function decodeHtmlEntity(entity: string, name: string): string { + const normalized = name.toLowerCase(); + const named = HTML_ENTITIES[normalized]; + if (named) return named; + + if (normalized.startsWith("#x")) { + return decodeCodePoint(entity, Number.parseInt(normalized.slice(2), 16)); + } + if (normalized.startsWith("#")) { + return decodeCodePoint(entity, Number.parseInt(normalized.slice(1), 10)); + } + + return entity; +} + +function decodeCodePoint(fallback: string, codePoint: number): string { + if (!Number.isFinite(codePoint)) return fallback; + try { + return String.fromCodePoint(codePoint); + } catch { + return fallback; + } +} + +function buildIncrementalTranscript(cues: Cue[]): { + cleanText: string; + cues: Cue[]; +} { + const words: string[] = []; + const incrementalCues: Cue[] = []; + let previousCueWords: string[] = []; + + for (const cue of cues) { + const currentWords = cue.text.split(/\s+/).filter(Boolean); + if (currentWords.length === 0) continue; + + if ( + currentWords.length <= previousCueWords.length && + (startsWithWords(previousCueWords, currentWords) || + endsWithWords(previousCueWords, currentWords)) + ) { + previousCueWords = currentWords; + continue; + } + + let addedWords = currentWords; + + if ( + previousCueWords.length > 0 && + currentWords.length >= previousCueWords.length && + startsWithWords(currentWords, previousCueWords) + ) { + addedWords = currentWords.slice(previousCueWords.length); + } else { + const previousOverlap = suffixPrefixOverlap( + previousCueWords, + currentWords, + 30, + ); + if (previousOverlap > 0) { + addedWords = currentWords.slice(previousOverlap); + } else { + const transcriptOverlap = suffixPrefixOverlap(words, currentWords, 60); + if (transcriptOverlap > 0) { + addedWords = currentWords.slice(transcriptOverlap); + } + } + } + + if (addedWords.length > 0) { + const text = addedWords.join(" "); + incrementalCues.push({ start: cue.start, text }); + words.push(...addedWords); + } + + previousCueWords = currentWords; + } + + return { + cleanText: words.join(" "), + cues: incrementalCues, + }; +} + +function startsWithWords(words: string[], prefix: string[]): boolean { + return prefix.every((word, index) => words[index] === word); +} + +function endsWithWords(words: string[], suffix: string[]): boolean { + return suffix.every( + (word, index) => words[words.length - suffix.length + index] === word, + ); +} + +function suffixPrefixOverlap( + left: string[], + right: string[], + maxLength: number, +): number { + let best = 0; + const max = Math.min(left.length, right.length, maxLength); + for (let length = 1; length <= max; length += 1) { + if (endsWithWords(left, right.slice(0, length))) { + best = length; + } + } + return best; +} + +function paragraphize(text: string): string[] { + const sentences = text.split(/(?<=[.!?])\s+/); + const paragraphs: string[] = []; + let current: string[] = []; + + for (const sentence of sentences) { + if (sentence) current.push(sentence); + if (current.join(" ").length >= 700) { + paragraphs.push(current.join(" ")); + current = []; + } + } + + if (current.length > 0) paragraphs.push(current.join(" ")); + return paragraphs; +} diff --git a/src/markit.ts b/src/markit.ts index c84c7aa..94c607a 100644 --- a/src/markit.ts +++ b/src/markit.ts @@ -14,6 +14,7 @@ import { PdfConverter } from "./converters/pdf/index.js"; import { PlainTextConverter } from "./converters/plain-text.js"; import { PptxConverter } from "./converters/pptx.js"; import { RssConverter } from "./converters/rss.js"; +import { VttConverter } from "./converters/vtt.js"; import { WikipediaConverter } from "./converters/wikipedia.js"; import { XlsxConverter } from "./converters/xlsx.js"; import { XmlConverter } from "./converters/xml.js"; @@ -51,6 +52,7 @@ export class Markit { new GitHubConverter(), new WikipediaConverter(), new RssConverter(), + new VttConverter(), new CsvConverter(), new JsonConverter(), new YamlConverter(),