mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
feat(captions): export subtitles as separate SRT/VTT file alongside video
This commit is contained in:
Vendored
+11
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user