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(),