import { FONT_SIZE_SCALE_REFERENCE } from "@/text/typography"; import { getTextVisualRect, measureTextBlock, setCanvasLetterSpacing, } from "@/text/layout"; import { DEFAULTS } from "@/timeline/defaults"; import { TICKS_PER_SECOND } from "@/wasm"; import type { CreateTextElement } from "@/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, }, }, }; }