Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,14 @@ interface Window {
tempPath: string;
fileName: string;
outputPath?: string | null;
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
};
}) => Promise<{
success: boolean;
path?: string;
Expand Down Expand Up @@ -614,10 +622,26 @@ interface Window {
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
},
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
writeExportedVideoToPath: (
videoData: ArrayBuffer,
outputPath: string,
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
},
) => Promise<{
success: boolean;
path?: string;
Expand Down
137 changes: 135 additions & 2 deletions electron/ipc/register/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,121 @@ 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");
}
}
Comment on lines +347 to +362

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Enforce MP4-only sidecar writes in the main process.

Line 355 currently writes sidecars whenever a payload is present, regardless of output extension. This allows .gif (or any other extension) exports to receive sidecars if an upstream caller sends the payload, which breaks the MP4-only contract.

Suggested fix
 async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) {
 	if (!payload) {
 		return;
 	}
+	if (path.extname(videoPath).toLowerCase() !== ".mp4") {
+		return;
+	}
 
 	const parsed = path.parse(videoPath);
 	const basePath = path.join(parsed.dir, parsed.name);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/register/export.ts` around lines 347 - 362, The
writeCaptionSidecars function currently writes sidecar files for any videoPath
when payload is present; update it to enforce MP4-only sidecar writes by
checking the parsed extension (from videoPath) and returning early unless
parsed.ext === '.mp4' (or lowercased '.mp4') before constructing basePath and
calling fs.writeFile for serializeSrt/serializeVtt; keep the existing
payload.format branches intact so sidecars are only written for MP4 outputs.


export function registerExportHandlers() {
ipcMain.handle(
"native-video-export-start",
Expand Down Expand Up @@ -829,8 +944,14 @@ export function registerExportHandlers() {

ipcMain.handle(
"save-exported-video",
async (event, videoData: ArrayBuffer, fileName: string) => {
async (
event,
videoData: ArrayBuffer,
fileName: string,
captionSidecar?: CaptionSidecarPayload,
) => {
try {
const sidecarPayload = parseCaptionSidecarPayload(captionSidecar);
const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength);
if (sizeError) {
return {
Expand Down Expand Up @@ -866,6 +987,7 @@ export function registerExportHandlers() {
}

await fs.writeFile(result.filePath, Buffer.from(videoData));
await writeCaptionSidecars(result.filePath, sidecarPayload);
Comment on lines 989 to +990

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t report full export failure after video persistence succeeds.

In these paths, writeCaptionSidecars(...) is awaited after the video file is already written/moved. If sidecar writing fails, the handler returns failure even though the video is already saved, causing partial-success misreporting and brittle retry behavior.

Suggested direction
- await moveExportedTempFile(tempPath, resolvedPath);
- await writeCaptionSidecars(resolvedPath, sidecarPayload);
- releaseOwnedExportPath(tempPath);
- approveUserPath(resolvedPath);
- return { success: true, path: resolvedPath, canceled: false, message: "Video exported successfully" };
+ await moveExportedTempFile(tempPath, resolvedPath);
+ releaseOwnedExportPath(tempPath);
+ approveUserPath(resolvedPath);
+
+ let sidecarError: string | undefined;
+ try {
+   await writeCaptionSidecars(resolvedPath, sidecarPayload);
+ } catch (error) {
+   sidecarError = error instanceof Error ? error.message : String(error);
+   console.warn("[export] Video saved but caption sidecar write failed:", sidecarError);
+ }
+
+ return {
+   success: true,
+   path: resolvedPath,
+   canceled: false,
+   message: sidecarError
+     ? "Video exported, but caption sidecar files could not be written."
+     : "Video exported successfully",
+   error: sidecarError,
+ };

Apply the same pattern to save-exported-video and write-exported-video-to-path so all three flows behave consistently.

Also applies to: 1031-1032, 1089-1130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/register/export.ts` around lines 989 - 990, The handler
currently awaits writeCaptionSidecars(...) after the video file is already
persisted and, on sidecar failure, returns an overall failure even though the
video succeeded; change the flow in the save-exported-video and
write-exported-video-to-path handlers (and the code paths invoking
writeCaptionSidecars) so that the video write/move (the fs.writeFile or video
persistence logic) determines success first, and any errors from
writeCaptionSidecars are caught and handled separately: log the sidecar error
(with context) and return a partial-success/result that indicates the video was
saved while sidecar writing failed (or still return success for the video write
but include sidecar failure detail), rather than treating sidecar failure as a
full export failure. Ensure references to writeCaptionSidecars,
save-exported-video, and write-exported-video-to-path are updated accordingly so
retries won’t re-do already-persisted video writes.

approveUserPath(result.filePath);

return {
Expand All @@ -886,8 +1008,14 @@ export function registerExportHandlers() {

ipcMain.handle(
"write-exported-video-to-path",
async (_event, videoData: ArrayBuffer, outputPath: string) => {
async (
_event,
videoData: ArrayBuffer,
outputPath: string,
captionSidecar?: CaptionSidecarPayload,
) => {
try {
const sidecarPayload = parseCaptionSidecarPayload(captionSidecar);
const sizeError = getInMemoryExportTooLargeMessage(videoData.byteLength);
if (sizeError) {
return {
Expand All @@ -901,6 +1029,7 @@ 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);
approveUserPath(resolvedPath);

return {
Expand Down Expand Up @@ -929,6 +1058,7 @@ export function registerExportHandlers() {
tempPath: string;
fileName: string;
outputPath?: string | null;
captionSidecar?: CaptionSidecarPayload;
},
) => {
const tempPath = payload?.tempPath;
Expand All @@ -954,9 +1084,11 @@ export function registerExportHandlers() {
}

try {
const sidecarPayload = parseCaptionSidecarPayload(payload.captionSidecar);
if (payload.outputPath) {
const resolvedPath = path.resolve(payload.outputPath);
await moveExportedTempFile(tempPath, resolvedPath);
await writeCaptionSidecars(resolvedPath, sidecarPayload);
releaseOwnedExportPath(tempPath);
approveUserPath(resolvedPath);
return {
Expand Down Expand Up @@ -994,6 +1126,7 @@ export function registerExportHandlers() {
}

await moveExportedTempFile(tempPath, result.filePath);
await writeCaptionSidecars(result.filePath, sidecarPayload);
releaseOwnedExportPath(tempPath);
approveUserPath(result.filePath);

Expand Down
43 changes: 39 additions & 4 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
tempPath: string;
fileName: string;
outputPath?: string | null;
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
};
}) => {
return ipcRenderer.invoke("finalize-exported-video", payload);
},
Expand Down Expand Up @@ -630,11 +638,38 @@ contextBridge.exposeInMainWorld("electronAPI", {
openAccessibilityPreferences: () => {
return ipcRenderer.invoke("open-accessibility-preferences");
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
},
) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName, captionSidecar);
},
writeExportedVideoToPath: (videoData: ArrayBuffer, outputPath: string) => {
return ipcRenderer.invoke("write-exported-video-to-path", videoData, outputPath);
writeExportedVideoToPath: (
videoData: ArrayBuffer,
outputPath: string,
captionSidecar?: {
format: "srt" | "vtt" | "both";
cues: Array<{
startMs: number;
endMs: number;
text: string;
}>;
},
) => {
return ipcRenderer.invoke(
"write-exported-video-to-path",
videoData,
outputPath,
captionSidecar,
);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
Expand Down
30 changes: 30 additions & 0 deletions src/components/video-editor/ExportSettingsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ interface ExportSettingsMenuProps {
experimentalNvidiaCudaExport?: boolean;
onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void;
nvidiaCudaExportAvailable?: boolean;
showCaptionSidecarOption?: boolean;
includeCaptionSidecar?: boolean;
onIncludeCaptionSidecarChange?: (enabled: boolean) => void;
mp4OutputDimensions?: Record<ExportQuality, { width: number; height: number }>;
gifFrameRate: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
Expand All @@ -55,6 +58,9 @@ export function ExportSettingsMenu({
experimentalNvidiaCudaExport = false,
onExperimentalNvidiaCudaExportChange,
nvidiaCudaExportAvailable = false,
showCaptionSidecarOption = false,
includeCaptionSidecar = false,
onIncludeCaptionSidecarChange,
mp4OutputDimensions,
gifFrameRate,
onGifFrameRateChange,
Expand Down Expand Up @@ -365,6 +371,30 @@ export function ExportSettingsMenu({
/>
</div>
) : null}
{showCaptionSidecarOption ? (
<div className="mb-3 flex min-h-12 items-center justify-between gap-3 rounded-lg border border-foreground/10 bg-foreground/5 px-3 py-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-foreground">
{tSettings("export.captionSidecar.title", "Export captions file")}
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground/75">
{tSettings(
"export.captionSidecar.hint",
"Save .srt and .vtt files next to your exported video.",
)}
</p>
</div>
<Switch
checked={includeCaptionSidecar}
onCheckedChange={onIncludeCaptionSidecarChange}
aria-label={tSettings(
"export.captionSidecar.toggle",
"Export captions sidecar files",
)}
className="shrink-0 scale-75 data-[state=checked]:bg-[#2563EB]"
/>
</div>
) : null}
</LayoutGroup>
) : (
<div className="mb-3 space-y-2">
Expand Down
Loading