Skip to content

Commit 8c7d23d

Browse files
committed
Handle caption sidecar save failures
1 parent 52dd842 commit 8c7d23d

3 files changed

Lines changed: 307 additions & 123 deletions

File tree

electron/ipc/register/export.ts

Lines changed: 38 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import path from "node:path";
55
import type { Readable, Writable } from "node:stream";
66
import type { SaveDialogOptions } from "electron";
77
import { app, BrowserWindow, dialog, ipcMain } from "electron";
8+
import {
9+
parseCaptionSidecarPayload,
10+
type CaptionSidecarPayload,
11+
withCaptionSidecarMessage,
12+
writeCaptionSidecarsBestEffort,
13+
} from "./exportCaptionSidecars";
814
import {
915
closeExportStream,
1016
isOwnedExportPath,
@@ -246,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean {
246252
return candidate.startsWith(withSep);
247253
}
248254

249-
type CaptionSidecarCue = {
250-
startMs: number;
251-
endMs: number;
252-
text: string;
253-
};
254-
255-
type CaptionSidecarPayload = {
256-
format: "srt" | "vtt" | "both";
257-
cues: CaptionSidecarCue[];
258-
};
259-
260-
function toSrtTimestamp(totalMs: number): string {
261-
const ms = Math.max(0, Math.round(totalMs));
262-
const hours = Math.floor(ms / 3_600_000);
263-
const minutes = Math.floor((ms % 3_600_000) / 60_000);
264-
const seconds = Math.floor((ms % 60_000) / 1000);
265-
const millis = ms % 1000;
266-
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
267-
}
268-
269-
function toVttTimestamp(totalMs: number): string {
270-
const ms = Math.max(0, Math.round(totalMs));
271-
const hours = Math.floor(ms / 3_600_000);
272-
const minutes = Math.floor((ms % 3_600_000) / 60_000);
273-
const seconds = Math.floor((ms % 60_000) / 1000);
274-
const millis = ms % 1000;
275-
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
276-
}
277-
278-
function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
279-
if (!Array.isArray(cues)) {
280-
return [];
281-
}
282-
283-
return cues
284-
.filter((cue): cue is CaptionSidecarCue => {
285-
return (
286-
typeof cue === "object" &&
287-
cue !== null &&
288-
typeof cue.startMs === "number" &&
289-
typeof cue.endMs === "number" &&
290-
typeof cue.text === "string" &&
291-
Number.isFinite(cue.startMs) &&
292-
Number.isFinite(cue.endMs) &&
293-
cue.endMs > cue.startMs &&
294-
cue.text.trim().length > 0
295-
);
296-
})
297-
.map((cue) => ({
298-
startMs: cue.startMs,
299-
endMs: cue.endMs,
300-
text: cue.text.replace(/\r\n/g, "\n").trim(),
301-
}));
302-
}
303-
304-
function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
305-
if (typeof payload !== "object" || payload === null) {
306-
return null;
307-
}
308-
309-
const candidate = payload as {
310-
format?: unknown;
311-
cues?: unknown;
312-
};
313-
314-
const format =
315-
candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
316-
? candidate.format
317-
: null;
318-
if (!format) {
319-
return null;
320-
}
321-
322-
const cues = normalizeCaptionSidecarCues(candidate.cues);
323-
if (cues.length === 0) {
324-
return null;
325-
}
326-
327-
return { format, cues };
328-
}
329-
330-
function serializeSrt(cues: CaptionSidecarCue[]): string {
331-
return cues
332-
.map((cue, index) => {
333-
return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
334-
})
335-
.join("\n\n");
336-
}
337-
338-
function serializeVtt(cues: CaptionSidecarCue[]): string {
339-
const body = cues
340-
.map((cue) => {
341-
return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
342-
})
343-
.join("\n\n");
344-
return `WEBVTT\n\n${body}`;
345-
}
346-
347-
async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) {
348-
if (!payload) {
349-
return;
350-
}
351-
352-
const parsed = path.parse(videoPath);
353-
const basePath = path.join(parsed.dir, parsed.name);
354-
355-
if (payload.format === "srt" || payload.format === "both") {
356-
await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
357-
}
358-
359-
if (payload.format === "vtt" || payload.format === "both") {
360-
await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
361-
}
362-
}
363-
364255
export function registerExportHandlers() {
365256
ipcMain.handle(
366257
"native-video-export-start",
@@ -987,13 +878,19 @@ export function registerExportHandlers() {
987878
}
988879

989880
await fs.writeFile(result.filePath, Buffer.from(videoData));
990-
await writeCaptionSidecars(result.filePath, sidecarPayload);
881+
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
882+
result.filePath,
883+
sidecarPayload,
884+
);
991885
approveUserPath(result.filePath);
992886

993887
return {
994888
success: true,
995889
path: result.filePath,
996-
message: "Video exported successfully",
890+
message: withCaptionSidecarMessage(
891+
"Video exported successfully",
892+
captionSidecarResult,
893+
),
997894
};
998895
} catch (error) {
999896
console.error("Failed to save exported video:", error);
@@ -1029,13 +926,19 @@ export function registerExportHandlers() {
1029926
const resolvedPath = path.resolve(outputPath);
1030927
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
1031928
await fs.writeFile(resolvedPath, Buffer.from(videoData));
1032-
await writeCaptionSidecars(resolvedPath, sidecarPayload);
929+
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
930+
resolvedPath,
931+
sidecarPayload,
932+
);
1033933
approveUserPath(resolvedPath);
1034934

1035935
return {
1036936
success: true,
1037937
path: resolvedPath,
1038-
message: "Video exported successfully",
938+
message: withCaptionSidecarMessage(
939+
"Video exported successfully",
940+
captionSidecarResult,
941+
),
1039942
canceled: false,
1040943
};
1041944
} catch (error) {
@@ -1088,14 +991,20 @@ export function registerExportHandlers() {
1088991
if (payload.outputPath) {
1089992
const resolvedPath = path.resolve(payload.outputPath);
1090993
await moveExportedTempFile(tempPath, resolvedPath);
1091-
await writeCaptionSidecars(resolvedPath, sidecarPayload);
1092994
releaseOwnedExportPath(tempPath);
995+
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
996+
resolvedPath,
997+
sidecarPayload,
998+
);
1093999
approveUserPath(resolvedPath);
10941000
return {
10951001
success: true,
10961002
path: resolvedPath,
10971003
canceled: false,
1098-
message: "Video exported successfully",
1004+
message: withCaptionSidecarMessage(
1005+
"Video exported successfully",
1006+
captionSidecarResult,
1007+
),
10991008
};
11001009
}
11011010

@@ -1126,15 +1035,21 @@ export function registerExportHandlers() {
11261035
}
11271036

11281037
await moveExportedTempFile(tempPath, result.filePath);
1129-
await writeCaptionSidecars(result.filePath, sidecarPayload);
11301038
releaseOwnedExportPath(tempPath);
1039+
const captionSidecarResult = await writeCaptionSidecarsBestEffort(
1040+
result.filePath,
1041+
sidecarPayload,
1042+
);
11311043
approveUserPath(result.filePath);
11321044

11331045
return {
11341046
success: true,
11351047
path: result.filePath,
11361048
canceled: false,
1137-
message: "Video exported successfully",
1049+
message: withCaptionSidecarMessage(
1050+
"Video exported successfully",
1051+
captionSidecarResult,
1052+
),
11381053
};
11391054
} catch (error) {
11401055
console.error("Failed to finalize exported video:", error);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
parseCaptionSidecarPayload,
7+
serializeSrt,
8+
serializeVtt,
9+
withCaptionSidecarMessage,
10+
writeCaptionSidecarsBestEffort,
11+
} from "./exportCaptionSidecars";
12+
13+
describe("exportCaptionSidecars", () => {
14+
afterEach(() => {
15+
vi.restoreAllMocks();
16+
});
17+
18+
it("serializes SRT cues with stable numbering and timestamps", () => {
19+
expect(
20+
serializeSrt([
21+
{
22+
startMs: 1234,
23+
endMs: 5678,
24+
text: "Hello\nworld",
25+
},
26+
]),
27+
).toBe("1\n00:00:01,234 --> 00:00:05,678\nHello\nworld");
28+
});
29+
30+
it("serializes VTT cues with header and dot timestamps", () => {
31+
expect(
32+
serializeVtt([
33+
{
34+
startMs: 0,
35+
endMs: 2000,
36+
text: "Caption",
37+
},
38+
]),
39+
).toBe("WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nCaption");
40+
});
41+
42+
it("drops malformed cues when parsing sidecar payloads", () => {
43+
expect(
44+
parseCaptionSidecarPayload({
45+
format: "both",
46+
cues: [
47+
{ startMs: 0, endMs: 1000, text: "ok" },
48+
{ startMs: 2000, endMs: 1000, text: "bad range" },
49+
{ startMs: 1000, endMs: 2000, text: " " },
50+
],
51+
}),
52+
).toEqual({
53+
format: "both",
54+
cues: [{ startMs: 0, endMs: 1000, text: "ok" }],
55+
});
56+
});
57+
58+
it("returns a warning result instead of throwing when sidecar writes fail", async () => {
59+
const writeFileSpy = vi.spyOn(fs, "writeFile").mockRejectedValueOnce(new Error("disk full"));
60+
61+
await expect(
62+
writeCaptionSidecarsBestEffort("/tmp/export.mp4", {
63+
format: "srt",
64+
cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
65+
}),
66+
).resolves.toEqual({
67+
wroteAny: false,
68+
error: "disk full",
69+
});
70+
71+
expect(writeFileSpy).toHaveBeenCalledTimes(1);
72+
});
73+
74+
it("writes requested caption sidecars when the filesystem succeeds", async () => {
75+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-sidecar-test-"));
76+
const videoPath = path.join(tempDir, "clip.mp4");
77+
78+
try {
79+
await expect(
80+
writeCaptionSidecarsBestEffort(videoPath, {
81+
format: "both",
82+
cues: [{ startMs: 0, endMs: 1000, text: "Caption" }],
83+
}),
84+
).resolves.toEqual({ wroteAny: true, error: null });
85+
86+
await expect(fs.readFile(path.join(tempDir, "clip.srt"), "utf8")).resolves.toContain(
87+
"00:00:00,000 --> 00:00:01,000",
88+
);
89+
await expect(fs.readFile(path.join(tempDir, "clip.vtt"), "utf8")).resolves.toContain(
90+
"WEBVTT",
91+
);
92+
} finally {
93+
await fs.rm(tempDir, { recursive: true, force: true });
94+
}
95+
});
96+
97+
it("appends a non-fatal caption warning only when sidecar writes fail", () => {
98+
expect(
99+
withCaptionSidecarMessage("Video exported successfully", {
100+
wroteAny: false,
101+
error: "disk full",
102+
}),
103+
).toBe("Video exported successfully Captions could not be saved alongside the video.");
104+
105+
expect(
106+
withCaptionSidecarMessage("Video exported successfully", {
107+
wroteAny: true,
108+
error: null,
109+
}),
110+
).toBe("Video exported successfully");
111+
});
112+
});

0 commit comments

Comments
 (0)