Handle caption sidecar save failures

This commit is contained in:
webadderall
2026-06-12 17:24:35 +10:00
parent 52dd84260a
commit 8c7d23d828
3 changed files with 307 additions and 123 deletions
+38 -123
View File
@@ -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);
@@ -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");
});
});
@@ -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<CaptionSidecarWriteResult> {
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.`;
}