Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
136 changes: 136 additions & 0 deletions electron/ipc/captions/exportSubtitleFile.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 183 additions & 0 deletions electron/ipc/captions/exportSubtitleFile.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 changes: 3 additions & 0 deletions electron/ipc/register/captions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -204,4 +205,6 @@ export function registerCaptionHandlers() {
}
})

ipcMain.handle('export-subtitle-file', exportSubtitleFile)

}
18 changes: 18 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
Loading