Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commands/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
73 changes: 73 additions & 0 deletions src/converters/vtt.test.ts
Original file line number Diff line number Diff line change
@@ -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><c> lying</c><00:00:00.880><c> in</c><00:00:01.040><c> bed</c>

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><c> 2:00</c><00:00:02.960><c> a.m.</c>
`);

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
<v Speaker>Fish &amp; chips &#x1F41F;</v>
`);

const result = await converter.convert(input, { extension: ".vtt" });

expect(result.markdown).toContain("Fish & chips 🐟");
expect(result.markdown).not.toContain("NOTE this should be ignored");
});
});
231 changes: 231 additions & 0 deletions src/converters/vtt.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<ConversionResult> {
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;
}
2 changes: 2 additions & 0 deletions src/markit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,6 +52,7 @@ export class Markit {
new GitHubConverter(),
new WikipediaConverter(),
new RssConverter(),
new VttConverter(),
new CsvConverter(),
new JsonConverter(),
new YamlConverter(),
Expand Down