Apply better default styling for subtitles.

[Modified from b5deec4285f99873ba5bf57971f7bcab26e8c743, 2026-03-31 11:49:34 -0400]

Shrink font size, aim to layout 5% above bottom, keep subtitle width 80% of video, wrap text on new lines if too long.
This commit is contained in:
kcfancher 2026-04-02 06:20:34 -04:00
parent 7e3ebef125
commit 5ca08166dd
2 changed files with 165 additions and 10 deletions

View File

@ -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,
},
},
};
}

View File

@ -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,
}),
}),
);
}