diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index be0d4108..f35e0e81 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -42,7 +42,7 @@ export function Captions() { const [isProcessing, setIsProcessing] = useState(false); const [processingStep, setProcessingStep] = useState(""); const [error, setError] = useState(null); - const [warning, setWarning] = useState(null); + const [warnings, setWarnings] = useState([]); const containerRef = useRef(null); const fileInputRef = useRef(null); const editor = useEditor(); @@ -59,7 +59,7 @@ export function Captions() { try { setIsProcessing(true); setError(null); - setWarning(null); + setWarnings([]); setProcessingStep("Extracting audio..."); const audioBlob = await extractTimelineAudio({ @@ -113,7 +113,7 @@ export function Captions() { try { setIsProcessing(true); setError(null); - setWarning(null); + setWarnings([]); setProcessingStep("Reading subtitle file..."); const input = await file.text(); @@ -123,17 +123,23 @@ export function Captions() { }); if (result.captions.length === 0) { - throw new Error("No valid subtitle cues were found in the .srt file"); + throw new Error( + "No valid subtitle cues were found in the subtitle file", + ); } setProcessingStep("Importing subtitles..."); insertCaptionChunks({ captions: result.captions }); + const nextWarnings = [...result.warnings]; if (result.skippedCueCount > 0) { - setWarning( + nextWarnings.unshift( `Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`, ); } + if (nextWarnings.length > 0) { + setWarnings(nextWarnings); + } } catch (error) { console.error("Subtitle import failed:", error); setError( @@ -191,7 +197,7 @@ export function Captions() { void handleFileChange({ event })} /> @@ -224,6 +230,7 @@ export function Captions() { {error && (

{error}

)} - {warning && ( + {warnings.length > 0 && (
-

{warning}

+
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
)} diff --git a/apps/web/src/lib/subtitles/ass.ts b/apps/web/src/lib/subtitles/ass.ts new file mode 100644 index 00000000..8ecbfe6d --- /dev/null +++ b/apps/web/src/lib/subtitles/ass.ts @@ -0,0 +1,514 @@ +import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants"; +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 fontSizeRatio = FONT_SIZE_SCALE_REFERENCE / scriptInfo.playResY; + 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 ?? ""); + const mappedFontSize = Number.isFinite(fontSize) + ? Math.round(fontSize * fontSizeRatio * 1000) / 1000 + : Number.NaN; + + 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() } : {}), + ...(Number.isFinite(mappedFontSize) && mappedFontSize > 0 + ? { fontSize: mappedFontSize } + : {}), + ...(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 index 8519e93b..a2c6a923 100644 --- a/apps/web/src/lib/subtitles/build-subtitle-text-element.ts +++ b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts @@ -1,8 +1,12 @@ import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants"; -import { measureTextBlock } from "@/lib/text/layout"; +import { + getTextVisualRect, + measureTextBlock, + setCanvasLetterSpacing, +} from "@/lib/text/layout"; import { DEFAULTS } from "@/lib/timeline/defaults"; import type { CreateTextElement } from "@/lib/timeline"; -import type { CaptionChunk } from "@/lib/transcription/types"; +import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; const SUBTITLE_MAX_WIDTH_RATIO = 0.8; const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05; @@ -75,22 +79,165 @@ 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 = - SUBTITLE_FONT_SIZE * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const lineHeight = (DEFAULTS.text.lineHeight ?? 1.2) * scaledFontSize; + 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)); - return measureTextBlock({ + const block = measureTextBlock({ lineMetrics, - lineHeightPx: lineHeight, + lineHeightPx, fallbackFontSize: scaledFontSize, }); + 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; +} { + return { + fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily, + fontSize: style?.fontSize ?? SUBTITLE_FONT_SIZE, + 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({ @@ -99,58 +246,80 @@ export function buildSubtitleTextElement({ canvasSize, }: { index: number; - caption: CaptionChunk; + caption: SubtitleCue; canvasSize: { width: number; height: number }; }): CreateTextElement { const ctx = createMeasurementContext(); - const fontFamily = quoteFontFamily({ - fontFamily: DEFAULTS.text.element.fontFamily, + const style = resolveSubtitleStyle({ + style: caption.style, }); - const fontWeight = "bold"; - const fontStyle = - DEFAULTS.text.element.fontStyle === "italic" ? "italic" : "normal"; + const fontFamily = quoteFontFamily({ + fontFamily: style.fontFamily, + }); + const fontWeight = style.fontWeight; + const fontStyle = style.fontStyle === "italic" ? "italic" : "normal"; const scaledFontSize = - SUBTITLE_FONT_SIZE * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); + style.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - const maxWidth = canvasSize.width * SUBTITLE_MAX_WIDTH_RATIO; + const maxWidth = resolveTargetWidth({ + canvasWidth: canvasSize.width, + placement: style.placement, + }); let content = caption.text; - let blockHeight = scaledFontSize; + let positionX = 0; + let positionY = 0; if (ctx) { ctx.font = fontString; + setCanvasLetterSpacing({ ctx, letterSpacingPx: style.letterSpacing }); content = wrapSubtitleText({ ctx, text: caption.text, maxWidth, }); - blockHeight = measureWrappedTextBlock({ + const measurement = measureWrappedTextBlock({ ctx, content, canvasHeight: canvasSize.height, - }).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, + }); } - const bottomMargin = canvasSize.height * SUBTITLE_BOTTOM_MARGIN_RATIO; - const centerY = canvasSize.height - bottomMargin - blockHeight / 2; - const positionY = centerY - canvasSize.height / 2; - return { ...DEFAULTS.text.element, name: `Caption ${index + 1}`, content, duration: caption.duration, startTime: caption.startTime, - fontSize: SUBTITLE_FONT_SIZE, - fontWeight, - background: { - ...DEFAULTS.text.element.background, - enabled: false, - }, + 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: 0, + x: positionX, y: positionY, }, }, diff --git a/apps/web/src/lib/subtitles/insert.ts b/apps/web/src/lib/subtitles/insert.ts index 34425574..c6a8d7fc 100644 --- a/apps/web/src/lib/subtitles/insert.ts +++ b/apps/web/src/lib/subtitles/insert.ts @@ -1,18 +1,19 @@ import type { EditorCore } from "@/core"; -import type { CaptionChunk } from "@/lib/transcription/types"; +import type { Command } from "@/lib/commands"; 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: CaptionChunk[]; + captions: SubtitleCue[]; }): string | null { if (captions.length === 0) { return null; @@ -20,22 +21,19 @@ export function insertCaptionChunksAsTextTrack({ const addTrackCommand = new AddTrackCommand("text", 0); const trackId = addTrackCommand.getTrackId(); - const commands = [addTrackCommand]; const canvasSize = editor.project.getActive().settings.canvasSize; - - for (let i = 0; i < captions.length; i++) { - const caption = captions[i]; - commands.push( + const insertCommands = captions.map( + (caption, index) => new InsertElementCommand({ placement: { mode: "explicit", trackId }, element: buildSubtitleTextElement({ - index: i, + index, caption, canvasSize, }), }), - ); - } + ); + const commands = [addTrackCommand, ...insertCommands] as unknown as Command[]; editor.command.execute({ command: new BatchCommand(commands), diff --git a/apps/web/src/lib/subtitles/parse.ts b/apps/web/src/lib/subtitles/parse.ts index da5e2930..7a2d2342 100644 --- a/apps/web/src/lib/subtitles/parse.ts +++ b/apps/web/src/lib/subtitles/parse.ts @@ -1,10 +1,7 @@ -import type { CaptionChunk } from "@/lib/transcription/types"; +import { parseAss } from "./ass"; import { parseSrt } from "./srt"; - -export interface ParseSubtitleResult { - captions: CaptionChunk[]; - skippedCueCount: number; -} +import type { ParseSubtitleResult } from "./types"; +export type { ParseSubtitleResult, SubtitleCue } from "./types"; export function parseSubtitleFile({ fileName, @@ -18,6 +15,8 @@ export function parseSubtitleFile({ switch (extension) { case "srt": return parseSrt({ input }); + case "ass": + return parseAss({ input }); default: throw new Error("Unsupported subtitle format"); } diff --git a/apps/web/src/lib/subtitles/srt.ts b/apps/web/src/lib/subtitles/srt.ts index 98ff90e6..197e96f9 100644 --- a/apps/web/src/lib/subtitles/srt.ts +++ b/apps/web/src/lib/subtitles/srt.ts @@ -1,5 +1,4 @@ -import type { CaptionChunk } from "@/lib/transcription/types"; -import type { ParseSubtitleResult } from "./parse"; +import type { ParseSubtitleResult, SubtitleCue } from "./types"; const TIMESTAMP_SEPARATOR = /\s*-->\s*/; const TIMESTAMP_PATTERN = @@ -11,11 +10,12 @@ export function parseSrt({ input }: { input: string }): ParseSubtitleResult { return { captions: [], skippedCueCount: 0, + warnings: [], }; } const blocks = normalized.split(/\n{2,}/); - const cues: CaptionChunk[] = []; + const cues: SubtitleCue[] = []; let skippedCueCount = 0; for (const block of blocks) { @@ -72,6 +72,7 @@ export function parseSrt({ input }: { input: string }): ParseSubtitleResult { return { captions: cues, skippedCueCount, + warnings: [], }; } diff --git a/apps/web/src/lib/subtitles/types.ts b/apps/web/src/lib/subtitles/types.ts new file mode 100644 index 00000000..42fa048a --- /dev/null +++ b/apps/web/src/lib/subtitles/types.ts @@ -0,0 +1,34 @@ +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 { + fontSize?: 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[]; +}