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..8519e93b --- /dev/null +++ b/apps/web/src/lib/subtitles/build-subtitle-text-element.ts @@ -0,0 +1,158 @@ +import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants"; +import { measureTextBlock } from "@/lib/text/layout"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { CreateTextElement } from "@/lib/timeline"; +import type { CaptionChunk } from "@/lib/transcription/types"; + +const SUBTITLE_MAX_WIDTH_RATIO = 0.8; +const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05; +const SUBTITLE_FONT_SIZE = 5; + +function quoteFontFamily({ fontFamily }: { fontFamily: string }): string { + return `"${fontFamily.replace(/"/g, '\\"')}"`; +} + +function createMeasurementContext(): CanvasRenderingContext2D | null { + const canvas = document.createElement("canvas"); + canvas.width = 4096; + canvas.height = 4096; + 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, +}: { + ctx: CanvasRenderingContext2D; + content: string; + canvasHeight: number; +}) { + const scaledFontSize = + SUBTITLE_FONT_SIZE * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); + const lineHeight = (DEFAULTS.text.lineHeight ?? 1.2) * scaledFontSize; + const lines = content.split("\n"); + const lineMetrics = lines.map((line) => ctx.measureText(line)); + + return measureTextBlock({ + lineMetrics, + lineHeightPx: lineHeight, + fallbackFontSize: scaledFontSize, + }); +} + +export function buildSubtitleTextElement({ + index, + caption, + canvasSize, +}: { + index: number; + caption: CaptionChunk; + canvasSize: { width: number; height: number }; +}): CreateTextElement { + const ctx = createMeasurementContext(); + const fontFamily = quoteFontFamily({ + fontFamily: DEFAULTS.text.element.fontFamily, + }); + const fontWeight = "bold"; + const fontStyle = + DEFAULTS.text.element.fontStyle === "italic" ? "italic" : "normal"; + const scaledFontSize = + SUBTITLE_FONT_SIZE * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); + const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; + const maxWidth = canvasSize.width * SUBTITLE_MAX_WIDTH_RATIO; + + let content = caption.text; + let blockHeight = scaledFontSize; + + if (ctx) { + ctx.font = fontString; + content = wrapSubtitleText({ + ctx, + text: caption.text, + maxWidth, + }); + blockHeight = measureWrappedTextBlock({ + ctx, + content, + canvasHeight: canvasSize.height, + }).height; + } + + const bottomMargin = canvasSize.height * SUBTITLE_BOTTOM_MARGIN_RATIO; + const centerY = canvasSize.height - bottomMargin - blockHeight / 2; + const positionY = centerY - canvasSize.height / 2; + + return { + ...DEFAULTS.text.element, + name: `Caption ${index + 1}`, + content, + duration: caption.duration, + startTime: caption.startTime, + fontSize: SUBTITLE_FONT_SIZE, + fontWeight, + background: { + ...DEFAULTS.text.element.background, + enabled: false, + }, + transform: { + ...DEFAULTS.text.element.transform, + position: { + x: 0, + y: positionY, + }, + }, + }; +} diff --git a/apps/web/src/lib/subtitles/insert.ts b/apps/web/src/lib/subtitles/insert.ts index 5d943fc9..34425574 100644 --- a/apps/web/src/lib/subtitles/insert.ts +++ b/apps/web/src/lib/subtitles/insert.ts @@ -1,11 +1,11 @@ import type { EditorCore } from "@/core"; -import { DEFAULTS } from "@/lib/timeline/defaults"; import type { CaptionChunk } from "@/lib/transcription/types"; import { AddTrackCommand, BatchCommand, InsertElementCommand, } from "@/lib/commands"; +import { buildSubtitleTextElement } from "./build-subtitle-text-element"; export function insertCaptionChunksAsTextTrack({ editor, @@ -21,21 +21,18 @@ export function insertCaptionChunksAsTextTrack({ const addTrackCommand = new AddTrackCommand("text", 0); const trackId = addTrackCommand.getTrackId(); const commands = [addTrackCommand]; + const canvasSize = editor.project.getActive().settings.canvasSize; for (let i = 0; i < captions.length; i++) { const caption = captions[i]; commands.push( new InsertElementCommand({ placement: { mode: "explicit", trackId }, - element: { - ...DEFAULTS.text.element, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, + element: buildSubtitleTextElement({ + index: i, + caption, + canvasSize, + }), }), ); }