diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 1223efee..c4410a27 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -5,6 +5,12 @@ import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { + parseCaptionSidecarPayload, + type CaptionSidecarPayload, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; import { closeExportStream, isOwnedExportPath, @@ -246,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean { return candidate.startsWith(withSep); } -type CaptionSidecarCue = { - startMs: number; - endMs: number; - text: string; -}; - -type CaptionSidecarPayload = { - format: "srt" | "vtt" | "both"; - cues: CaptionSidecarCue[]; -}; - -function toSrtTimestamp(totalMs: number): string { - const ms = Math.max(0, Math.round(totalMs)); - const hours = Math.floor(ms / 3_600_000); - const minutes = Math.floor((ms % 3_600_000) / 60_000); - const seconds = Math.floor((ms % 60_000) / 1000); - const millis = ms % 1000; - return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; -} - -function toVttTimestamp(totalMs: number): string { - const ms = Math.max(0, Math.round(totalMs)); - const hours = Math.floor(ms / 3_600_000); - const minutes = Math.floor((ms % 3_600_000) / 60_000); - const seconds = Math.floor((ms % 60_000) / 1000); - const millis = ms % 1000; - return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; -} - -function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { - if (!Array.isArray(cues)) { - return []; - } - - return cues - .filter((cue): cue is CaptionSidecarCue => { - return ( - typeof cue === "object" && - cue !== null && - typeof cue.startMs === "number" && - typeof cue.endMs === "number" && - typeof cue.text === "string" && - Number.isFinite(cue.startMs) && - Number.isFinite(cue.endMs) && - cue.endMs > cue.startMs && - cue.text.trim().length > 0 - ); - }) - .map((cue) => ({ - startMs: cue.startMs, - endMs: cue.endMs, - text: cue.text.replace(/\r\n/g, "\n").trim(), - })); -} - -function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { - if (typeof payload !== "object" || payload === null) { - return null; - } - - const candidate = payload as { - format?: unknown; - cues?: unknown; - }; - - const format = - candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" - ? candidate.format - : null; - if (!format) { - return null; - } - - const cues = normalizeCaptionSidecarCues(candidate.cues); - if (cues.length === 0) { - return null; - } - - return { format, cues }; -} - -function serializeSrt(cues: CaptionSidecarCue[]): string { - return cues - .map((cue, index) => { - return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; - }) - .join("\n\n"); -} - -function serializeVtt(cues: CaptionSidecarCue[]): string { - const body = cues - .map((cue) => { - return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; - }) - .join("\n\n"); - return `WEBVTT\n\n${body}`; -} - -async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) { - if (!payload) { - return; - } - - const parsed = path.parse(videoPath); - const basePath = path.join(parsed.dir, parsed.name); - - if (payload.format === "srt" || payload.format === "both") { - await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); - } - - if (payload.format === "vtt" || payload.format === "both") { - await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); - } -} - export function registerExportHandlers() { ipcMain.handle( "native-video-export-start", @@ -987,13 +878,19 @@ export function registerExportHandlers() { } await fs.writeFile(result.filePath, Buffer.from(videoData)); - await writeCaptionSidecars(result.filePath, sidecarPayload); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); approveUserPath(result.filePath); return { success: true, path: result.filePath, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } catch (error) { console.error("Failed to save exported video:", error); @@ -1029,13 +926,19 @@ export function registerExportHandlers() { const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); await fs.writeFile(resolvedPath, Buffer.from(videoData)); - await writeCaptionSidecars(resolvedPath, sidecarPayload); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); approveUserPath(resolvedPath); return { success: true, path: resolvedPath, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), canceled: false, }; } catch (error) { @@ -1088,14 +991,20 @@ export function registerExportHandlers() { if (payload.outputPath) { const resolvedPath = path.resolve(payload.outputPath); await moveExportedTempFile(tempPath, resolvedPath); - await writeCaptionSidecars(resolvedPath, sidecarPayload); releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + resolvedPath, + sidecarPayload, + ); approveUserPath(resolvedPath); return { success: true, path: resolvedPath, canceled: false, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } @@ -1126,15 +1035,21 @@ export function registerExportHandlers() { } await moveExportedTempFile(tempPath, result.filePath); - await writeCaptionSidecars(result.filePath, sidecarPayload); releaseOwnedExportPath(tempPath); + const captionSidecarResult = await writeCaptionSidecarsBestEffort( + result.filePath, + sidecarPayload, + ); approveUserPath(result.filePath); return { success: true, path: result.filePath, canceled: false, - message: "Video exported successfully", + message: withCaptionSidecarMessage( + "Video exported successfully", + captionSidecarResult, + ), }; } catch (error) { console.error("Failed to finalize exported video:", error); diff --git a/electron/ipc/register/exportCaptionSidecars.test.ts b/electron/ipc/register/exportCaptionSidecars.test.ts new file mode 100644 index 00000000..6839e959 --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.test.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseCaptionSidecarPayload, + serializeSrt, + serializeVtt, + withCaptionSidecarMessage, + writeCaptionSidecarsBestEffort, +} from "./exportCaptionSidecars"; + +describe("exportCaptionSidecars", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("serializes SRT cues with stable numbering and timestamps", () => { + expect( + serializeSrt([ + { + startMs: 1234, + endMs: 5678, + text: "Hello\nworld", + }, + ]), + ).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld"); + }); + + it("serializes VTT cues with header and dot timestamps", () => { + expect( + serializeVtt([ + { + startMs: 0, + endMs: 2000, + text: "Caption", + }, + ]), + ).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption"); + }); + + it("drops malformed cues when parsing sidecar payloads", () => { + expect( + parseCaptionSidecarPayload({ + format: "both", + cues: [ + { startMs: 0, endMs: 1000, text: "ok" }, + { startMs: 2000, endMs: 1000, text: "bad range" }, + { startMs: 1000, endMs: 2000, text: " " }, + ], + }), + ).toEqual({ + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "ok" }], + }); + }); + + it("returns a warning result instead of throwing when sidecar writes fail", async () => { + const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full")); + + await expect( + writeCaptionSidecarsBestEffort("/tmp/export.mp4", { + format: "srt", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ + wroteAny: false, + error: "disk full", + }); + + expect(writeFileSpy).toHaveBeenCalledTimes(1); + }); + + it("writes requested caption sidecars when the filesystem succeeds", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-")); + const videoPath = path.join(tempDir, "clip.mp4"); + + try { + await expect( + writeCaptionSidecarsBestEffort(videoPath, { + format: "both", + cues: [{ startMs: 0, endMs: 1000, text: "Caption" }], + }), + ).resolves.toEqual({ wroteAny: true, error: null }); + + await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain( + "00:00:00,000 --> 00:00:01,000", + ); + await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain( + "WEBVTT", + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("appends a non-fatal caption warning only when sidecar writes fail", () => { + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: false, + error: "disk full", + }), + ).toBe("Video exported successfully Captions could not be saved alongside the video."); + + expect( + withCaptionSidecarMessage("Video exported successfully", { + wroteAny: true, + error: null, + }), + ).toBe("Video exported successfully"); + }); +}); \ No newline at end of file diff --git a/electron/ipc/register/exportCaptionSidecars.ts b/electron/ipc/register/exportCaptionSidecars.ts new file mode 100644 index 00000000..6711d3bb --- /dev/null +++ b/electron/ipc/register/exportCaptionSidecars.ts @@ -0,0 +1,157 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export type CaptionSidecarCue = { + startMs: number; + endMs: number; + text: string; +}; + +export type CaptionSidecarPayload = { + format: "srt" | "vtt" | "both"; + cues: CaptionSidecarCue[]; +}; + +export type CaptionSidecarWriteResult = { + wroteAny: boolean; + error: string | null; +}; + +function toSrtTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`; +} + +function toVttTimestamp(totalMs: number): string { + const ms = Math.max(0, Math.round(totalMs)); + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = Math.floor((ms % 60_000) / 1000); + const millis = ms % 1000; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] { + if (!Array.isArray(cues)) { + return []; + } + + return cues + .filter((cue): cue is CaptionSidecarCue => { + return ( + typeof cue === "object" && + cue !== null && + typeof cue.startMs === "number" && + typeof cue.endMs === "number" && + typeof cue.text === "string" && + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + cue.text.trim().length > 0 + ); + }) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text.replace(/\r\n/g, "\n").trim(), + })); +} + +export function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + + const candidate = payload as { + format?: unknown; + cues?: unknown; + }; + + const format = + candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both" + ? candidate.format + : null; + if (!format) { + return null; + } + + const cues = normalizeCaptionSidecarCues(candidate.cues); + if (cues.length === 0) { + return null; + } + + return { format, cues }; +} + +export function serializeSrt(cues: CaptionSidecarCue[]): string { + return cues + .map((cue, index) => { + return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); +} + +export function serializeVtt(cues: CaptionSidecarCue[]): string { + const body = cues + .map((cue) => { + return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`; + }) + .join("\n\n"); + return `WEBVTT\n\n${body}`; +} + +export async function writeCaptionSidecars( + videoPath: string, + payload: CaptionSidecarPayload | null, +) { + if (!payload) { + return; + } + + const parsed = path.parse(videoPath); + const basePath = path.join(parsed.dir, parsed.name); + + if (payload.format === "srt" || payload.format === "both") { + await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8"); + } + + if (payload.format === "vtt" || payload.format === "both") { + await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8"); + } +} + +export async function writeCaptionSidecarsBestEffort( + videoPath: string, + payload: CaptionSidecarPayload | null, +): Promise { + if (!payload) { + return { wroteAny: false, error: null }; + } + + try { + await writeCaptionSidecars(videoPath, payload); + return { wroteAny: true, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn("[export] Failed to write caption sidecars:", { + videoPath, + message, + }); + return { wroteAny: false, error: message }; + } +} + +export function withCaptionSidecarMessage( + baseMessage: string, + captionSidecarResult: CaptionSidecarWriteResult, +) { + if (!captionSidecarResult.error) { + return baseMessage; + } + + return `${baseMessage} Captions could not be saved alongside the video.`; +} \ No newline at end of file