diff options
Diffstat (limited to 'apps/web/src/subtitles/build-subtitle-text-element.ts')
| -rw-r--r-- | apps/web/src/subtitles/build-subtitle-text-element.ts | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/apps/web/src/subtitles/build-subtitle-text-element.ts b/apps/web/src/subtitles/build-subtitle-text-element.ts new file mode 100644 index 00000000..c4ac21ac --- /dev/null +++ b/apps/web/src/subtitles/build-subtitle-text-element.ts @@ -0,0 +1,333 @@ +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<SubtitleStyleOverrides["placement"]>; +} { + 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<typeof resolveSubtitleStyle>["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<typeof resolveSubtitleStyle>["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<typeof resolveSubtitleStyle>["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, + }, + }, + }; +} |
