Add .ass subtitle import support

Extend subtitle import to accept .ass files alongside .srt, map supported .ass styles onto imported text elements, and display warnings when unsupported .ass features are skipped.
This commit is contained in:
kcfancher 2026-04-11 06:21:07 -04:00
parent 38b3afc90e
commit bdfe172967
7 changed files with 786 additions and 59 deletions

View File

@ -42,7 +42,7 @@ export function Captions() {
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState("");
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editor = useEditor();
@ -59,7 +59,7 @@ export function Captions() {
try {
setIsProcessing(true);
setError(null);
setWarning(null);
setWarnings([]);
setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio({
@ -113,7 +113,7 @@ export function Captions() {
try {
setIsProcessing(true);
setError(null);
setWarning(null);
setWarnings([]);
setProcessingStep("Reading subtitle file...");
const input = await file.text();
@ -123,17 +123,23 @@ export function Captions() {
});
if (result.captions.length === 0) {
throw new Error("No valid subtitle cues were found in the .srt file");
throw new Error(
"No valid subtitle cues were found in the subtitle file",
);
}
setProcessingStep("Importing subtitles...");
insertCaptionChunks({ captions: result.captions });
const nextWarnings = [...result.warnings];
if (result.skippedCueCount > 0) {
setWarning(
nextWarnings.unshift(
`Imported ${result.captions.length} subtitle cue(s) and skipped ${result.skippedCueCount} malformed cue(s).`,
);
}
if (nextWarnings.length > 0) {
setWarnings(nextWarnings);
}
} catch (error) {
console.error("Subtitle import failed:", error);
setError(
@ -191,7 +197,7 @@ export function Captions() {
<input
ref={fileInputRef}
type="file"
accept=".srt"
accept=".srt,.ass"
className="hidden"
onChange={(event) => void handleFileChange({ event })}
/>
@ -224,6 +230,7 @@ export function Captions() {
</SectionFields>
<Button
type="button"
className="mt-auto w-full"
onClick={handleGenerateTranscript}
disabled={isProcessing}
@ -247,26 +254,31 @@ export function Captions() {
>
<SectionContent className="flex flex-col gap-4 h-full pt-1">
<p className="text-muted-foreground text-sm">
Import an existing <code>.srt</code> subtitle file into the
timeline.
Import an existing <code>.srt</code> or <code>.ass</code> subtitle
file into the timeline.
</p>
<Button
type="button"
variant="outline"
className="mt-auto w-full"
onClick={handleImportClick}
disabled={isProcessing}
>
{isProcessing && <Spinner className="mr-1" />}
{isProcessing ? processingStep : "Import .srt"}
{isProcessing ? processingStep : "Import subtitle file"}
</Button>
{error && (
<div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
{warning && (
{warnings.length > 0 && (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 p-3">
<p className="text-sm text-amber-700">{warning}</p>
<ul className="text-sm text-amber-700 space-y-1">
{warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</SectionContent>

View File

@ -0,0 +1,514 @@
import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
import type {
ParseSubtitleResult,
SubtitleCue,
SubtitleStyleOverrides,
} from "./types";
const ASS_DEFAULT_PLAY_RES_X = 384;
const ASS_DEFAULT_PLAY_RES_Y = 288;
const STYLE_SECTION_NAMES = new Set(["v4 styles", "v4+ styles"]);
const ALIGNMENT_MAP: Record<
number,
{
textAlign: NonNullable<SubtitleStyleOverrides["textAlign"]>;
verticalAlign: NonNullable<
NonNullable<SubtitleStyleOverrides["placement"]>["verticalAlign"]
>;
}
> = {
1: { textAlign: "left", verticalAlign: "bottom" },
2: { textAlign: "center", verticalAlign: "bottom" },
3: { textAlign: "right", verticalAlign: "bottom" },
4: { textAlign: "left", verticalAlign: "middle" },
5: { textAlign: "center", verticalAlign: "middle" },
6: { textAlign: "right", verticalAlign: "middle" },
7: { textAlign: "left", verticalAlign: "top" },
8: { textAlign: "center", verticalAlign: "top" },
9: { textAlign: "right", verticalAlign: "top" },
};
interface AssScriptInfo {
playResX: number;
playResY: number;
}
interface AssStyleRecord {
name: string;
fontname?: string;
fontsize?: string;
primarycolour?: string;
secondarycolour?: string;
outlinecolour?: string;
backcolour?: string;
bold?: string;
italic?: string;
underline?: string;
strikeout?: string;
scalex?: string;
scaley?: string;
spacing?: string;
angle?: string;
borderstyle?: string;
outline?: string;
shadow?: string;
alignment?: string;
marginl?: string;
marginr?: string;
marginv?: string;
}
export function parseAss({ input }: { input: string }): ParseSubtitleResult {
const normalized = input.replace(/\r\n?/g, "\n").trim();
if (!normalized) {
return {
captions: [],
skippedCueCount: 0,
warnings: [],
};
}
const scriptInfo: AssScriptInfo = {
playResX: ASS_DEFAULT_PLAY_RES_X,
playResY: ASS_DEFAULT_PLAY_RES_Y,
};
const warnings = new Set<string>();
const styles = new Map<string, SubtitleStyleOverrides>();
const captions: SubtitleCue[] = [];
let currentSection = "";
let styleFormat: string[] | null = null;
let eventFormat: string[] | null = null;
let skippedCueCount = 0;
let strippedInlineTagCueCount = 0;
let skippedNonDialogueEventCount = 0;
let ignoredEffectCount = 0;
let missingStyleCueCount = 0;
let usesHeavilyUnsupportedStyles = false;
for (const rawLine of normalized.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith(";")) {
continue;
}
const sectionMatch = line.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1].trim().toLowerCase();
continue;
}
if (currentSection === "script info") {
const [rawKey, ...rest] = line.split(":");
if (!rawKey || rest.length === 0) continue;
const key = rawKey.trim().toLowerCase();
const value = rest.join(":").trim();
const parsedValue = parseFloat(value);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
continue;
}
if (key === "playresx") {
scriptInfo.playResX = parsedValue;
}
if (key === "playresy") {
scriptInfo.playResY = parsedValue;
}
continue;
}
if (STYLE_SECTION_NAMES.has(currentSection)) {
if (line.startsWith("Format:")) {
styleFormat = parseAssFormat({ line });
continue;
}
if (line.startsWith("Style:") && styleFormat) {
const values = splitAssFields({
value: line.slice("Style:".length).trim(),
expectedFieldCount: styleFormat.length,
});
if (values.length !== styleFormat.length) {
continue;
}
const record = mapFieldsToRecord<AssStyleRecord>({
fields: styleFormat,
values,
});
if (!record.name) {
continue;
}
const mappedStyle = mapAssStyleToSubtitleStyle({
style: record,
scriptInfo,
});
usesHeavilyUnsupportedStyles ||= mappedStyle.hasUnsupportedFeatures;
styles.set(record.name.toLowerCase(), mappedStyle.style);
}
continue;
}
if (currentSection !== "events") {
continue;
}
if (line.startsWith("Format:")) {
eventFormat = parseAssFormat({ line });
continue;
}
if (!eventFormat) {
continue;
}
if (line.startsWith("Dialogue:")) {
const values = splitAssFields({
value: line.slice("Dialogue:".length).trim(),
expectedFieldCount: eventFormat.length,
});
if (values.length !== eventFormat.length) {
skippedCueCount += 1;
continue;
}
const record = mapFieldsToRecord<Record<string, string>>({
fields: eventFormat,
values,
});
const startTime = parseAssTimestamp({ input: record.start });
const endTime = parseAssTimestamp({ input: record.end });
const duration = endTime - startTime;
if (
!Number.isFinite(startTime) ||
!Number.isFinite(endTime) ||
duration <= 0
) {
skippedCueCount += 1;
continue;
}
const strippedText = stripAssText({
input: record.text ?? "",
});
strippedInlineTagCueCount += strippedText.hadInlineTags ? 1 : 0;
if (!strippedText.text) {
skippedCueCount += 1;
continue;
}
if (record.effect?.trim()) {
ignoredEffectCount += 1;
}
const referencedStyleName = record.style?.trim().toLowerCase();
const referencedStyle = referencedStyleName
? styles.get(referencedStyleName)
: undefined;
if (referencedStyleName && !referencedStyle) {
missingStyleCueCount += 1;
}
const style = referencedStyle ?? styles.get("default");
captions.push({
text: strippedText.text,
startTime,
duration,
style,
});
continue;
}
if (line.includes(":")) {
skippedNonDialogueEventCount += 1;
}
}
if (strippedInlineTagCueCount > 0) {
warnings.add(
`Stripped unsupported ASS inline override tags from ${strippedInlineTagCueCount} subtitle cue(s).`,
);
}
if (ignoredEffectCount > 0) {
warnings.add(
`Ignored ASS event effects in ${ignoredEffectCount} subtitle cue(s).`,
);
}
if (missingStyleCueCount > 0) {
warnings.add(
`Fell back to default subtitle styling for ${missingStyleCueCount} cue(s) that referenced missing ASS styles.`,
);
}
if (skippedNonDialogueEventCount > 0) {
warnings.add(
`Ignored ${skippedNonDialogueEventCount} non-dialogue ASS event(s).`,
);
}
if (usesHeavilyUnsupportedStyles) {
warnings.add(
"Ignored unsupported ASS style features such as outline, shadow, rotation, or scaling.",
);
}
return {
captions,
skippedCueCount,
warnings: [...warnings],
};
}
function parseAssFormat({ line }: { line: string }): string[] {
return line
.slice(line.indexOf(":") + 1)
.split(",")
.map((field) => field.trim().toLowerCase());
}
function splitAssFields({
value,
expectedFieldCount,
}: {
value: string;
expectedFieldCount: number;
}): string[] {
if (expectedFieldCount <= 1) {
return [value];
}
const result: string[] = [];
let current = "";
for (const character of value) {
if (character === "," && result.length < expectedFieldCount - 1) {
result.push(current.trim());
current = "";
continue;
}
current += character;
}
result.push(current.trim());
return result;
}
function mapFieldsToRecord<T extends object>({
fields,
values,
}: {
fields: string[];
values: string[];
}): T {
const record = {} as Record<string, string | undefined>;
for (let index = 0; index < fields.length; index++) {
record[fields[index]] = values[index];
}
return record as T;
}
function parseAssTimestamp({ input }: { input: string | undefined }): number {
if (!input) {
return Number.NaN;
}
const match = input.trim().match(/^(\d+):(\d{2}):(\d{2})[.](\d{1,2}|\d{3})$/);
if (!match) {
return Number.NaN;
}
const [, hours, minutes, seconds, fraction] = match;
const parsedHours = Number.parseInt(hours, 10);
const parsedMinutes = Number.parseInt(minutes, 10);
const parsedSeconds = Number.parseInt(seconds, 10);
const parsedMilliseconds = Number.parseInt(fraction.padEnd(3, "0"), 10);
return (
parsedHours * 3600 +
parsedMinutes * 60 +
parsedSeconds +
parsedMilliseconds / 1000
);
}
function stripAssText({ input }: { input: string }): {
text: string;
hadInlineTags: boolean;
} {
const hadInlineTags = /\{[^}]*\}/.test(input);
const text = input
.replace(/\{[^}]*\}/g, "")
.replace(/\\N/gi, "\n")
.replace(/\\h/g, " ")
.replace(/\\n/g, "\n")
.trim();
return {
text,
hadInlineTags,
};
}
function mapAssStyleToSubtitleStyle({
style,
scriptInfo,
}: {
style: AssStyleRecord;
scriptInfo: AssScriptInfo;
}): {
style: SubtitleStyleOverrides;
hasUnsupportedFeatures: boolean;
} {
const fontSize = parseFloat(style.fontsize ?? "");
const fontSizeRatio = FONT_SIZE_SCALE_REFERENCE / scriptInfo.playResY;
const primaryColor = parseAssColor({ input: style.primarycolour });
const backColor = parseAssColor({ input: style.backcolour });
const bold = parseAssBoolean({ input: style.bold });
const italic = parseAssBoolean({ input: style.italic });
const underline = parseAssBoolean({ input: style.underline });
const strikeOut = parseAssBoolean({ input: style.strikeout });
const borderStyle = parseFloat(style.borderstyle ?? "");
const spacing = parseFloat(style.spacing ?? "");
const alignment = parseFloat(style.alignment ?? "");
const marginLeft = parseFloat(style.marginl ?? "");
const marginRight = parseFloat(style.marginr ?? "");
const marginVertical = parseFloat(style.marginv ?? "");
const mappedFontSize = Number.isFinite(fontSize)
? Math.round(fontSize * fontSizeRatio * 1000) / 1000
: Number.NaN;
const mappedAlignment =
ALIGNMENT_MAP[Math.round(alignment)] ?? ALIGNMENT_MAP[2];
const placement =
Number.isFinite(marginLeft) ||
Number.isFinite(marginRight) ||
Number.isFinite(marginVertical) ||
mappedAlignment.verticalAlign !== "bottom"
? {
verticalAlign: mappedAlignment.verticalAlign,
marginLeftRatio: Number.isFinite(marginLeft)
? marginLeft / scriptInfo.playResX
: undefined,
marginRightRatio: Number.isFinite(marginRight)
? marginRight / scriptInfo.playResX
: undefined,
marginVerticalRatio: Number.isFinite(marginVertical)
? marginVertical / scriptInfo.playResY
: undefined,
}
: undefined;
const styleOverrides: SubtitleStyleOverrides = {
...(style.fontname ? { fontFamily: style.fontname.trim() } : {}),
...(Number.isFinite(mappedFontSize) && mappedFontSize > 0
? { fontSize: mappedFontSize }
: {}),
...(primaryColor?.cssColor ? { color: primaryColor.cssColor } : {}),
...(bold !== null ? { fontWeight: bold ? "bold" : "normal" } : {}),
...(italic !== null ? { fontStyle: italic ? "italic" : "normal" } : {}),
...(underline || strikeOut
? {
textDecoration: underline
? "underline"
: strikeOut
? "line-through"
: "none",
}
: {}),
...(Number.isFinite(spacing) ? { letterSpacing: spacing } : {}),
textAlign: mappedAlignment.textAlign,
...(placement ? { placement } : {}),
...(backColor?.cssColor && Math.round(borderStyle) === 3
? {
background: {
enabled: backColor.alpha > 0,
color: backColor.alpha > 0 ? backColor.cssColor : "transparent",
},
}
: {}),
};
const hasUnsupportedFeatures =
Math.round(borderStyle) !== 1 && Math.round(borderStyle) !== 3
? true
: (parseFloat(style.outline ?? "") || 0) > 0 ||
(parseFloat(style.shadow ?? "") || 0) > 0 ||
(parseFloat(style.angle ?? "") || 0) !== 0 ||
(Number.isFinite(parseFloat(style.scalex ?? "")) &&
parseFloat(style.scalex ?? "") !== 100) ||
(Number.isFinite(parseFloat(style.scaley ?? "")) &&
parseFloat(style.scaley ?? "") !== 100) ||
Boolean(underline && strikeOut);
return {
style: styleOverrides,
hasUnsupportedFeatures,
};
}
function parseAssBoolean({
input,
}: {
input: string | undefined;
}): boolean | null {
if (input === undefined) {
return null;
}
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed !== 0;
}
function parseAssColor({
input,
}: {
input: string | undefined;
}): { cssColor: string; alpha: number } | null {
if (!input) {
return null;
}
const normalized = input.trim().replace(/^&?H/i, "").padStart(8, "0");
if (!/^[0-9a-fA-F]{8}$/.test(normalized)) {
return null;
}
const alphaHex = normalized.slice(0, 2);
const blueHex = normalized.slice(2, 4);
const greenHex = normalized.slice(4, 6);
const redHex = normalized.slice(6, 8);
const red = Number.parseInt(redHex, 16);
const green = Number.parseInt(greenHex, 16);
const blue = Number.parseInt(blueHex, 16);
const alpha = 1 - Number.parseInt(alphaHex, 16) / 255;
if (alpha >= 1) {
return {
cssColor: `#${redHex}${greenHex}${blueHex}`.toLowerCase(),
alpha,
};
}
return {
cssColor: `rgba(${red}, ${green}, ${blue}, ${Math.round(alpha * 1000) / 1000})`,
alpha,
};
}

View File

@ -1,8 +1,12 @@
import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
import { measureTextBlock } from "@/lib/text/layout";
import {
getTextVisualRect,
measureTextBlock,
setCanvasLetterSpacing,
} from "@/lib/text/layout";
import { DEFAULTS } from "@/lib/timeline/defaults";
import type { CreateTextElement } from "@/lib/timeline";
import type { CaptionChunk } from "@/lib/transcription/types";
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
const SUBTITLE_MAX_WIDTH_RATIO = 0.8;
const SUBTITLE_BOTTOM_MARGIN_RATIO = 0.05;
@ -75,22 +79,165 @@ 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 =
SUBTITLE_FONT_SIZE * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const lineHeight = (DEFAULTS.text.lineHeight ?? 1.2) * scaledFontSize;
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));
return measureTextBlock({
const block = measureTextBlock({
lineMetrics,
lineHeightPx: lineHeight,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
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"]>;
} {
return {
fontFamily: style?.fontFamily ?? DEFAULTS.text.element.fontFamily,
fontSize: style?.fontSize ?? SUBTITLE_FONT_SIZE,
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({
@ -99,58 +246,80 @@ export function buildSubtitleTextElement({
canvasSize,
}: {
index: number;
caption: CaptionChunk;
caption: SubtitleCue;
canvasSize: { width: number; height: number };
}): CreateTextElement {
const ctx = createMeasurementContext();
const fontFamily = quoteFontFamily({
fontFamily: DEFAULTS.text.element.fontFamily,
const style = resolveSubtitleStyle({
style: caption.style,
});
const fontWeight = "bold";
const fontStyle =
DEFAULTS.text.element.fontStyle === "italic" ? "italic" : "normal";
const fontFamily = quoteFontFamily({
fontFamily: style.fontFamily,
});
const fontWeight = style.fontWeight;
const fontStyle = style.fontStyle === "italic" ? "italic" : "normal";
const scaledFontSize =
SUBTITLE_FONT_SIZE * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
style.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE);
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
const maxWidth = canvasSize.width * SUBTITLE_MAX_WIDTH_RATIO;
const maxWidth = resolveTargetWidth({
canvasWidth: canvasSize.width,
placement: style.placement,
});
let content = caption.text;
let blockHeight = scaledFontSize;
let positionX = 0;
let positionY = 0;
if (ctx) {
ctx.font = fontString;
setCanvasLetterSpacing({ ctx, letterSpacingPx: style.letterSpacing });
content = wrapSubtitleText({
ctx,
text: caption.text,
maxWidth,
});
blockHeight = measureWrappedTextBlock({
const measurement = measureWrappedTextBlock({
ctx,
content,
canvasHeight: canvasSize.height,
}).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,
});
}
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,
},
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: 0,
x: positionX,
y: positionY,
},
},

View File

@ -1,18 +1,19 @@
import type { EditorCore } from "@/core";
import type { CaptionChunk } from "@/lib/transcription/types";
import type { Command } from "@/lib/commands";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
import { buildSubtitleTextElement } from "./build-subtitle-text-element";
import type { SubtitleCue } from "./types";
export function insertCaptionChunksAsTextTrack({
editor,
captions,
}: {
editor: EditorCore;
captions: CaptionChunk[];
captions: SubtitleCue[];
}): string | null {
if (captions.length === 0) {
return null;
@ -20,22 +21,19 @@ 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(
const insertCommands = captions.map(
(caption, index) =>
new InsertElementCommand({
placement: { mode: "explicit", trackId },
element: buildSubtitleTextElement({
index: i,
index,
caption,
canvasSize,
}),
}),
);
}
);
const commands = [addTrackCommand, ...insertCommands] as unknown as Command[];
editor.command.execute({
command: new BatchCommand(commands),

View File

@ -1,10 +1,7 @@
import type { CaptionChunk } from "@/lib/transcription/types";
import { parseAss } from "./ass";
import { parseSrt } from "./srt";
export interface ParseSubtitleResult {
captions: CaptionChunk[];
skippedCueCount: number;
}
import type { ParseSubtitleResult } from "./types";
export type { ParseSubtitleResult, SubtitleCue } from "./types";
export function parseSubtitleFile({
fileName,
@ -18,6 +15,8 @@ export function parseSubtitleFile({
switch (extension) {
case "srt":
return parseSrt({ input });
case "ass":
return parseAss({ input });
default:
throw new Error("Unsupported subtitle format");
}

View File

@ -1,5 +1,4 @@
import type { CaptionChunk } from "@/lib/transcription/types";
import type { ParseSubtitleResult } from "./parse";
import type { ParseSubtitleResult, SubtitleCue } from "./types";
const TIMESTAMP_SEPARATOR = /\s*-->\s*/;
const TIMESTAMP_PATTERN =
@ -11,11 +10,12 @@ export function parseSrt({ input }: { input: string }): ParseSubtitleResult {
return {
captions: [],
skippedCueCount: 0,
warnings: [],
};
}
const blocks = normalized.split(/\n{2,}/);
const cues: CaptionChunk[] = [];
const cues: SubtitleCue[] = [];
let skippedCueCount = 0;
for (const block of blocks) {
@ -72,6 +72,7 @@ export function parseSrt({ input }: { input: string }): ParseSubtitleResult {
return {
captions: cues,
skippedCueCount,
warnings: [],
};
}

View File

@ -0,0 +1,34 @@
import type { TextBackground, TextElement } from "@/lib/timeline";
import type { CaptionChunk } from "@/lib/transcription/types";
export interface SubtitlePlacementStyle {
verticalAlign?: "top" | "middle" | "bottom";
marginLeftRatio?: number;
marginRightRatio?: number;
marginVerticalRatio?: number;
}
export interface SubtitleStyleOverrides {
fontSize?: number;
fontFamily?: string;
color?: string;
background?: Pick<TextBackground, "enabled" | "color"> &
Partial<Omit<TextBackground, "enabled" | "color">>;
textAlign?: TextElement["textAlign"];
fontWeight?: TextElement["fontWeight"];
fontStyle?: TextElement["fontStyle"];
textDecoration?: TextElement["textDecoration"];
letterSpacing?: number;
lineHeight?: number;
placement?: SubtitlePlacementStyle;
}
export interface SubtitleCue extends CaptionChunk {
style?: SubtitleStyleOverrides;
}
export interface ParseSubtitleResult {
captions: SubtitleCue[];
skippedCueCount: number;
warnings: string[];
}