feat(captions): export subtitles as separate SRT/VTT file alongside video

This commit is contained in:
Matt Van Horn
2026-05-26 17:30:11 -07:00
parent 24e67790a1
commit c6ab201caf
7 changed files with 455 additions and 0 deletions
+11
View File
@@ -670,6 +670,17 @@ interface Window {
message?: string;
error?: string;
}>;
exportSubtitleFile: (options: {
format: "srt" | "vtt";
cues: AutoCaptionCue[];
fileName?: string;
}) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
canceled?: boolean;
}>;
setCurrentVideoPath: (
path: string,
options?: {
@@ -0,0 +1,136 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: () => process.env.TEMP ?? process.cwd(),
},
BrowserWindow: {
fromWebContents: () => null,
},
dialog: {
showSaveDialog: vi.fn(),
},
}));
vi.mock("../utils", () => ({
approveUserPath: vi.fn(),
}));
import { dialog } from "electron";
import {
cuesToSrt,
cuesToVtt,
exportSubtitleFile,
subtitleCuesToFile,
} from "./exportSubtitleFile";
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-subtitle-export-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
vi.restoreAllMocks();
await Promise.allSettled(
tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })),
);
});
describe("subtitle serializers", () => {
it("serializes SRT cues with numbered blocks and comma millisecond timestamps", () => {
expect(
cuesToSrt([
{ start: 0, end: 1500, text: "Hello" },
{ start: 1500, end: 3200, text: "World" },
]),
).toBe(
[
"1",
"00:00:00,000 --> 00:00:01,500",
"Hello",
"",
"2",
"00:00:01,500 --> 00:00:03,200",
"World",
"",
].join("\n"),
);
});
it("serializes VTT cues with a WEBVTT header and dot millisecond timestamps", () => {
expect(
cuesToVtt([
{ startMs: 0, endMs: 1500, text: "Hello" },
{ startMs: 1500, endMs: 3200, text: "World" },
]),
).toBe(
[
"WEBVTT",
"",
"1",
"00:00:00.000 --> 00:00:01.500",
"Hello",
"",
"2",
"00:00:01.500 --> 00:00:03.200",
"World",
"",
].join("\n"),
);
});
it("skips malformed cues without aborting serialization", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
expect(
subtitleCuesToFile("srt", [
{ startMs: 1000, endMs: 500, text: "bad" },
{ startMs: 1000, endMs: 1500, text: "good" },
]),
).toBe("1\n00:00:01,000 --> 00:00:01,500\ngood\n");
expect(warnSpy).toHaveBeenCalledWith(
"[subtitle-export] Skipping malformed caption cue:",
expect.objectContaining({ index: 0 }),
);
});
it("returns an empty SRT body and a header-only VTT body for empty cues", () => {
expect(cuesToSrt([])).toBe("");
expect(cuesToVtt([])).toBe("WEBVTT\n\n");
});
it("preserves multiline cue text literally", () => {
expect(cuesToSrt([{ startMs: 0, endMs: 1000, text: "Hello\nWorld" }])).toBe(
"1\n00:00:00,000 --> 00:00:01,000\nHello\nWorld\n",
);
});
});
describe("exportSubtitleFile", () => {
it("returns a user-readable error when the selected path cannot be written", async () => {
const dir = await makeTempDir();
vi.mocked(dialog.showSaveDialog).mockResolvedValue({
canceled: false,
filePath: path.join(dir, "missing", "captions.srt"),
});
const result = await exportSubtitleFile(
{ sender: {} } as Parameters<typeof exportSubtitleFile>[0],
{
format: "srt",
cues: [{ id: "caption-1", startMs: 0, endMs: 1000, text: "Hello" }],
fileName: "captions.srt",
},
);
expect(result.success).toBe(false);
expect(result.message).toContain("Failed to export subtitle file");
expect(result.error).toBeTruthy();
});
});
+183
View File
@@ -0,0 +1,183 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { IpcMainInvokeEvent, SaveDialogOptions } from "electron";
import { app, BrowserWindow, dialog } from "electron";
import type { CaptionCuePayload } from "../types";
import { approveUserPath } from "../utils";
export type SubtitleExportFormat = "srt" | "vtt";
type SubtitleCueInput = Partial<CaptionCuePayload> & {
start?: number;
end?: number;
};
type NormalizedSubtitleCue = {
startMs: number;
endMs: number;
text: string;
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function getCueTimeMs(cue: SubtitleCueInput, primaryKey: "startMs" | "endMs") {
const fallbackKey = primaryKey === "startMs" ? "start" : "end";
const primaryValue = cue[primaryKey];
if (isFiniteNumber(primaryValue)) {
return Math.round(primaryValue);
}
const fallbackValue = cue[fallbackKey];
return isFiniteNumber(fallbackValue) ? Math.round(fallbackValue) : null;
}
function normalizeSubtitleCues(cues: SubtitleCueInput[]) {
const normalizedCues: NormalizedSubtitleCue[] = [];
cues.forEach((cue, index) => {
const startMs = getCueTimeMs(cue, "startMs");
const endMs = getCueTimeMs(cue, "endMs");
const text = typeof cue.text === "string" ? cue.text.replace(/\r\n?/g, "\n") : "";
if (startMs == null || endMs == null || endMs <= startMs || text.trim().length === 0) {
console.warn("[subtitle-export] Skipping malformed caption cue:", {
index,
startMs,
endMs,
hasText: text.trim().length > 0,
});
return;
}
normalizedCues.push({ startMs, endMs, text });
});
return normalizedCues;
}
function formatTimestamp(ms: number, separator: "," | ".") {
const roundedMs = Math.max(0, Math.round(ms));
const hours = Math.floor(roundedMs / 3_600_000);
const minutes = Math.floor((roundedMs % 3_600_000) / 60_000);
const seconds = Math.floor((roundedMs % 60_000) / 1_000);
const milliseconds = roundedMs % 1_000;
return [
String(hours).padStart(2, "0"),
String(minutes).padStart(2, "0"),
`${String(seconds).padStart(2, "0")}${separator}${String(milliseconds).padStart(3, "0")}`,
].join(":");
}
export function cuesToSrt(cues: SubtitleCueInput[]) {
const blocks = normalizeSubtitleCues(cues).map((cue, index) =>
[
String(index + 1),
`${formatTimestamp(cue.startMs, ",")} --> ${formatTimestamp(cue.endMs, ",")}`,
cue.text,
].join("\n"),
);
return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : "";
}
export function cuesToVtt(cues: SubtitleCueInput[]) {
const blocks = normalizeSubtitleCues(cues).map((cue, index) =>
[
String(index + 1),
`${formatTimestamp(cue.startMs, ".")} --> ${formatTimestamp(cue.endMs, ".")}`,
cue.text,
].join("\n"),
);
return `WEBVTT\n\n${blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""}`;
}
export function subtitleCuesToFile(format: SubtitleExportFormat, cues: SubtitleCueInput[]) {
if (format === "srt") {
return cuesToSrt(cues);
}
if (format === "vtt") {
return cuesToVtt(cues);
}
throw new Error("Unsupported subtitle export format.");
}
function getSubtitleFilter(format: SubtitleExportFormat) {
return format === "srt"
? { name: "SubRip Subtitle", extensions: ["srt"] }
: { name: "WebVTT Subtitle", extensions: ["vtt"] };
}
function getSafeFileName(fileName: unknown, format: SubtitleExportFormat) {
if (typeof fileName !== "string" || fileName.trim().length === 0) {
return `captions.${format}`;
}
const normalizedFileName = fileName.trim();
return normalizedFileName.toLowerCase().endsWith(`.${format}`)
? normalizedFileName
: `${normalizedFileName}.${format}`;
}
export async function exportSubtitleFile(
event: IpcMainInvokeEvent,
options: {
cues?: SubtitleCueInput[];
format?: SubtitleExportFormat;
fileName?: string;
},
) {
try {
const format = options?.format;
if (format !== "srt" && format !== "vtt") {
throw new Error("Choose a subtitle format to export.");
}
if (!Array.isArray(options.cues)) {
throw new Error("Subtitle export requires caption cues.");
}
const fileName = getSafeFileName(options.fileName, format);
const saveDialogOptions: SaveDialogOptions = {
title: `Save ${format.toUpperCase()} Subtitle File`,
defaultPath: path.join(app.getPath("downloads"), fileName),
filters: [getSubtitleFilter(format)],
properties: ["createDirectory", "showOverwriteConfirmation"],
};
const parentWindow = BrowserWindow.fromWebContents(event.sender);
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, saveDialogOptions)
: await dialog.showSaveDialog(saveDialogOptions);
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Subtitle export canceled",
};
}
await fs.writeFile(result.filePath, subtitleCuesToFile(format, options.cues), "utf-8");
approveUserPath(result.filePath);
return {
success: true,
path: result.filePath,
message: "Subtitle file exported successfully",
};
} catch (error) {
console.error("Failed to export subtitle file:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
canceled: false,
message: `Failed to export subtitle file: ${errorMessage}`,
error: errorMessage,
};
}
}
+3
View File
@@ -7,6 +7,7 @@ import {
sendWhisperModelDownloadProgress,
} from "../captions/whisper";
import { generateAutoCaptionsFromVideo } from "../captions/generate";
import { exportSubtitleFile } from "../captions/exportSubtitleFile";
import { approveUserPath, getRecordingsDir } from "../utils";
export function registerCaptionHandlers() {
@@ -204,4 +205,6 @@ export function registerCaptionHandlers() {
}
})
ipcMain.handle('export-subtitle-file', exportSubtitleFile)
}
+18
View File
@@ -682,6 +682,24 @@ contextBridge.exposeInMainWorld("electronAPI", {
}) => {
return ipcRenderer.invoke("generate-auto-captions", options);
},
exportSubtitleFile: (options: {
format: "srt" | "vtt";
cues: Array<{
id: string;
startMs: number;
endMs: number;
text: string;
words?: Array<{
text: string;
startMs: number;
endMs: number;
leadingSpace?: boolean;
}>;
}>;
fileName?: string;
}) => {
return ipcRenderer.invoke("export-subtitle-file", options);
},
setCurrentVideoPath: (
path: string,
options?: {
+102
View File
@@ -78,6 +78,7 @@ import {
canUseInMemoryExportSaveFallback,
describeBlockedInMemoryExportSave,
} from "@/lib/exporter/exportSavePolicy";
import type { SubtitleExportFormat } from "@/lib/exporter/types";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import {
@@ -352,6 +353,20 @@ function getErrorMessage(error: unknown): string {
return "Something went wrong";
}
function getSubtitleExportFileName(
format: SubtitleExportFormat,
projectPath: string | null,
sourcePath: string | null,
) {
const sourceName =
(projectPath ?? sourcePath)
?.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") ?? "captions";
const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim();
return `${safeBaseName || "captions"}.${format}`;
}
export default function VideoEditor() {
const { t } = useI18n();
const smokeExportConfig = useMemo(
@@ -521,6 +536,7 @@ export default function VideoEditor() {
>(initialEditorPreferences.whisperModelPath ? "downloaded" : "idle");
const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0);
const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false);
const [isExportingSubtitleFile, setIsExportingSubtitleFile] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
@@ -2667,6 +2683,53 @@ export default function VideoEditor() {
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
}, []);
const handleExportSubtitleFile = useCallback(
async (format: SubtitleExportFormat) => {
if (isExportingSubtitleFile) {
return;
}
if (autoCaptions.length === 0) {
toast.error("Generate captions before exporting a subtitle file");
return;
}
setIsExportingSubtitleFile(true);
try {
const result = await window.electronAPI.exportSubtitleFile({
format,
cues: autoCaptions,
fileName: getSubtitleExportFileName(
format,
currentProjectPath,
currentSourcePath,
),
});
if (result.canceled) {
toast.info("Subtitle export canceled");
return;
}
if (!result.success || !result.path) {
toast.error(
result.message ||
getErrorMessage(result.error) ||
"Failed to export subtitle file",
);
return;
}
toast.success(`Subtitle file exported to ${result.path}`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsExportingSubtitleFile(false);
}
},
[autoCaptions, currentProjectPath, currentSourcePath, isExportingSubtitleFile],
);
const saveProject = useCallback(
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
clearPendingProjectAutosave();
@@ -5442,6 +5505,45 @@ export default function VideoEditor() {
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={autoCaptions.length === 0 || isExportingSubtitleFile}
className="inline-flex h-8 min-w-[108px] items-center justify-center gap-2 rounded-[5px] border-foreground/10 bg-foreground/5 px-3 text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45"
title={
autoCaptions.length > 0
? "Export subtitles"
: "Generate captions before exporting subtitles"
}
>
<PhCaptions className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
Subtitles
</span>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="border-foreground/10 bg-editor-surface-alt"
>
<DropdownMenuItem
onClick={() => void handleExportSubtitleFile("srt")}
className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
>
Export SRT
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleExportSubtitleFile("vtt")}
className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
>
Export VTT
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
+2
View File
@@ -179,6 +179,8 @@ export type ExportMp4FrameRate = 24 | 30 | 60;
// GIF Export Types
export type ExportFormat = "mp4" | "gif";
export type SubtitleExportFormat = "srt" | "vtt";
export type GifFrameRate = 15 | 20 | 25 | 30;
export type GifSizePreset = "medium" | "large" | "original";