summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/subtitles/build-subtitle-text-element.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/subtitles/build-subtitle-text-element.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-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.ts333
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,
- },
- },
- };
-}