mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Handle caption sidecar save failures
This commit is contained in:
+38
-123
@@ -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.`;
|
||||
}
|
||||
Reference in New Issue
Block a user