Public: anyone can read this; the crew can change it.
A subtitle file (.srt, .vtt) renders as a timed transcript #10334
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10287-a-subtitle-file-srt-vtt-renders-as-a-tim → main 4 commits fork 3 threads · 3 unresolved
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"; | ||
| 51 | 51 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 52 | 52 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 53 | 53 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| 54 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; | |
| 54 | 55 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| 55 | 56 | import type { Ctx, Screen } from "../app/app.ts"; |
| 56 | 57 | import { binding, type Binding } from "../app/keys.ts"; |
| @@ -282,6 +283,27 @@ function isTablePath(path: string): boolean { | ||
| 282 | 283 | return /\.[ct]sv$/i.test(path); |
| 283 | 284 | } |
| 284 | 285 | |
| 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 | ||
| 285 | 307 | /** `README.md`, `NOTES.markdown`, `readme` — the prose a repo opens on. */ |
| 286 | 308 | function isMarkdownPath(path: string): boolean { |
| 287 | 309 | const name = path.slice(path.lastIndexOf("/") + 1); |
| @@ -333,6 +355,8 @@ class Tab { | ||
| 333 | 355 | /** The parsed file, when its name says table. Parse errors ride along. */ |
| 334 | 356 | table: Table | null = null; |
| 335 | 357 | grid: Grid = { header: "", rows: [] }; |
| 358 | /** Cues of a subtitle file that could not be read. */ | |
| 359 | unread = 0; | |
| 336 | 360 | |
| 337 | 361 | constructor(readonly path: string) {} |
| 338 | 362 | |
| @@ -411,7 +435,13 @@ class Tab { | ||
| 411 | 435 | const built = buildLines(new TextDecoder().decode(source), this.path, c); |
| 412 | 436 | this.plain = built.plain; |
| 413 | 437 | 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)) { | |
| 415 | 445 | // The decoded source, not `plain`: `expandTabs` has eaten every tab there. |
| 416 | 446 | const delimiter = /\.tsv$/i.test(this.path) ? "\t" : undefined; |
| 417 | 447 | this.table = parseCsv(new TextDecoder().decode(source), { delimiter }); |
| @@ -2463,7 +2493,7 @@ export function workspaceScreen( | ||
| 2463 | 2493 | ? "" |
| 2464 | 2494 | : `${t.path} · ${ |
| 2465 | 2495 | t.tabular && t.table !== null |
| 2466 | ? tableStatus(t.table) | |
| 2496 | ? tableStatus(t.table, t.unread, isSubtitlePath(t.path)) | |
| 2467 | 2497 | : `${plural( |
| 2468 | 2498 | t.reading ? t.doc.length : t.plain.length, |
| 2469 | 2499 | "line", |
| @@ -2797,9 +2827,11 @@ function drawCode(s: Surface, r: Rect, c: Tokens, t: Tab): void { | ||
| 2797 | 2827 | } |
| 2798 | 2828 | |
| 2799 | 2829 | /** `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 { | |
| 2801 | 2831 | 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 | }${ | |
| 2803 | 2835 | table.more > 0 ? `, first ${table.rows.length} shown` : "" |
| 2804 | 2836 | }${ |
| 2805 | 2837 | table.columns > MAX_COLUMNS |
src/web/render/kinds.ts +4
| @@ -23,6 +23,7 @@ export type FileKind = | ||
| 23 | 23 | | "archive" |
| 24 | 24 | | "config" |
| 25 | 25 | | "markdown" |
| 26 | | "subtitles" | |
| 26 | 27 | | "text" |
| 27 | 28 | | "binary"; |
| 28 | 29 | |
| @@ -44,6 +45,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 44 | 45 | archive: 50 * MB, |
| 45 | 46 | config: 1 * MB, |
| 46 | 47 | markdown: 1 * MB, |
| 48 | subtitles: 5 * MB, | |
| 47 | 49 | text: 1 * MB, |
| 48 | 50 | binary: 0, |
| 49 | 51 | }; |
| @@ -134,6 +136,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 134 | 136 | md: "markdown", |
| 135 | 137 | markdown: "markdown", |
| 136 | 138 | mdx: "markdown", |
| 139 | srt: "subtitles", | |
| 140 | vtt: "subtitles", | |
| 137 | 141 | }; |
| 138 | 142 | |
| 139 | 143 | /** |
src/web/render/renderers.ts +2
| @@ -15,6 +15,7 @@ import { image } from "./image.ts"; | ||
| 15 | 15 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 16 | 16 | import { markdown } from "./markdown.ts"; |
| 17 | 17 | import { pdf } from "./pdf.ts"; |
| 18 | import { subtitles } from "./subtitles.ts"; | |
| 18 | 19 | import { video } from "./video.ts"; |
| 19 | 20 | |
| 20 | 21 | /** 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>>> = { | ||
| 55 | 56 | audio, |
| 56 | 57 | video, |
| 57 | 58 | config, |
| 59 | subtitles, | |
| 58 | 60 | archive, |
| 59 | 61 | }; |
| 60 | 62 |
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); | |
| 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]> = { | ||
| 53 | 53 | md: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 54 | 54 | markdown: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 55 | 55 | 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"], | |
| 56 | 58 | }; |
| 57 | 59 | |
| 58 | 60 | 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 <b>\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 < 2 && 3 > 2"); | |
| 60 | expect(out).toContain("&"); | |
| 61 | expect(out).toContain("&lt;b&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", () => { | ||
| 309 | 309 | } |
| 310 | 310 | }); |
| 311 | 311 | |
| 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 | ||
| 312 | 345 | test("tables: search, edit, the caps in the status line, a ; file, and s on a broken one", async () => { |
| 313 | 346 | const made = await createRepo(forge, alice, { |
| 314 | 347 | name: "sheets", |
numandsubs-failedhave no CSS outside .csv-view; use existing classes or define them in docs/design/areas/.