From e3d4f3fa92a0483ea08b3a2506bd3239626eaf62 Mon Sep 17 00:00:00 2001 From: kcfancher Date: Mon, 13 Apr 2026 21:19:12 -0400 Subject: Add support for basic SRT subtitle import to Captions panel (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SRT subtitle import to the captions panel [Modified from 0bd641b4102c827ef8d218ada099229f73396077, 2026-03-28 08:46:23 -0400] Parse .srt files into chunks and insert them as text elements on a new text track using the existing caption styling defaults. Displays a warning when malformed cues are skipped during import. * Batch caption text insertion [Modified from eece3bbc5f0578a24a4f90f0ab87fe5ca37e94e9, 2026-03-28 09:13:18 -0400] Batch caption insertion in a BatchCommand so that undo will batch undo all subtitle insertion from either srt file or ai transcriptions. * Move subtitle parsing into a shared interface [Modified from e84404b7857f8c611eda7a65b5d61ee6c9823d75, 2026-03-28 09:52:31 -0400] Extracted the srt specific extraction behind a generic shared interface to allow easier adding of other subtitle formats in the future. * Apply better default styling for subtitles. [Modified from b5deec4285f99873ba5bf57971f7bcab26e8c743, 2026-03-31 11:49:34 -0400] Shrink font size, aim to layout 5% above bottom, keep subtitle width 80% of video, wrap text on new lines if too long. * Update Captions Panel UI Split captions panel into Generate and Import tabs as they are two different approaches for adding captions. * Add biome formatting fixes * Add .ass subtitle import support Extend subtitle import to accept .ass files alongside .srt, map supported .ass styles onto imported text elements, and display warnings when unsupported .ass features are skipped. * Change “Import subtitles” button style * refactor: review fixes * fix: convert subtitle times from seconds to ticks * refactor: replace captions tab switcher with import action button --------- Co-authored-by: Maze Winther --- apps/web/src/lib/subtitles/ass.ts | 514 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 apps/web/src/lib/subtitles/ass.ts (limited to 'apps/web/src/lib/subtitles/ass.ts') diff --git a/apps/web/src/lib/subtitles/ass.ts b/apps/web/src/lib/subtitles/ass.ts new file mode 100644 index 00000000..030c5b36 --- /dev/null +++ b/apps/web/src/lib/subtitles/ass.ts @@ -0,0 +1,514 @@ +import type { + ParseSubtitleResult, + SubtitleCue, + SubtitleStyleOverrides, +} from "./types"; + +const ASS_DEFAULT_PLAY_RES_X = 384; +const ASS_DEFAULT_PLAY_RES_Y = 288; +const STYLE_SECTION_NAMES = new Set(["v4 styles", "v4+ styles"]); +const ALIGNMENT_MAP: Record< + number, + { + textAlign: NonNullable; + verticalAlign: NonNullable< + NonNullable["verticalAlign"] + >; + } +> = { + 1: { textAlign: "left", verticalAlign: "bottom" }, + 2: { textAlign: "center", verticalAlign: "bottom" }, + 3: { textAlign: "right", verticalAlign: "bottom" }, + 4: { textAlign: "left", verticalAlign: "middle" }, + 5: { textAlign: "center", verticalAlign: "middle" }, + 6: { textAlign: "right", verticalAlign: "middle" }, + 7: { textAlign: "left", verticalAlign: "top" }, + 8: { textAlign: "center", verticalAlign: "top" }, + 9: { textAlign: "right", verticalAlign: "top" }, +}; + +interface AssScriptInfo { + playResX: number; + playResY: number; +} + +interface AssStyleRecord { + name: string; + fontname?: string; + fontsize?: string; + primarycolour?: string; + secondarycolour?: string; + outlinecolour?: string; + backcolour?: string; + bold?: string; + italic?: string; + underline?: string; + strikeout?: string; + scalex?: string; + scaley?: string; + spacing?: string; + angle?: string; + borderstyle?: string; + outline?: string; + shadow?: string; + alignment?: string; + marginl?: string; + marginr?: string; + marginv?: string; +} + +export function parseAss({ input }: { input: string }): ParseSubtitleResult { + const normalized = input.replace(/\r\n?/g, "\n").trim(); + if (!normalized) { + return { + captions: [], + skippedCueCount: 0, + warnings: [], + }; + } + + const scriptInfo: AssScriptInfo = { + playResX: ASS_DEFAULT_PLAY_RES_X, + playResY: ASS_DEFAULT_PLAY_RES_Y, + }; + const warnings = new Set(); + const styles = new Map(); + const captions: SubtitleCue[] = []; + + let currentSection = ""; + let styleFormat: string[] | null = null; + let eventFormat: string[] | null = null; + let skippedCueCount = 0; + let strippedInlineTagCueCount = 0; + let skippedNonDialogueEventCount = 0; + let ignoredEffectCount = 0; + let missingStyleCueCount = 0; + let usesHeavilyUnsupportedStyles = false; + + for (const rawLine of normalized.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith(";")) { + continue; + } + + const sectionMatch = line.match(/^\[(.+)\]$/); + if (sectionMatch) { + currentSection = sectionMatch[1].trim().toLowerCase(); + continue; + } + + if (currentSection === "script info") { + const [rawKey, ...rest] = line.split(":"); + if (!rawKey || rest.length === 0) continue; + + const key = rawKey.trim().toLowerCase(); + const value = rest.join(":").trim(); + const parsedValue = parseFloat(value); + + if (!Number.isFinite(parsedValue) || parsedValue <= 0) { + continue; + } + + if (key === "playresx") { + scriptInfo.playResX = parsedValue; + } + if (key === "playresy") { + scriptInfo.playResY = parsedValue; + } + continue; + } + + if (STYLE_SECTION_NAMES.has(currentSection)) { + if (line.startsWith("Format:")) { + styleFormat = parseAssFormat({ line }); + continue; + } + + if (line.startsWith("Style:") && styleFormat) { + const values = splitAssFields({ + value: line.slice("Style:".length).trim(), + expectedFieldCount: styleFormat.length, + }); + if (values.length !== styleFormat.length) { + continue; + } + + const record = mapFieldsToRecord({ + fields: styleFormat, + values, + }); + if (!record.name) { + continue; + } + + const mappedStyle = mapAssStyleToSubtitleStyle({ + style: record, + scriptInfo, + }); + usesHeavilyUnsupportedStyles ||= mappedStyle.hasUnsupportedFeatures; + styles.set(record.name.toLowerCase(), mappedStyle.style); + } + continue; + } + + if (currentSection !== "events") { + continue; + } + + if (line.startsWith("Format:")) { + eventFormat = parseAssFormat({ line }); + continue; + } + + if (!eventFormat) { + continue; + } + + if (line.startsWith("Dialogue:")) { + const values = splitAssFields({ + value: line.slice("Dialogue:".length).trim(), + expectedFieldCount: eventFormat.length, + }); + if (values.length !== eventFormat.length) { + skippedCueCount += 1; + continue; + } + + const record = mapFieldsToRecord>({ + fields: eventFormat, + values, + }); + const startTime = parseAssTimestamp({ input: record.start }); + const endTime = parseAssTimestamp({ input: record.end }); + const duration = endTime - startTime; + if ( + !Number.isFinite(startTime) || + !Number.isFinite(endTime) || + duration <= 0 + ) { + skippedCueCount += 1; + continue; + } + + const strippedText = stripAssText({ + input: record.text ?? "", + }); + strippedInlineTagCueCount += strippedText.hadInlineTags ? 1 : 0; + if (!strippedText.text) { + skippedCueCount += 1; + continue; + } + + if (record.effect?.trim()) { + ignoredEffectCount += 1; + } + + const referencedStyleName = record.style?.trim().toLowerCase(); + const referencedStyle = referencedStyleName + ? styles.get(referencedStyleName) + : undefined; + if (referencedStyleName && !referencedStyle) { + missingStyleCueCount += 1; + } + const style = referencedStyle ?? styles.get("default"); + + captions.push({ + text: strippedText.text, + startTime, + duration, + style, + }); + continue; + } + + if (line.includes(":")) { + skippedNonDialogueEventCount += 1; + } + } + + if (strippedInlineTagCueCount > 0) { + warnings.add( + `Stripped unsupported ASS inline override tags from ${strippedInlineTagCueCount} subtitle cue(s).`, + ); + } + + if (ignoredEffectCount > 0) { + warnings.add( + `Ignored ASS event effects in ${ignoredEffectCount} subtitle cue(s).`, + ); + } + + if (missingStyleCueCount > 0) { + warnings.add( + `Fell back to default subtitle styling for ${missingStyleCueCount} cue(s) that referenced missing ASS styles.`, + ); + } + + if (skippedNonDialogueEventCount > 0) { + warnings.add( + `Ignored ${skippedNonDialogueEventCount} non-dialogue ASS event(s).`, + ); + } + + if (usesHeavilyUnsupportedStyles) { + warnings.add( + "Ignored unsupported ASS style features such as outline, shadow, rotation, or scaling.", + ); + } + + return { + captions, + skippedCueCount, + warnings: [...warnings], + }; +} + +function parseAssFormat({ line }: { line: string }): string[] { + return line + .slice(line.indexOf(":") + 1) + .split(",") + .map((field) => field.trim().toLowerCase()); +} + +function splitAssFields({ + value, + expectedFieldCount, +}: { + value: string; + expectedFieldCount: number; +}): string[] { + if (expectedFieldCount <= 1) { + return [value]; + } + + const result: string[] = []; + let current = ""; + + for (const character of value) { + if (character === "," && result.length < expectedFieldCount - 1) { + result.push(current.trim()); + current = ""; + continue; + } + + current += character; + } + + result.push(current.trim()); + return result; +} + +function mapFieldsToRecord({ + fields, + values, +}: { + fields: string[]; + values: string[]; +}): T { + const record = {} as Record; + + for (let index = 0; index < fields.length; index++) { + record[fields[index]] = values[index]; + } + + return record as T; +} + +function parseAssTimestamp({ input }: { input: string | undefined }): number { + if (!input) { + return Number.NaN; + } + + const match = input.trim().match(/^(\d+):(\d{2}):(\d{2})[.](\d{1,2}|\d{3})$/); + if (!match) { + return Number.NaN; + } + + const [, hours, minutes, seconds, fraction] = match; + const parsedHours = Number.parseInt(hours, 10); + const parsedMinutes = Number.parseInt(minutes, 10); + const parsedSeconds = Number.parseInt(seconds, 10); + const parsedMilliseconds = Number.parseInt(fraction.padEnd(3, "0"), 10); + + return ( + parsedHours * 3600 + + parsedMinutes * 60 + + parsedSeconds + + parsedMilliseconds / 1000 + ); +} + +function stripAssText({ input }: { input: string }): { + text: string; + hadInlineTags: boolean; +} { + const hadInlineTags = /\{[^}]*\}/.test(input); + const text = input + .replace(/\{[^}]*\}/g, "") + .replace(/\\N/gi, "\n") + .replace(/\\h/g, " ") + .replace(/\\n/g, "\n") + .trim(); + + return { + text, + hadInlineTags, + }; +} + +function mapAssStyleToSubtitleStyle({ + style, + scriptInfo, +}: { + style: AssStyleRecord; + scriptInfo: AssScriptInfo; +}): { + style: SubtitleStyleOverrides; + hasUnsupportedFeatures: boolean; +} { + const fontSize = parseFloat(style.fontsize ?? ""); + const primaryColor = parseAssColor({ input: style.primarycolour }); + const backColor = parseAssColor({ input: style.backcolour }); + const bold = parseAssBoolean({ input: style.bold }); + const italic = parseAssBoolean({ input: style.italic }); + const underline = parseAssBoolean({ input: style.underline }); + const strikeOut = parseAssBoolean({ input: style.strikeout }); + const borderStyle = parseFloat(style.borderstyle ?? ""); + const spacing = parseFloat(style.spacing ?? ""); + const alignment = parseFloat(style.alignment ?? ""); + const marginLeft = parseFloat(style.marginl ?? ""); + const marginRight = parseFloat(style.marginr ?? ""); + const marginVertical = parseFloat(style.marginv ?? ""); + // Store as a ratio of playResY so the builder can convert to app units + // without the parser needing to know the app's coordinate system. + const fontSizeRatioOfPlayHeight = Number.isFinite(fontSize) + ? Math.round((fontSize / scriptInfo.playResY) * 1000) / 1000 + : null; + + const mappedAlignment = + ALIGNMENT_MAP[Math.round(alignment)] ?? ALIGNMENT_MAP[2]; + const placement = + Number.isFinite(marginLeft) || + Number.isFinite(marginRight) || + Number.isFinite(marginVertical) || + mappedAlignment.verticalAlign !== "bottom" + ? { + verticalAlign: mappedAlignment.verticalAlign, + marginLeftRatio: Number.isFinite(marginLeft) + ? marginLeft / scriptInfo.playResX + : undefined, + marginRightRatio: Number.isFinite(marginRight) + ? marginRight / scriptInfo.playResX + : undefined, + marginVerticalRatio: Number.isFinite(marginVertical) + ? marginVertical / scriptInfo.playResY + : undefined, + } + : undefined; + + const styleOverrides: SubtitleStyleOverrides = { + ...(style.fontname ? { fontFamily: style.fontname.trim() } : {}), + ...(fontSizeRatioOfPlayHeight !== null && fontSizeRatioOfPlayHeight > 0 + ? { fontSizeRatioOfPlayHeight } + : {}), + ...(primaryColor?.cssColor ? { color: primaryColor.cssColor } : {}), + ...(bold !== null ? { fontWeight: bold ? "bold" : "normal" } : {}), + ...(italic !== null ? { fontStyle: italic ? "italic" : "normal" } : {}), + ...(underline || strikeOut + ? { + textDecoration: underline + ? "underline" + : strikeOut + ? "line-through" + : "none", + } + : {}), + ...(Number.isFinite(spacing) ? { letterSpacing: spacing } : {}), + textAlign: mappedAlignment.textAlign, + ...(placement ? { placement } : {}), + ...(backColor?.cssColor && Math.round(borderStyle) === 3 + ? { + background: { + enabled: backColor.alpha > 0, + color: backColor.alpha > 0 ? backColor.cssColor : "transparent", + }, + } + : {}), + }; + + const hasUnsupportedFeatures = + Math.round(borderStyle) !== 1 && Math.round(borderStyle) !== 3 + ? true + : (parseFloat(style.outline ?? "") || 0) > 0 || + (parseFloat(style.shadow ?? "") || 0) > 0 || + (parseFloat(style.angle ?? "") || 0) !== 0 || + (Number.isFinite(parseFloat(style.scalex ?? "")) && + parseFloat(style.scalex ?? "") !== 100) || + (Number.isFinite(parseFloat(style.scaley ?? "")) && + parseFloat(style.scaley ?? "") !== 100) || + Boolean(underline && strikeOut); + + return { + style: styleOverrides, + hasUnsupportedFeatures, + }; +} + +function parseAssBoolean({ + input, +}: { + input: string | undefined; +}): boolean | null { + if (input === undefined) { + return null; + } + + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed)) { + return null; + } + + return parsed !== 0; +} + +function parseAssColor({ + input, +}: { + input: string | undefined; +}): { cssColor: string; alpha: number } | null { + if (!input) { + return null; + } + + const normalized = input.trim().replace(/^&?H/i, "").padStart(8, "0"); + if (!/^[0-9a-fA-F]{8}$/.test(normalized)) { + return null; + } + + const alphaHex = normalized.slice(0, 2); + const blueHex = normalized.slice(2, 4); + const greenHex = normalized.slice(4, 6); + const redHex = normalized.slice(6, 8); + + const red = Number.parseInt(redHex, 16); + const green = Number.parseInt(greenHex, 16); + const blue = Number.parseInt(blueHex, 16); + const alpha = 1 - Number.parseInt(alphaHex, 16) / 255; + + if (alpha >= 1) { + return { + cssColor: `#${redHex}${greenHex}${blueHex}`.toLowerCase(), + alpha, + }; + } + + return { + cssColor: `rgba(${red}, ${green}, ${blue}, ${Math.round(alpha * 1000) / 1000})`, + alpha, + }; +} -- cgit v1.3.1