Public: anyone can read this; the crew can change it.

A subtitle file (.srt, .vtt) renders as a timed transcript #10334

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10287-a-subtitle-file-srt-vtt-renders-as-a-tim → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/10287-a-subtitle-file-srt-vtt-renders-as-a-tim was merged into main as 3b155ae.

Review

Sign in to review this change.
9 files changed +279 −4
docs/design/areas/subtitles.md added +3
@@ -0,0 +1,3 @@
1 # Subtitles
2
3 No new component class. The transcript is the existing bare `.table` with `data-dense`, unboxed (no `card`); its notes are `muted small` paragraphs under the table, so a failure never moves the transcript.
docs/features/subtitles.md added +9
@@ -0,0 +1,9 @@
1 # Subtitles as a transcript
2
3 `.srt` (SubRip) and `.vtt` (WebVTT) files open as a transcript: one row per cue, its start time and its text.
4 One parser, `parseSubtitles` in `src/web/render/subtitles.ts`, serves both doors.
5
6 - **Web:** the blob page draws a bare two-column table (`Start`, `Text`) on the server; up to 5,000 cues. Tags (`<i>`, `<v Name>`, `{\an8}`) are stripped and the text escaped. A cue that does not parse is counted in one line *below* the table, naming the first one and what to do ("check the arrow line"); the rest still draws. `?view=source`, Raw and Download work as for any file.
7 - **Terminal:** the workspace file view shows the same cues as an aligned table, like `.csv`; `s` flips between the table and the source. The status line reads `N cues` and, when some failed, how many were unreadable.
8 - A file with no cues shows its source.
9 - SRT and VTT timing syntax is accepted in either file (comma or dot, hours optional).
src/tui/screens/workspace.ts +36 −4
@@ -51,6 +51,7 @@ import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
5151 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5252 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5353 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
54 import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts";
5455 import { layoutTable, type Grid } from "../ui/grid.ts";
5556 import type { Ctx, Screen } from "../app/app.ts";
5657 import { binding, type Binding } from "../app/keys.ts";
@@ -282,6 +283,27 @@ function isTablePath(path: string): boolean {
282283 return /\.[ct]sv$/i.test(path);
283284 }
284285
286 /** `.srt` and `.vtt`: a transcript, drawn as the same two-column table. */
287 function isSubtitlePath(path: string): boolean {
288 return /\.(srt|vtt)$/i.test(path);
289 }
290
291 /** The cues of a subtitle file as a table: start time, text. Shares the web's parser. */
292 function subtitleTable(source: string): { table: Table; unread: number } {
293 const { cues, failures } = parseSubtitles(source);
294 const shown = cues.slice(0, CUE_CAP);
295 return {
296 unread: failures.length,
297 table: {
298 header: ["Start", "Text"],
299 rows: shown.map((c) => [c.start, c.text]),
300 more: cues.length - shown.length,
301 delimiter: ",",
302 columns: 2,
303 },
304 };
305 }
306
285307 /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */
286308 function isMarkdownPath(path: string): boolean {
287309 const name = path.slice(path.lastIndexOf("/") + 1);
@@ -333,6 +355,8 @@ class Tab {
333355 /** The parsed file, when its name says table. Parse errors ride along. */
334356 table: Table | null = null;
335357 grid: Grid = { header: "", rows: [] };
358 /** Cues of a subtitle file that could not be read. */
359 unread = 0;
336360
337361 constructor(readonly path: string) {}
338362
@@ -411,7 +435,13 @@ class Tab {
411435 const built = buildLines(new TextDecoder().decode(source), this.path, c);
412436 this.plain = built.plain;
413437 this.lines = built.lines;
414 if (isTablePath(this.path)) {
438 this.unread = 0;
439 if (isSubtitlePath(this.path)) {
440 const subs = subtitleTable(new TextDecoder().decode(source));
441 this.table = subs.table;
442 this.unread = subs.unread;
443 this.grid = layoutTable(this.table);
444 } else if (isTablePath(this.path)) {
415445 // The decoded source, not `plain`: `expandTabs` has eaten every tab there.
416446 const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined;
417447 this.table = parseCsv(new TextDecoder().decode(source), { delimiter });
@@ -2463,7 +2493,7 @@ export function workspaceScreen(
24632493 ? ""
24642494 : `${t.path} · ${
24652495 t.tabular && t.table !== null
2466 ? tableStatus(t.table)
2496 ? tableStatus(t.table, t.unread, isSubtitlePath(t.path))
24672497 : `${plural(
24682498 t.reading ? t.doc.length : t.plain.length,
24692499 "line",
@@ -2797,9 +2827,11 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void {
27972827 }
27982828
27992829 /** `80 rows, first 50 shown · 3 more columns`: what the table leaves out. */
2800 function tableStatus(table: Table): string {
2830 function tableStatus(table: Table, unread = 0, subs = false): string {
28012831 const rows = table.rows.length + table.more;
2802 return `${plural(rows, "row")}${
2832 return `${plural(rows, subs ? "cue" : "row")}${
2833 unread > 0 ? ` · ${unread} unreadable, check their arrow lines` : ""
2834 }${
28032835 table.more > 0 ? `, first ${table.rows.length} shown` : ""
28042836 }${
28052837 table.columns > MAX_COLUMNS
src/web/render/kinds.ts +4
@@ -23,6 +23,7 @@ export type FileKind =
2323 | "archive"
2424 | "config"
2525 | "markdown"
26 | "subtitles"
2627 | "text"
2728 | "binary";
2829
@@ -44,6 +45,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
4445 archive: 50 * MB,
4546 config: 1 * MB,
4647 markdown: 1 * MB,
48 subtitles: 5 * MB,
4749 text: 1 * MB,
4850 binary: 0,
4951 };
@@ -134,6 +136,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
134136 md: "markdown",
135137 markdown: "markdown",
136138 mdx: "markdown",
139 srt: "subtitles",
140 vtt: "subtitles",
137141 };
138142
139143 /**
src/web/render/renderers.ts +2
@@ -15,6 +15,7 @@ import { image } from "./image.ts";
1515 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1616 import { markdown } from "./markdown.ts";
1717 import { pdf } from "./pdf.ts";
18 import { subtitles } from "./subtitles.ts";
1819 import { video } from "./video.ts";
1920
2021 /** What a renderer is handed: the file, and where its bytes can be fetched. */
@@ -55,6 +56,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
5556 audio,
5657 video,
5758 config,
59 subtitles,
5860 archive,
5961 };
6062
src/web/render/subtitles.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * A subtitle file (SubRip `.srt`, WebVTT `.vtt`) as a transcript: one row per
3 * cue, its start time and its text. Tags inside a cue (`<i>`, `<v Name>`,
4 * `<00:01.000>`, `{\an8}`) are stripped, then the text is escaped. A cue that
5 * does not parse is named in one line and the rest still draws.
6 *
7 * Formats: SubRip, "HH:MM:SS,mmm --> HH:MM:SS,mmm"; WebVTT, W3C
8 * "WebVTT: The Web Video Text Tracks Format" §4, where hours are optional and
9 * the separator is a dot. Both are accepted in either file.
10 */
11
12 import { escapeHtml, html, raw } from "../html.ts";
13 import { fileSize } from "../ui.ts";
14 import type { Rendered, RenderInput } from "./renderers.ts";
15
16 /** Cues drawn, whatever the file holds. */
17 export const CUE_CAP = 5_000;
18 export interface Cue {
19 readonly start: string;
20 readonly text: string;
21 }
22 export interface Failure {
23 /** The cue's number or id when it has one, else its position. */
24 readonly name: string;
25 /** 1-based line in the file where the cue starts. */
26 readonly line: number;
27 readonly why: string;
28 }
29 export interface Transcript {
30 readonly cues: Cue[];
31 readonly failures: Failure[];
32 }
33
34 const TIME = String.raw`(?:\d{1,}:)?\d{1,2}:\d{2}[.,]\d{1,3}`;
35 const TIMING = new RegExp(String.raw`^\s*(${TIME})\s*-->\s*(${TIME})(?:\s.*)?$`);
36
37 /** Tags and SubRip/ASS style overrides removed from cue text. */
38 export const stripTags = (text: string): string =>
39 text.replace(/<\/?[A-Za-z0-9][^>\n]*>/g, "").replace(/\{\\[^}\n]*\}/g, "");
40
41 /** Parse a subtitle file's text. Pure; never throws. */
42 export function parseSubtitles(source: string): Transcript {
43 const lines = source.replace(/^/, "").replace(/\r\n?/g, "\n").split("\n");
44 const cues: Cue[] = [];
45 const failures: Failure[] = [];
46 let i = 0;
47 let position = 0;
48 // A WebVTT file opens on its signature line, which is not a cue.
49 const vtt = /^WEBVTT(?:[ \t]|$)/.test(lines[0] ?? "");
50 while (i < lines.length) {
51 if ((lines[i] ?? "").trim() === "") {
52 i++;
53 continue;
54 }
55 const first = i;
56 const block: string[] = [];
57 while (i < lines.length && (lines[i] ?? "").trim() !== "") block.push(lines[i++]!);
58 if (vtt && first === 0) continue;
59 if (vtt && /^(NOTE|STYLE|REGION)(?:[ \t]|$)/.test(block[0]!)) continue;
60 position++;
61 const at = block.findIndex((l) => l.includes("-->"));
62 const name = at > 0 ? block[0]!.trim().slice(0, 40) : String(position);
63 if (at === -1) {
64 failures.push({ name, line: first + 1, why: "no timing line, add an arrow line (00:00:01,000 --> 00:00:02,000)" });
65 continue;
66 }
67 const m = TIMING.exec(block[at]!);
68 if (m === null) {
69 failures.push({ name, line: first + at + 1, why: "unreadable timing, check the arrow line (00:00:01,000 --> 00:00:02,000)" });
70 continue;
71 }
72 cues.push({
73 start: m[1]!.replace(",", "."),
74 text: stripTags(block.slice(at + 1).join("\n")).trim(),
75 });
76 }
77 return { cues, failures };
78 }
79
80 export function subtitles(input: RenderInput): Rendered {
81 const { cues, failures } = parseSubtitles(new TextDecoder().decode(input.body));
82 if (cues.length === 0 && failures.length === 0)
83 return { content: html``, lines: 0, fallback: "This file holds no cues." };
84 const shown = cues.slice(0, CUE_CAP);
outdated

num and subs-failed have no CSS outside .csv-view; use existing classes or define them in docs/design/areas/.

85 let rows = "";
86 for (const c of shown)
87 rows += `<tr><td><time>${escapeHtml(c.start)}</time></td><td>${escapeHtml(c.text).replace(/\n/g, "<br>")}</td></tr>`;
88 const notes: string[] = [];
89 if (failures.length > 0) {
90 const f = failures[0]!;
91 notes.push(
92 `${failures.length} ${failures.length === 1 ? "cue" : "cues"} could not be read, first is cue ${escapeHtml(f.name)} (line ${f.line}): ${escapeHtml(f.why)}.`,
93 );
outdated

Principles 6 and 7: a card around a table is a box around a thing. Render the table bare.

94 }
outdated

Failure notes above the table push the transcript down; move them below the table.

95 if (cues.length > CUE_CAP)
96 notes.push(
97 `Showing ${CUE_CAP.toLocaleString()} of ${cues.length.toLocaleString()} cues. The rest is in the raw file.`,
98 );
99 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
100 const content = html`<table class="table subs-view" data-dense aria-label="${name} transcript">
101 <thead><tr><th scope="col">Start</th><th scope="col">Text</th></tr></thead>
102 <tbody>${raw(rows)}</tbody>
103 </table>${raw(notes.map((n) => `<p class="muted small">${n}</p>`).join(""))}`;
104 return {
105 content,
106 lines: 0,
107 summary: `${cues.length.toLocaleString()} cues · ${fileSize(input.size)}`,
108 };
109 }
test/render-kinds.test.ts +2
@@ -53,6 +53,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
5353 md: [enc("# a\n"), "markdown", "application/octet-stream"],
5454 markdown: [enc("# a\n"), "markdown", "application/octet-stream"],
5555 mdx: [enc("# a\n"), "markdown", "application/octet-stream"],
56 srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"],
57 vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"],
5658 };
5759
5860 describe("the registry of file kinds", () => {
test/render-subtitles.test.ts added +81
@@ -0,0 +1,81 @@
1 /** Subtitle files as a transcript: SRT, VTT, hostile text, a bad cue. */
2 import { describe, expect, test } from "bun:test";
3 import { parseSubtitles, subtitles } from "../src/web/render/subtitles.ts";
4 import { fileKind } from "../src/web/render/kinds.ts";
5 import { RENDERERS } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (text: string, path = "a.srt") =>
9 subtitles({ path, size: text.length, body: enc(text), media: "/m" });
10 const html = (text: string, path?: string): string => String(draw(text, path).content);
11
12 const SRT = "1\r\n00:00:01,500 --> 00:00:03,000\r\nHello <i>there</i>\r\n\r\n2\r\n01:02:03,004 --> 01:02:05,000\r\n{\\an8}Line one\r\nLine two\r\n";
13 const VTT = `WEBVTT - demo
14
15 NOTE a comment
16
17 00:01.250 --> 00:03.000 align:start
18 <v Ann>Hi <00:02.000>again</v>
19
20 intro
21 00:01:00.000 --> 00:01:02.000
22 Bye
23 `;
24
25 describe("subtitle files", () => {
26 test("the registry names .srt and .vtt and a renderer draws them", () => {
27 expect(fileKind("x/a.srt", enc(SRT))).toBe("subtitles");
28 expect(fileKind("x/a.VTT", enc(VTT))).toBe("subtitles");
29 expect(RENDERERS.subtitles).toBe(subtitles);
30 });
31
32 test("an SRT: start times and text, tags stripped", () => {
33 const t = parseSubtitles(SRT);
34 expect(t.failures).toEqual([]);
35 expect(t.cues).toEqual([
36 { start: "00:00:01.500", text: "Hello there" },
37 { start: "01:02:03.004", text: "Line one\nLine two" },
38 ]);
39 const out = html(SRT);
40 expect(out).toContain("<time>00:00:01.500</time>");
41 expect(out).toContain("Line one<br>Line two");
42 expect(out).not.toContain("<i>");
43 expect(draw(SRT).summary).toContain("2 cues");
44 });
45
46 test("a VTT: header, NOTE and ids skipped, hour optional, voice tags stripped", () => {
47 const t = parseSubtitles(VTT);
48 expect(t.failures).toEqual([]);
49 expect(t.cues).toEqual([
50 { start: "00:01.250", text: "Hi again" },
51 { start: "00:01:00.000", text: "Bye" },
52 ]);
53 });
54
55 test("a cue containing <script> is escaped, never emitted", () => {
56 const out = html("1\n00:00:01,000 --> 00:00:02,000\n&<script>alert(1)</script>x &lt;b&gt;\n");
57 expect(out).not.toContain("<script");
58 const lone = html("1\n00:00:01,000 --> 00:00:02,000\n1 < 2 && 3 > 2\n");
59 expect(lone).toContain("1 &lt; 2 &amp;&amp; 3 &gt; 2");
60 expect(out).toContain("&amp;");
61 expect(out).toContain("&amp;lt;b&amp;gt;");
62 });
63
64 test("a malformed cue is named and the rest still renders", () => {
65 const src = "1\n00:00:01,000 --> 00:00:02,000\nOne\n\n2\n00:00:0x --> nope\nBroken\n\n3\njust words\n\n4\n00:00:09,000 --> 00:00:10,000\nFour\n";
66 const t = parseSubtitles(src);
67 expect(t.cues.map((c) => c.text)).toEqual(["One", "Four"]);
68 expect(t.failures.map((f) => [f.name, f.line])).toEqual([["2", 6], ["3", 9]]);
69 const out = html(src);
70 expect(out).toContain("2 cues could not be read, first is cue 2 (line 6)");
71 expect(out).toContain("check the arrow line");
72 // One summary line, below the table, in a bare table (no card).
73 expect(out.indexOf("could not be read")).toBeGreaterThan(out.indexOf("</table>"));
74 expect(out).not.toContain("card");
75 expect(out).toContain("Four");
76 });
77
78 test("a file with no cues falls back to source", () => {
79 expect(draw("").fallback).toBeDefined();
80 });
81 });
test/tui-workspace.test.ts +33
@@ -309,6 +309,39 @@ describe("the terminal workspace", () => {
309309 }
310310 });
311311
312 test("a .srt opens as a Start/Text table sharing the web parser, and s flips to the source", async () => {
313 const made = await createRepo(forge, alice, {
314 name: "captions",
315 visibility: "public",
316 description: "subtitles",
317 });
318 if (!made.ok) throw new Error("repo failed");
319 await commitToBranch(forge, alice, made.value, {
320 message: "seed",
321 files: [
322 {
323 path: "talk.srt",
324 content:
325 "1\n00:00:01,500 --> 00:00:03,000\nHello <i>there</i>\n\n2\n00:00:0x --> nope\nBroken\n",
326 },
327 ],
328 });
329 const h = boot();
330 try {
331 await h.settle();
332 await open(h, "captions");
333 await openFile(h, "talk.srt");
334 expect(h.text()).toContain("Start");
335 expect(h.text()).toContain("00:00:01.500 │ Hello there");
336 expect(h.text()).toContain("1 cue");
337 expect(h.text()).toContain("1 unreadable, check their arrow lines");
338 await h.press("s");
339 expect(h.text()).toContain("00:00:01,500 --> 00:00:03,000");
340 } finally {
341 h.stop();
342 }
343 });
344
312345 test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => {
313346 const made = await createRepo(forge, alice, {
314347 name: "sheets",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs