diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a3f663f2..6dc7d396 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; @@ -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; diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 78afeb2f..1223efee 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -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); 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); diff --git a/electron/preload.ts b/electron/preload.ts index 04695d6b..6f941e80 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }, @@ -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"); diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx index 4d690a0a..5e36cdd7 100644 --- a/src/components/video-editor/ExportSettingsMenu.tsx +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -29,6 +29,9 @@ interface ExportSettingsMenuProps { experimentalNvidiaCudaExport?: boolean; onExperimentalNvidiaCudaExportChange?: (enabled: boolean) => void; nvidiaCudaExportAvailable?: boolean; + showCaptionSidecarOption?: boolean; + includeCaptionSidecar?: boolean; + onIncludeCaptionSidecarChange?: (enabled: boolean) => void; mp4OutputDimensions?: Record; gifFrameRate: GifFrameRate; onGifFrameRateChange?: (rate: GifFrameRate) => void; @@ -55,6 +58,9 @@ export function ExportSettingsMenu({ experimentalNvidiaCudaExport = false, onExperimentalNvidiaCudaExportChange, nvidiaCudaExportAvailable = false, + showCaptionSidecarOption = false, + includeCaptionSidecar = false, + onIncludeCaptionSidecarChange, mp4OutputDimensions, gifFrameRate, onGifFrameRateChange, @@ -365,6 +371,30 @@ export function ExportSettingsMenu({ /> ) : null} + {showCaptionSidecarOption ? ( +
+
+

+ {tSettings("export.captionSidecar.title", "Export captions file")} +

+

+ {tSettings( + "export.captionSidecar.hint", + "Save .srt and .vtt files next to your exported video.", + )} +

+
+ +
+ ) : null} ) : (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9ed08752..f18b931f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,14 @@ type PendingExportSave = { fileName: string; arrayBuffer?: ArrayBuffer; tempFilePath?: string; + captionSidecar?: { + format: "srt" | "vtt" | "both"; + cues: Array<{ + startMs: number; + endMs: number; + text: string; + }>; + }; }; type CancelableExporter = { @@ -521,6 +529,7 @@ export default function VideoEditor() { const [autoCaptionSettings, setAutoCaptionSettings] = useState( DEFAULT_AUTO_CAPTION_SETTINGS, ); + const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true); const [whisperExecutablePath, setWhisperExecutablePath] = useState( initialEditorPreferences.whisperExecutablePath, ); @@ -596,6 +605,32 @@ export default function VideoEditor() { const [gifSizePreset, setGifSizePreset] = useState( initialEditorPreferences.gifSizePreset, ); + const hasCaptionsForSidecar = autoCaptionSettings.enabled && autoCaptions.length > 0; + const captionSidecarCues = useMemo( + () => + autoCaptions + .filter( + (cue) => + Number.isFinite(cue.startMs) && + Number.isFinite(cue.endMs) && + cue.endMs > cue.startMs && + typeof cue.text === "string" && + cue.text.trim().length > 0, + ) + .map((cue) => ({ + startMs: cue.startMs, + endMs: cue.endMs, + text: cue.text, + })), + [autoCaptions], + ); + const captionSidecarPayload = + hasCaptionsForSidecar && captionSidecarCues.length > 0 && includeCaptionSidecar + ? { + format: "both" as const, + cues: captionSidecarCues, + } + : undefined; const [exportedFilePath, setExportedFilePath] = useState(undefined); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); @@ -1283,7 +1318,12 @@ export default function VideoEditor() { }, []); const saveBlobExport = useCallback( - async (blob: Blob, fileName: string, outputPath: string | null = null) => { + async ( + blob: Blob, + fileName: string, + outputPath: string | null = null, + captionSidecar?: PendingExportSave["captionSidecar"], + ) => { const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; const hasExportStreamApi = typeof window !== "undefined" && @@ -1300,10 +1340,12 @@ export default function VideoEditor() { tempPath: tempFilePath, fileName, outputPath, + captionSidecar, }), pendingSave: { fileName, tempFilePath, + captionSidecar, } satisfies PendingExportSave, }; } @@ -1342,11 +1384,20 @@ export default function VideoEditor() { const arrayBuffer = await blob.arrayBuffer(); return { saveResult: outputPath - ? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath) - : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName), + ? await window.electronAPI.writeExportedVideoToPath( + arrayBuffer, + outputPath, + captionSidecar, + ) + : await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + captionSidecar, + ), pendingSave: { fileName, arrayBuffer, + captionSidecar, } satisfies PendingExportSave, }; }, @@ -4458,6 +4509,10 @@ export default function VideoEditor() { if (result.success && (result.blob || result.tempFilePath)) { const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; + const sidecarForThisExport = + settings.includeCaptionSidecar && captionSidecarPayload + ? captionSidecarPayload + : undefined; markExportAsSaving(); let saveResult: { @@ -4479,8 +4534,13 @@ export default function VideoEditor() { smokeExportConfig.enabled && smokeExportConfig.outputPath ? smokeExportConfig.outputPath : null, + captionSidecar: sidecarForThisExport, }); - pendingOnCancel = { fileName, tempFilePath: result.tempFilePath }; + pendingOnCancel = { + fileName, + tempFilePath: result.tempFilePath, + captionSidecar: sidecarForThisExport, + }; } else if (result.blob) { // Legacy fallback: some export paths still surface a Blob, but in // Electron we stream it into a temp file first so save/finalize @@ -4489,6 +4549,7 @@ export default function VideoEditor() { result.blob, fileName, smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + sidecarForThisExport, ); saveResult = blobSave.saveResult; pendingOnCancel = blobSave.pendingSave; @@ -4701,6 +4762,7 @@ export default function VideoEditor() { annotationRegions, autoCaptions, autoCaptionSettings, + captionSidecarPayload, isPlaying, exportQuality, effectiveZoomRegions, @@ -4858,6 +4920,7 @@ export default function VideoEditor() { sourceWidth, sourceHeight, exportFormat, + includeCaptionSidecar: hasCaptionsForSidecar && includeCaptionSidecar, exportEncodingMode, exportQuality, mp4FrameRate, @@ -4881,6 +4944,8 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + hasCaptionsForSidecar, + includeCaptionSidecar, exportBackendPreference, exportPipelineModel, handleExport, @@ -4925,11 +4990,13 @@ export default function VideoEditor() { tempPath: pendingSave.tempFilePath, fileName: pendingSave.fileName, outputPath: null, + captionSidecar: pendingSave.captionSidecar, }); } else if (pendingSave.arrayBuffer) { saveResult = await window.electronAPI.saveExportedVideo( pendingSave.arrayBuffer, pendingSave.fileName, + pendingSave.captionSidecar, ); } else { saveResult = { success: false, message: "No pending export to save" }; @@ -5669,6 +5736,9 @@ export default function VideoEditor() { onGifLoopChange={setGifLoop} gifSizePreset={gifSizePreset} onGifSizePresetChange={setGifSizePreset} + showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"} + includeCaptionSidecar={includeCaptionSidecar} + onIncludeCaptionSidecarChange={setIncludeCaptionSidecar} mp4OutputDimensions={mp4OutputDimensions} gifOutputDimensions={gifOutputDimensions} onExport={handleStartExportFromDropdown} diff --git a/src/components/video-editor/exportStartSettings.test.ts b/src/components/video-editor/exportStartSettings.test.ts index 357e7fb3..567807da 100644 --- a/src/components/video-editor/exportStartSettings.test.ts +++ b/src/components/video-editor/exportStartSettings.test.ts @@ -5,6 +5,7 @@ const baseOptions = { sourceWidth: 1920, sourceHeight: 1080, exportFormat: "mp4" as const, + includeCaptionSidecar: true, exportEncodingMode: "balanced" as const, exportQuality: "good" as const, mp4FrameRate: 30 as const, @@ -19,6 +20,7 @@ describe("resolveExportStartSettings", () => { it("preserves MP4 dropdown settings", () => { expect(resolveExportStartSettings(baseOptions)).toEqual({ format: "mp4", + includeCaptionSidecar: true, encodingMode: "balanced", mp4FrameRate: 30, backendPreference: "auto", @@ -41,6 +43,7 @@ describe("resolveExportStartSettings", () => { }), ).toEqual({ format: "gif", + includeCaptionSidecar: false, encodingMode: undefined, mp4FrameRate: undefined, backendPreference: undefined, diff --git a/src/components/video-editor/exportStartSettings.ts b/src/components/video-editor/exportStartSettings.ts index 6b57268a..dc0aeab7 100644 --- a/src/components/video-editor/exportStartSettings.ts +++ b/src/components/video-editor/exportStartSettings.ts @@ -16,6 +16,7 @@ export function resolveExportStartSettings({ sourceWidth, sourceHeight, exportFormat, + includeCaptionSidecar, exportEncodingMode, exportQuality, mp4FrameRate, @@ -28,6 +29,7 @@ export function resolveExportStartSettings({ sourceWidth: number; sourceHeight: number; exportFormat: ExportFormat; + includeCaptionSidecar: boolean; exportEncodingMode: ExportEncodingMode; exportQuality: ExportQuality; mp4FrameRate: ExportMp4FrameRate; @@ -44,6 +46,7 @@ export function resolveExportStartSettings({ return { format: exportFormat, + includeCaptionSidecar: exportFormat === "mp4" ? includeCaptionSidecar : false, encodingMode: exportFormat === "mp4" ? exportEncodingMode : undefined, mp4FrameRate: exportFormat === "mp4" ? mp4FrameRate : undefined, backendPreference: exportFormat === "mp4" ? exportBackendPreference : undefined, diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index 72682f01..f474f998 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -193,6 +193,7 @@ export interface GifExportConfig { export interface ExportSettings { format: ExportFormat; + includeCaptionSidecar?: boolean; // MP4 settings quality?: ExportQuality; encodingMode?: ExportEncodingMode;