Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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 @@ -672,6 +672,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();
});
});
217 changes: 217 additions & 0 deletions electron/ipc/captions/exportSubtitleFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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);
}

/**
* 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];
if (isFiniteNumber(primaryValue)) {
return Math.round(primaryValue);
}

const fallbackValue = cue[fallbackKey];
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[] = [];

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;
}

/**
* 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);
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(":");
}

/**
* 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) =>
[
String(index + 1),
`${formatTimestamp(cue.startMs, ",")} --> ${formatTimestamp(cue.endMs, ",")}`,
cue.text,
].join("\n"),
);

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) =>
[
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` : ""}`;
}

/**
* 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);
}

if (format === "vtt") {
return cuesToVtt(cues);
}

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}`;
}

const normalizedFileName = fileName.trim();
return normalizedFileName.toLowerCase().endsWith(`.${format}`)
? normalizedFileName
: `${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: {
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)

}
Loading
Loading