summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/subtitles/ass.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/subtitles/ass.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/subtitles/ass.ts')
-rw-r--r--apps/web/src/lib/subtitles/ass.ts514
1 files changed, 0 insertions, 514 deletions
diff --git a/apps/web/src/lib/subtitles/ass.ts b/apps/web/src/lib/subtitles/ass.ts
deleted file mode 100644
index 030c5b36..00000000
--- a/apps/web/src/lib/subtitles/ass.ts
+++ /dev/null
@@ -1,514 +0,0 @@
-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<SubtitleStyleOverrides["textAlign"]>;
- verticalAlign: NonNullable<
- NonNullable<SubtitleStyleOverrides["placement"]>["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<string>();
- const styles = new Map<string, SubtitleStyleOverrides>();
- 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<AssStyleRecord>({
- 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<Record<string, string>>({
- 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<T extends object>({
- fields,
- values,
-}: {
- fields: string[];
- values: string[];
-}): T {
- const record = {} as Record<string, string | undefined>;
-
- 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,
- };
-}