From c6ab201caf5753df0bf3b84ee2cebeb1d4bbe9cb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 26 May 2026 17:30:11 -0700 Subject: [PATCH 1/2] feat(captions): export subtitles as separate SRT/VTT file alongside video --- electron/electron-env.d.ts | 11 ++ .../ipc/captions/exportSubtitleFile.test.ts | 136 +++++++++++++ electron/ipc/captions/exportSubtitleFile.ts | 183 ++++++++++++++++++ electron/ipc/register/captions.ts | 3 + electron/preload.ts | 18 ++ src/components/video-editor/VideoEditor.tsx | 102 ++++++++++ src/lib/exporter/types.ts | 2 + 7 files changed, 455 insertions(+) create mode 100644 electron/ipc/captions/exportSubtitleFile.test.ts create mode 100644 electron/ipc/captions/exportSubtitleFile.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 32ed313e0..0f30a2d21 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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?: { diff --git a/electron/ipc/captions/exportSubtitleFile.test.ts b/electron/ipc/captions/exportSubtitleFile.test.ts new file mode 100644 index 000000000..9ab8d05e3 --- /dev/null +++ b/electron/ipc/captions/exportSubtitleFile.test.ts @@ -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[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(); + }); +}); diff --git a/electron/ipc/captions/exportSubtitleFile.ts b/electron/ipc/captions/exportSubtitleFile.ts new file mode 100644 index 000000000..de15cdf02 --- /dev/null +++ b/electron/ipc/captions/exportSubtitleFile.ts @@ -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 & { + 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, + }; + } +} diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 9d9b7f060..0482f5513 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -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) + } diff --git a/electron/preload.ts b/electron/preload.ts index 932db07b0..abac3d103 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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?: { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4050189e8..8a1268907 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(null); const [exportError, setExportError] = useState(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() { )} + + + + + + void handleExportSubtitleFile("srt")} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + Export SRT + + void handleExportSubtitleFile("vtt")} + className="cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + Export VTT + + + diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 72682f011..ef29005e0 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -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"; From 69fb2a8d89531af0a03dea52b4bba96053f9b253 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 26 May 2026 17:51:13 -0700 Subject: [PATCH 2/2] docs: add JSDoc to subtitle export functions Addresses CodeRabbit pre-merge docstring coverage warning. --- electron/ipc/captions/exportSubtitleFile.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/electron/ipc/captions/exportSubtitleFile.ts b/electron/ipc/captions/exportSubtitleFile.ts index de15cdf02..09c1cfa1a 100644 --- a/electron/ipc/captions/exportSubtitleFile.ts +++ b/electron/ipc/captions/exportSubtitleFile.ts @@ -22,6 +22,10 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +/** + * Reads the preferred cue timestamp, falling back to legacy start/end fields. + * Returns rounded milliseconds, or null when neither value is usable. + */ function getCueTimeMs(cue: SubtitleCueInput, primaryKey: "startMs" | "endMs") { const fallbackKey = primaryKey === "startMs" ? "start" : "end"; const primaryValue = cue[primaryKey]; @@ -33,6 +37,10 @@ function getCueTimeMs(cue: SubtitleCueInput, primaryKey: "startMs" | "endMs") { return isFiniteNumber(fallbackValue) ? Math.round(fallbackValue) : null; } +/** + * Converts raw caption cues into export-ready cues with valid timing and text. + * Malformed cues are skipped so one bad cue does not fail the whole export. + */ function normalizeSubtitleCues(cues: SubtitleCueInput[]) { const normalizedCues: NormalizedSubtitleCue[] = []; @@ -57,6 +65,10 @@ function normalizeSubtitleCues(cues: SubtitleCueInput[]) { return normalizedCues; } +/** + * Formats milliseconds as a subtitle timestamp using the format delimiter. + * SRT uses commas for milliseconds, while WebVTT uses periods. + */ function formatTimestamp(ms: number, separator: "," | ".") { const roundedMs = Math.max(0, Math.round(ms)); const hours = Math.floor(roundedMs / 3_600_000); @@ -71,6 +83,10 @@ function formatTimestamp(ms: number, separator: "," | ".") { ].join(":"); } +/** + * Converts caption cues into SubRip content. + * Returns an empty string when no valid cues are available. + */ export function cuesToSrt(cues: SubtitleCueInput[]) { const blocks = normalizeSubtitleCues(cues).map((cue, index) => [ @@ -83,6 +99,10 @@ export function cuesToSrt(cues: SubtitleCueInput[]) { return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""; } +/** + * Converts caption cues into WebVTT content. + * Always includes the required WEBVTT header. + */ export function cuesToVtt(cues: SubtitleCueInput[]) { const blocks = normalizeSubtitleCues(cues).map((cue, index) => [ @@ -95,6 +115,10 @@ export function cuesToVtt(cues: SubtitleCueInput[]) { return `WEBVTT\n\n${blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""}`; } +/** + * Converts caption cues to the requested subtitle file format. + * Throws when the format is not supported. + */ export function subtitleCuesToFile(format: SubtitleExportFormat, cues: SubtitleCueInput[]) { if (format === "srt") { return cuesToSrt(cues); @@ -107,12 +131,18 @@ export function subtitleCuesToFile(format: SubtitleExportFormat, cues: SubtitleC throw new Error("Unsupported subtitle export format."); } +/** + * Builds the save dialog filter for the selected subtitle format. + */ function getSubtitleFilter(format: SubtitleExportFormat) { return format === "srt" ? { name: "SubRip Subtitle", extensions: ["srt"] } : { name: "WebVTT Subtitle", extensions: ["vtt"] }; } +/** + * Normalizes the requested download name and ensures it has the format extension. + */ function getSafeFileName(fileName: unknown, format: SubtitleExportFormat) { if (typeof fileName !== "string" || fileName.trim().length === 0) { return `captions.${format}`; @@ -124,6 +154,10 @@ function getSafeFileName(fileName: unknown, format: SubtitleExportFormat) { : `${normalizedFileName}.${format}`; } +/** + * Handles the IPC request for exporting captions to a subtitle file. + * Opens a native save dialog, writes the selected format, and returns export status. + */ export async function exportSubtitleFile( event: IpcMainInvokeEvent, options: {