diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/subtitles/build-subtitle-text-element.ts | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/subtitles/build-subtitle-text-element.ts')
| -rw-r--r-- | apps/web/src/lib/subtitles/build-subtitle-text-element.ts | 333 |
1 files changed, 0 insertions, 333 deletions
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<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, - }, - }, - }; -} |
