@@ -5,6 +5,12 @@ import path from "node:path";
55import type { Readable , Writable } from "node:stream" ;
66import type { SaveDialogOptions } from "electron" ;
77import { app , BrowserWindow , dialog , ipcMain } from "electron" ;
8+ import {
9+ parseCaptionSidecarPayload ,
10+ type CaptionSidecarPayload ,
11+ withCaptionSidecarMessage ,
12+ writeCaptionSidecarsBestEffort ,
13+ } from "./exportCaptionSidecars" ;
814import {
915 closeExportStream ,
1016 isOwnedExportPath ,
@@ -246,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean {
246252 return candidate . startsWith ( withSep ) ;
247253}
248254
249- type CaptionSidecarCue = {
250- startMs : number ;
251- endMs : number ;
252- text : string ;
253- } ;
254-
255- type CaptionSidecarPayload = {
256- format : "srt" | "vtt" | "both" ;
257- cues : CaptionSidecarCue [ ] ;
258- } ;
259-
260- function toSrtTimestamp ( totalMs : number ) : string {
261- const ms = Math . max ( 0 , Math . round ( totalMs ) ) ;
262- const hours = Math . floor ( ms / 3_600_000 ) ;
263- const minutes = Math . floor ( ( ms % 3_600_000 ) / 60_000 ) ;
264- const seconds = Math . floor ( ( ms % 60_000 ) / 1000 ) ;
265- const millis = ms % 1000 ;
266- return `${ String ( hours ) . padStart ( 2 , "0" ) } :${ String ( minutes ) . padStart ( 2 , "0" ) } :${ String ( seconds ) . padStart ( 2 , "0" ) } ,${ String ( millis ) . padStart ( 3 , "0" ) } ` ;
267- }
268-
269- function toVttTimestamp ( totalMs : number ) : string {
270- const ms = Math . max ( 0 , Math . round ( totalMs ) ) ;
271- const hours = Math . floor ( ms / 3_600_000 ) ;
272- const minutes = Math . floor ( ( ms % 3_600_000 ) / 60_000 ) ;
273- const seconds = Math . floor ( ( ms % 60_000 ) / 1000 ) ;
274- const millis = ms % 1000 ;
275- return `${ String ( hours ) . padStart ( 2 , "0" ) } :${ String ( minutes ) . padStart ( 2 , "0" ) } :${ String ( seconds ) . padStart ( 2 , "0" ) } .${ String ( millis ) . padStart ( 3 , "0" ) } ` ;
276- }
277-
278- function normalizeCaptionSidecarCues ( cues : unknown ) : CaptionSidecarCue [ ] {
279- if ( ! Array . isArray ( cues ) ) {
280- return [ ] ;
281- }
282-
283- return cues
284- . filter ( ( cue ) : cue is CaptionSidecarCue => {
285- return (
286- typeof cue === "object" &&
287- cue !== null &&
288- typeof cue . startMs === "number" &&
289- typeof cue . endMs === "number" &&
290- typeof cue . text === "string" &&
291- Number . isFinite ( cue . startMs ) &&
292- Number . isFinite ( cue . endMs ) &&
293- cue . endMs > cue . startMs &&
294- cue . text . trim ( ) . length > 0
295- ) ;
296- } )
297- . map ( ( cue ) => ( {
298- startMs : cue . startMs ,
299- endMs : cue . endMs ,
300- text : cue . text . replace ( / \r \n / g, "\n" ) . trim ( ) ,
301- } ) ) ;
302- }
303-
304- function parseCaptionSidecarPayload ( payload : unknown ) : CaptionSidecarPayload | null {
305- if ( typeof payload !== "object" || payload === null ) {
306- return null ;
307- }
308-
309- const candidate = payload as {
310- format ?: unknown ;
311- cues ?: unknown ;
312- } ;
313-
314- const format =
315- candidate . format === "srt" || candidate . format === "vtt" || candidate . format === "both"
316- ? candidate . format
317- : null ;
318- if ( ! format ) {
319- return null ;
320- }
321-
322- const cues = normalizeCaptionSidecarCues ( candidate . cues ) ;
323- if ( cues . length === 0 ) {
324- return null ;
325- }
326-
327- return { format, cues } ;
328- }
329-
330- function serializeSrt ( cues : CaptionSidecarCue [ ] ) : string {
331- return cues
332- . map ( ( cue , index ) => {
333- return `${ index + 1 } \n${ toSrtTimestamp ( cue . startMs ) } --> ${ toSrtTimestamp ( cue . endMs ) } \n${ cue . text } ` ;
334- } )
335- . join ( "\n\n" ) ;
336- }
337-
338- function serializeVtt ( cues : CaptionSidecarCue [ ] ) : string {
339- const body = cues
340- . map ( ( cue ) => {
341- return `${ toVttTimestamp ( cue . startMs ) } --> ${ toVttTimestamp ( cue . endMs ) } \n${ cue . text } ` ;
342- } )
343- . join ( "\n\n" ) ;
344- return `WEBVTT\n\n${ body } ` ;
345- }
346-
347- async function writeCaptionSidecars ( videoPath : string , payload : CaptionSidecarPayload | null ) {
348- if ( ! payload ) {
349- return ;
350- }
351-
352- const parsed = path . parse ( videoPath ) ;
353- const basePath = path . join ( parsed . dir , parsed . name ) ;
354-
355- if ( payload . format === "srt" || payload . format === "both" ) {
356- await fs . writeFile ( `${ basePath } .srt` , serializeSrt ( payload . cues ) , "utf8" ) ;
357- }
358-
359- if ( payload . format === "vtt" || payload . format === "both" ) {
360- await fs . writeFile ( `${ basePath } .vtt` , serializeVtt ( payload . cues ) , "utf8" ) ;
361- }
362- }
363-
364255export function registerExportHandlers ( ) {
365256 ipcMain . handle (
366257 "native-video-export-start" ,
@@ -987,13 +878,19 @@ export function registerExportHandlers() {
987878 }
988879
989880 await fs . writeFile ( result . filePath , Buffer . from ( videoData ) ) ;
990- await writeCaptionSidecars ( result . filePath , sidecarPayload ) ;
881+ const captionSidecarResult = await writeCaptionSidecarsBestEffort (
882+ result . filePath ,
883+ sidecarPayload ,
884+ ) ;
991885 approveUserPath ( result . filePath ) ;
992886
993887 return {
994888 success : true ,
995889 path : result . filePath ,
996- message : "Video exported successfully" ,
890+ message : withCaptionSidecarMessage (
891+ "Video exported successfully" ,
892+ captionSidecarResult ,
893+ ) ,
997894 } ;
998895 } catch ( error ) {
999896 console . error ( "Failed to save exported video:" , error ) ;
@@ -1029,13 +926,19 @@ export function registerExportHandlers() {
1029926 const resolvedPath = path . resolve ( outputPath ) ;
1030927 await fs . mkdir ( path . dirname ( resolvedPath ) , { recursive : true } ) ;
1031928 await fs . writeFile ( resolvedPath , Buffer . from ( videoData ) ) ;
1032- await writeCaptionSidecars ( resolvedPath , sidecarPayload ) ;
929+ const captionSidecarResult = await writeCaptionSidecarsBestEffort (
930+ resolvedPath ,
931+ sidecarPayload ,
932+ ) ;
1033933 approveUserPath ( resolvedPath ) ;
1034934
1035935 return {
1036936 success : true ,
1037937 path : resolvedPath ,
1038- message : "Video exported successfully" ,
938+ message : withCaptionSidecarMessage (
939+ "Video exported successfully" ,
940+ captionSidecarResult ,
941+ ) ,
1039942 canceled : false ,
1040943 } ;
1041944 } catch ( error ) {
@@ -1088,14 +991,20 @@ export function registerExportHandlers() {
1088991 if ( payload . outputPath ) {
1089992 const resolvedPath = path . resolve ( payload . outputPath ) ;
1090993 await moveExportedTempFile ( tempPath , resolvedPath ) ;
1091- await writeCaptionSidecars ( resolvedPath , sidecarPayload ) ;
1092994 releaseOwnedExportPath ( tempPath ) ;
995+ const captionSidecarResult = await writeCaptionSidecarsBestEffort (
996+ resolvedPath ,
997+ sidecarPayload ,
998+ ) ;
1093999 approveUserPath ( resolvedPath ) ;
10941000 return {
10951001 success : true ,
10961002 path : resolvedPath ,
10971003 canceled : false ,
1098- message : "Video exported successfully" ,
1004+ message : withCaptionSidecarMessage (
1005+ "Video exported successfully" ,
1006+ captionSidecarResult ,
1007+ ) ,
10991008 } ;
11001009 }
11011010
@@ -1126,15 +1035,21 @@ export function registerExportHandlers() {
11261035 }
11271036
11281037 await moveExportedTempFile ( tempPath , result . filePath ) ;
1129- await writeCaptionSidecars ( result . filePath , sidecarPayload ) ;
11301038 releaseOwnedExportPath ( tempPath ) ;
1039+ const captionSidecarResult = await writeCaptionSidecarsBestEffort (
1040+ result . filePath ,
1041+ sidecarPayload ,
1042+ ) ;
11311043 approveUserPath ( result . filePath ) ;
11321044
11331045 return {
11341046 success : true ,
11351047 path : result . filePath ,
11361048 canceled : false ,
1137- message : "Video exported successfully" ,
1049+ message : withCaptionSidecarMessage (
1050+ "Video exported successfully" ,
1051+ captionSidecarResult ,
1052+ ) ,
11381053 } ;
11391054 } catch ( error ) {
11401055 console . error ( "Failed to finalize exported video:" , error ) ;
0 commit comments