summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/subtitles/build-subtitle-text-element.ts
diff options
context:
space:
mode:
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, 333 insertions, 0 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
new file mode 100644
index 00000000..8be93e9d
--- /dev/null
+++ b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts
@@ -0,0 +1,333 @@
+import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
+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,
+ },
+ },
+ };
+}