From b5a01a3cec7533686ce5611deac0fc35bfdbc7d8 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 30 May 2026 19:49:00 +1000 Subject: [PATCH] Remove subtitle export changes from PR --- electron/electron-env.d.ts | 11 - .../ipc/captions/exportSubtitleFile.test.ts | 136 ----------- electron/ipc/captions/exportSubtitleFile.ts | 217 ------------------ 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, 489 deletions(-) delete mode 100644 electron/ipc/captions/exportSubtitleFile.test.ts delete mode 100644 electron/ipc/captions/exportSubtitleFile.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index bd7ae46b..c0cf0cdb 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -672,17 +672,6 @@ 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 deleted file mode 100644 index 9ab8d05e..00000000 --- a/electron/ipc/captions/exportSubtitleFile.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 09c1cfa1..00000000 --- a/electron/ipc/captions/exportSubtitleFile.ts +++ /dev/null @@ -1,217 +0,0 @@ -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); -} - -/** - * 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, - }; - } -} diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 0482f551..9d9b7f06 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -7,7 +7,6 @@ import { sendWhisperModelDownloadProgress, } from "../captions/whisper"; import { generateAutoCaptionsFromVideo } from "../captions/generate"; -import { exportSubtitleFile } from "../captions/exportSubtitleFile"; import { approveUserPath, getRecordingsDir } from "../utils"; export function registerCaptionHandlers() { @@ -205,6 +204,4 @@ export function registerCaptionHandlers() { } }) - ipcMain.handle('export-subtitle-file', exportSubtitleFile) - } diff --git a/electron/preload.ts b/electron/preload.ts index af88cde1..384682a1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -688,24 +688,6 @@ 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 fefb9de4..12908a6d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -77,7 +77,6 @@ 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,20 +351,6 @@ 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( @@ -550,7 +535,6 @@ 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); @@ -2761,53 +2745,6 @@ 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(); @@ -5748,45 +5685,6 @@ 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 ef29005e..72682f01 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -179,8 +179,6 @@ 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";