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:
parent
38b3afc90e
commit
bdfe172967
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
Loading…
Reference in New Issue