From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/lib/subtitles/ass.ts | 514 --------------------- .../lib/subtitles/build-subtitle-text-element.ts | 333 ------------- apps/web/src/lib/subtitles/insert.ts | 40 -- apps/web/src/lib/subtitles/parse.ts | 28 -- apps/web/src/lib/subtitles/srt.ts | 98 ---- apps/web/src/lib/subtitles/types.ts | 46 -- 6 files changed, 1059 deletions(-) delete mode 100644 apps/web/src/lib/subtitles/ass.ts delete mode 100644 apps/web/src/lib/subtitles/build-subtitle-text-element.ts delete mode 100644 apps/web/src/lib/subtitles/insert.ts delete mode 100644 apps/web/src/lib/subtitles/parse.ts delete mode 100644 apps/web/src/lib/subtitles/srt.ts delete mode 100644 apps/web/src/lib/subtitles/types.ts (limited to 'apps/web/src/lib/subtitles') 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; - 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, - }; -} diff --git a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts deleted file mode 100644 index 698310a6..00000000 --- a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography"; -import { - getTextVisualRect, - measureTextBlock, - setCanvasLetterSpacing, -} from "@/lib/text/layout"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import type { CreateTextElement } from "@/lib/timeline"; -import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; - -const SUBTITLE_MAX_WIDTH_RATIO = 0.8; -const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05; -const SUBTITLE_FONT_SIZE = 5; -const MEASUREMENT_CANVAS_SIZE = 4096; - -function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { - return `"${fontFamily.replace(/"/g, '\\"')}"`; -} - -function createMeasurementContext(): CanvasRenderingContext2D | null { - const canvas = document.createElement("canvas"); - canvas.width = MEASUREMENT_CANVAS_SIZE; - canvas.height = MEASUREMENT_CANVAS_SIZE; - return canvas.getContext("2d"); -} - -function measureLineWidth({ - ctx, - text, -}: { - ctx: CanvasRenderingContext2D; - text: string; -}): number { - return ctx.measureText(text).width; -} - -function wrapSubtitleText({ - ctx, - text, - maxWidth, -}: { - ctx: CanvasRenderingContext2D; - text: string; - maxWidth: number; -}): string { - const normalized = text.trim().replace(/\r\n/g, "\n"); - const paragraphs = normalized.split("\n"); - const wrappedParagraphs: string[] = []; - - for (const paragraph of paragraphs) { - const trimmedParagraph = paragraph.trim(); - if (!trimmedParagraph) { - wrappedParagraphs.push(""); - continue; - } - - const words = trimmedParagraph.split(/\s+/); - let currentLine = words[0] ?? ""; - const lines: string[] = []; - - for (let i = 1; i < words.length; i++) { - const nextLine = `${currentLine} ${words[i]}`; - if (measureLineWidth({ ctx, text: nextLine }) <= maxWidth) { - currentLine = nextLine; - continue; - } - - lines.push(currentLine); - currentLine = words[i]; - } - - lines.push(currentLine); - wrappedParagraphs.push(lines.join("\n")); - } - - return wrappedParagraphs.join("\n"); -} - -function measureWrappedTextBlock({ - ctx, - content, - canvasHeight, - textAlign, - background, - fontSize, - lineHeight, -}: { - ctx: CanvasRenderingContext2D; - content: string; - canvasHeight: number; - textAlign: CreateTextElement["textAlign"]; - background: CreateTextElement["background"]; - fontSize: number; - lineHeight: number; -}) { - const scaledFontSize = fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const lineHeightPx = lineHeight * scaledFontSize; - const lines = content.split("\n"); - const lineMetrics = lines.map((line) => ctx.measureText(line)); - - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - }); - const visualRect = getTextVisualRect({ - textAlign, - block, - background, - fontSizeRatio: fontSize / DEFAULTS.text.element.fontSize, - }); - - return { - block, - visualRect, - }; -} - -function resolveSubtitleStyle({ - style, -}: { - style: SubtitleStyleOverrides | undefined; -}): { - fontFamily: string; - fontSize: number; - color: string; - textAlign: CreateTextElement["textAlign"]; - fontWeight: CreateTextElement["fontWeight"]; - fontStyle: CreateTextElement["fontStyle"]; - textDecoration: CreateTextElement["textDecoration"]; - letterSpacing: number; - lineHeight: number; - background: CreateTextElement["background"]; - placement: NonNullable; -} { - const fontSize = - style?.fontSizeRatioOfPlayHeight != null - ? style.fontSizeRatioOfPlayHeight * FONT_SIZE_SCALE_REFERENCE - : (style?.fontSize ?? SUBTITLE_FONT_SIZE); - - return { - fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, - fontSize, - color: style?.color ?? DEFAULTS.text.element.color, - textAlign: style?.textAlign ?? "center", - fontWeight: style?.fontWeight ?? "bold", - fontStyle: style?.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: - style?.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing, - lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight, - background: { - ...DEFAULTS.text.element.background, - enabled: false, - ...(style?.background ?? {}), - }, - placement: { - verticalAlign: style?.placement?.verticalAlign ?? "bottom", - marginLeftRatio: style?.placement?.marginLeftRatio, - marginRightRatio: style?.placement?.marginRightRatio, - marginVerticalRatio: style?.placement?.marginVerticalRatio, - }, - }; -} - -function resolveTargetWidth({ - canvasWidth, - placement, -}: { - canvasWidth: number; - placement: ReturnType["placement"]; -}): number { - const leftRatio = placement.marginLeftRatio ?? 0; - const rightRatio = placement.marginRightRatio ?? 0; - const hasExplicitMargins = leftRatio > 0 || rightRatio > 0; - if (!hasExplicitMargins) { - return canvasWidth * SUBTITLE_MAX_WIDTH_RATIO; - } - - const availableWidth = canvasWidth * (1 - leftRatio - rightRatio); - return Math.max(0, availableWidth); -} - -function resolvePositionX({ - canvasWidth, - textAlign, - placement, - visualRect, -}: { - canvasWidth: number; - textAlign: CreateTextElement["textAlign"]; - placement: ReturnType["placement"]; - visualRect: { left: number; width: number }; -}): number { - const leftMargin = canvasWidth * (placement.marginLeftRatio ?? 0); - const rightMargin = canvasWidth * (placement.marginRightRatio ?? 0); - const canvasCenterX = canvasWidth / 2; - - if (textAlign === "left") { - return leftMargin - visualRect.left - canvasCenterX; - } - - if (textAlign === "right") { - return ( - canvasWidth - - rightMargin - - (visualRect.left + visualRect.width) - - canvasCenterX - ); - } - - const availableWidth = canvasWidth - leftMargin - rightMargin; - const targetCenterX = leftMargin + availableWidth / 2; - return ( - targetCenterX - (visualRect.left + visualRect.width / 2) - canvasCenterX - ); -} - -function resolvePositionY({ - canvasHeight, - placement, - visualRect, -}: { - canvasHeight: number; - placement: ReturnType["placement"]; - visualRect: { top: number; height: number }; -}): number { - const margin = - canvasHeight * - (placement.marginVerticalRatio ?? SUBTITLE_BOTTOM_MARGIN_RATIO); - const canvasCenterY = canvasHeight / 2; - - if (placement.verticalAlign === "top") { - return margin - visualRect.top - canvasCenterY; - } - - if (placement.verticalAlign === "middle") { - const targetCenterY = canvasHeight / 2; - return ( - targetCenterY - (visualRect.top + visualRect.height / 2) - canvasCenterY - ); - } - - return ( - canvasHeight - margin - (visualRect.top + visualRect.height) - canvasCenterY - ); -} - -export function buildSubtitleTextElement({ - index, - caption, - canvasSize, -}: { - index: number; - caption: SubtitleCue; - canvasSize: { width: number; height: number }; -}): CreateTextElement { - const ctx = createMeasurementContext(); - const style = resolveSubtitleStyle({ - style: caption.style, - }); - const fontFamily = quoteFontFamily({ - fontFamily: style.fontFamily, - }); - const fontWeight = style.fontWeight; - const fontStyle = style.fontStyle === "italic" ? "italic" : "normal"; - const scaledFontSize = - style.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); - const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - const maxWidth = resolveTargetWidth({ - canvasWidth: canvasSize.width, - placement: style.placement, - }); - - let content = caption.text; - let positionX = 0; - let positionY = 0; - - if (ctx) { - ctx.font = fontString; - setCanvasLetterSpacing({ ctx, letterSpacingPx: style.letterSpacing }); - content = wrapSubtitleText({ - ctx, - text: caption.text, - maxWidth, - }); - const measurement = measureWrappedTextBlock({ - ctx, - content, - canvasHeight: canvasSize.height, - textAlign: style.textAlign, - background: style.background, - fontSize: style.fontSize, - lineHeight: style.lineHeight, - }); - positionX = resolvePositionX({ - canvasWidth: canvasSize.width, - textAlign: style.textAlign, - placement: style.placement, - visualRect: measurement.visualRect, - }); - positionY = resolvePositionY({ - canvasHeight: canvasSize.height, - placement: style.placement, - visualRect: measurement.visualRect, - }); - } - - return { - ...DEFAULTS.text.element, - name: `Caption ${index + 1}`, - content, - duration: Math.round(caption.duration * TICKS_PER_SECOND), - startTime: Math.round(caption.startTime * TICKS_PER_SECOND), - fontSize: style.fontSize, - fontFamily: style.fontFamily, - color: style.color, - textAlign: style.textAlign, - fontWeight: style.fontWeight, - fontStyle: style.fontStyle, - textDecoration: style.textDecoration, - letterSpacing: style.letterSpacing, - lineHeight: style.lineHeight, - background: style.background, - transform: { - ...DEFAULTS.text.element.transform, - position: { - x: positionX, - y: positionY, - }, - }, - }; -} diff --git a/apps/web/src/lib/subtitles/insert.ts b/apps/web/src/lib/subtitles/insert.ts deleted file mode 100644 index 1bb04fc8..00000000 --- a/apps/web/src/lib/subtitles/insert.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { EditorCore } from "@/core"; -import { - AddTrackCommand, - BatchCommand, - InsertElementCommand, -} from "@/lib/commands"; -import { buildSubtitleTextElement } from "./build-subtitle-text-element"; -import type { SubtitleCue } from "./types"; - -export function insertCaptionChunksAsTextTrack({ - editor, - captions, -}: { - editor: EditorCore; - captions: SubtitleCue[]; -}): string | null { - if (captions.length === 0) { - return null; - } - - const addTrackCommand = new AddTrackCommand("text", 0); - const trackId = addTrackCommand.getTrackId(); - const canvasSize = editor.project.getActive().settings.canvasSize; - const insertCommands = captions.map( - (caption, index) => - new InsertElementCommand({ - placement: { mode: "explicit", trackId }, - element: buildSubtitleTextElement({ - index, - caption, - canvasSize, - }), - }), - ); - editor.command.execute({ - command: new BatchCommand([addTrackCommand, ...insertCommands]), - }); - - return trackId; -} diff --git a/apps/web/src/lib/subtitles/parse.ts b/apps/web/src/lib/subtitles/parse.ts deleted file mode 100644 index 7a2d2342..00000000 --- a/apps/web/src/lib/subtitles/parse.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { parseAss } from "./ass"; -import { parseSrt } from "./srt"; -import type { ParseSubtitleResult } from "./types"; -export type { ParseSubtitleResult, SubtitleCue } from "./types"; - -export function parseSubtitleFile({ - fileName, - input, -}: { - fileName: string; - input: string; -}): ParseSubtitleResult { - const extension = getFileExtension({ fileName }); - - switch (extension) { - case "srt": - return parseSrt({ input }); - case "ass": - return parseAss({ input }); - default: - throw new Error("Unsupported subtitle format"); - } -} - -function getFileExtension({ fileName }: { fileName: string }): string { - const extension = fileName.split(".").pop(); - return extension?.toLowerCase() ?? ""; -} diff --git a/apps/web/src/lib/subtitles/srt.ts b/apps/web/src/lib/subtitles/srt.ts deleted file mode 100644 index 197e96f9..00000000 --- a/apps/web/src/lib/subtitles/srt.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ParseSubtitleResult, SubtitleCue } from "./types"; - -const TIMESTAMP_SEPARATOR = /\s*-->\s*/; -const TIMESTAMP_PATTERN = - /^(\d{2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{1,3})/; - -export function parseSrt({ input }: { input: string }): ParseSubtitleResult { - const normalized = input.replace(/\r\n?/g, "\n").trim(); - if (!normalized) { - return { - captions: [], - skippedCueCount: 0, - warnings: [], - }; - } - - const blocks = normalized.split(/\n{2,}/); - const cues: SubtitleCue[] = []; - let skippedCueCount = 0; - - for (const block of blocks) { - const lines = block - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - if (lines.length < 2) { - skippedCueCount += 1; - continue; - } - - const timestampIndex = TIMESTAMP_SEPARATOR.test(lines[0]) ? 0 : 1; - const timestampLine = lines[timestampIndex]; - if (!timestampLine || !TIMESTAMP_PATTERN.test(timestampLine)) { - skippedCueCount += 1; - continue; - } - - const textLines = lines.slice(timestampIndex + 1); - const text = textLines.join("\n").trim(); - if (!text) { - skippedCueCount += 1; - continue; - } - - const [rawStart, rawEnd] = timestampLine.split(TIMESTAMP_SEPARATOR); - if (!rawStart || !rawEnd) { - skippedCueCount += 1; - continue; - } - - const startTime = parseSrtTimestamp({ input: rawStart }); - const endTime = parseSrtTimestamp({ input: rawEnd }); - const duration = endTime - startTime; - - if ( - !Number.isFinite(startTime) || - !Number.isFinite(endTime) || - duration <= 0 - ) { - skippedCueCount += 1; - continue; - } - - cues.push({ - text, - startTime, - duration, - }); - } - - return { - captions: cues, - skippedCueCount, - warnings: [], - }; -} - -function parseSrtTimestamp({ input }: { input: string }): number { - const normalized = input.trim().replace(",", "."); - const match = normalized.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{1,3})$/); - if (!match) { - return Number.NaN; - } - - const [, hours, minutes, seconds, milliseconds] = match; - const parsedHours = Number.parseInt(hours, 10); - const parsedMinutes = Number.parseInt(minutes, 10); - const parsedSeconds = Number.parseInt(seconds, 10); - const parsedMilliseconds = Number.parseInt(milliseconds.padEnd(3, "0"), 10); - - return ( - parsedHours * 3600 + - parsedMinutes * 60 + - parsedSeconds + - parsedMilliseconds / 1000 - ); -} diff --git a/apps/web/src/lib/subtitles/types.ts b/apps/web/src/lib/subtitles/types.ts deleted file mode 100644 index 86f6525d..00000000 --- a/apps/web/src/lib/subtitles/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { TextBackground, TextElement } from "@/lib/timeline"; -import type { CaptionChunk } from "@/lib/transcription/types"; - -export interface SubtitlePlacementStyle { - verticalAlign?: "top" | "middle" | "bottom"; - marginLeftRatio?: number; - marginRightRatio?: number; - marginVerticalRatio?: number; -} - -export interface SubtitleStyleOverrides { - /** - * Font size in app units (same coordinate space as TextElement.fontSize). - * Use fontSizeRatioOfPlayHeight when the source coordinate space is unknown - * (e.g. ASS files, where font size is relative to the script's play resolution). - */ - fontSize?: number; - /** - * Font size expressed as a fraction of the reference canvas height. - * Set by the ASS parser so the builder can convert to app units without - * the parser needing to know about the app's coordinate system. - * Takes precedence over fontSize when both are present. - */ - fontSizeRatioOfPlayHeight?: number; - fontFamily?: string; - color?: string; - background?: Pick & - Partial>; - textAlign?: TextElement["textAlign"]; - fontWeight?: TextElement["fontWeight"]; - fontStyle?: TextElement["fontStyle"]; - textDecoration?: TextElement["textDecoration"]; - letterSpacing?: number; - lineHeight?: number; - placement?: SubtitlePlacementStyle; -} - -export interface SubtitleCue extends CaptionChunk { - style?: SubtitleStyleOverrides; -} - -export interface ParseSubtitleResult { - captions: SubtitleCue[]; - skippedCueCount: number; - warnings: string[]; -} -- cgit v1.3.1