-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add caption sidecar export option #669
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
| } | ||
| } | ||
|
|
||
| export function registerExportHandlers() { | ||
| ipcMain.handle( | ||
| "native-video-export-start", | ||
|
|
@@ -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 { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t report full export failure after video persistence succeeds. In these paths, 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 Also applies to: 1031-1032, 1089-1130 🤖 Prompt for AI Agents |
||
| approveUserPath(result.filePath); | ||
|
|
||
| return { | ||
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
|
|
@@ -929,6 +1058,7 @@ export function registerExportHandlers() { | |
| tempPath: string; | ||
| fileName: string; | ||
| outputPath?: string | null; | ||
| captionSidecar?: CaptionSidecarPayload; | ||
| }, | ||
| ) => { | ||
| const tempPath = payload?.tempPath; | ||
|
|
@@ -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 { | ||
|
|
@@ -994,6 +1126,7 @@ export function registerExportHandlers() { | |
| } | ||
|
|
||
| await moveExportedTempFile(tempPath, result.filePath); | ||
| await writeCaptionSidecars(result.filePath, sidecarPayload); | ||
| releaseOwnedExportPath(tempPath); | ||
| approveUserPath(result.filePath); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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