349 lines
9.0 KiB
TypeScript
349 lines
9.0 KiB
TypeScript
import { FONT_SIZE_SCALE_REFERENCE } from "@/text/typography";
|
|
import {
|
|
getTextVisualRect,
|
|
measureTextBlock,
|
|
setCanvasLetterSpacing,
|
|
} from "@/text/layout";
|
|
import { DEFAULTS } from "@/timeline/defaults";
|
|
import { mediaTimeFromSeconds } from "@/wasm";
|
|
import type { CreateTextElement } from "@/timeline";
|
|
import type { TextBackground } from "@/text/background";
|
|
import type {
|
|
TextAlign,
|
|
TextDecoration,
|
|
TextFontStyle,
|
|
TextFontWeight,
|
|
} from "@/text/primitives";
|
|
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: TextAlign;
|
|
background: TextBackground;
|
|
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 / 15,
|
|
});
|
|
|
|
return {
|
|
block,
|
|
visualRect,
|
|
};
|
|
}
|
|
|
|
function resolveSubtitleStyle({
|
|
style,
|
|
}: {
|
|
style: SubtitleStyleOverrides | undefined;
|
|
}): {
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
color: string;
|
|
textAlign: TextAlign;
|
|
fontWeight: TextFontWeight;
|
|
fontStyle: TextFontStyle;
|
|
textDecoration: TextDecoration;
|
|
letterSpacing: number;
|
|
lineHeight: number;
|
|
background: TextBackground;
|
|
placement: NonNullable<SubtitleStyleOverrides["placement"]>;
|
|
} {
|
|
const fontSize =
|
|
style?.fontSizeRatioOfPlayHeight != null
|
|
? style.fontSizeRatioOfPlayHeight * FONT_SIZE_SCALE_REFERENCE
|
|
: (style?.fontSize ?? SUBTITLE_FONT_SIZE);
|
|
|
|
return {
|
|
fontFamily: style?.fontFamily ?? "Arial",
|
|
fontSize,
|
|
color: style?.color ?? "#ffffff",
|
|
textAlign: style?.textAlign ?? "center",
|
|
fontWeight: style?.fontWeight ?? "bold",
|
|
fontStyle: style?.fontStyle ?? "normal",
|
|
textDecoration: style?.textDecoration ?? "none",
|
|
letterSpacing: style?.letterSpacing ?? DEFAULTS.text.letterSpacing,
|
|
lineHeight: style?.lineHeight ?? DEFAULTS.text.lineHeight,
|
|
background: {
|
|
...DEFAULTS.text.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: 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}`,
|
|
duration: mediaTimeFromSeconds({ seconds: caption.duration }),
|
|
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
|
|
params: {
|
|
...DEFAULTS.text.element.params,
|
|
content,
|
|
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.enabled": style.background.enabled,
|
|
"background.color": style.background.color,
|
|
"background.cornerRadius":
|
|
style.background.cornerRadius ?? DEFAULTS.text.background.cornerRadius,
|
|
"background.paddingX":
|
|
style.background.paddingX ?? DEFAULTS.text.background.paddingX,
|
|
"background.paddingY":
|
|
style.background.paddingY ?? DEFAULTS.text.background.paddingY,
|
|
"background.offsetX":
|
|
style.background.offsetX ?? DEFAULTS.text.background.offsetX,
|
|
"background.offsetY":
|
|
style.background.offsetY ?? DEFAULTS.text.background.offsetY,
|
|
"transform.positionX": positionX,
|
|
"transform.positionY": positionY,
|
|
},
|
|
};
|
|
}
|