diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e8d1525..be20fcd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -670,6 +670,16 @@ export function registerIpcHandlers( } }); + /** + * Handles saving an exported video file. + * Shows a save dialog, normalizes the file path for the current OS, + * ensures the directory exists, and writes the video data. + * @param _ - Unused event parameter. + * @param videoData - The exported video as an ArrayBuffer. + * @param fileName - Suggested filename for the save dialog. + * @returns Object with success status, optional file path, and error details. + */ + ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { try { // Determine file type from extension @@ -695,11 +705,18 @@ export function registerIpcHandlers( }; } - await fs.writeFile(result.filePath, Buffer.from(videoData)); + // --- FIX: Normalize the path for Windows compatibility --- + const normalizedPath = path.normalize(result.filePath); + + // Ensure the parent directory exists (Windows may fail if the folder is missing) + await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); + // --- END FIX --- + + await fs.writeFile(normalizedPath, Buffer.from(videoData)); return { success: true, - path: result.filePath, + path: normalizedPath, message: "Video exported successfully", }; } catch (error) { @@ -711,7 +728,6 @@ export function registerIpcHandlers( }; } }); - ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9477eda..e2973cf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -322,7 +322,6 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, - webcamSizePreset, webcamPosition, exportQuality, exportFormat, diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 61a8190..81e6218 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1322,7 +1322,6 @@ export default function TimelineEditor({ selectedBlurId, selectedSpeedId, annotationRegions, - blurRegions, currentTime, onSelectAnnotation, keyShortcuts, diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 00d9f0b..24b9844 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -281,7 +281,15 @@ export class StreamingVideoDecoder { return this.metadata; } - + /** + * Decodes all video frames from the loaded source and invokes a callback for each. + * Handles trimming and speed adjustments, and resamples to the target frame rate. + * On Windows, early decode termination is tolerated to work around driver quirks. + * @param targetFrameRate - Desired output frame rate. + * @param trimRegions - Array of time regions to keep (others discarded). + * @param speedRegions - Array of speed adjustments for specific time ranges. + * @param onFrame - Async callback receiving each decoded VideoFrame. + */ async decodeAll( targetFrameRate: number, trimRegions: TrimRegion[] | undefined, @@ -309,6 +317,8 @@ export class StreamingVideoDecoder { this.computeSegments(this.metadata.duration, trimRegions), speedRegions, ); + const requiredEndSec = segments[segments.length - 1]?.endSec ?? 0; + const segmentOutputFrameCounts = segments.map((segment) => Math.ceil( ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate, @@ -556,7 +566,8 @@ export class StreamingVideoDecoder { } this.decoder = null; - const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0; + const isWindows = typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent); + if ( shouldFailDecodeEndedEarly({ cancelled: this.cancelled, @@ -567,9 +578,22 @@ export class StreamingVideoDecoder { ) { const decodedAtLabel = lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; - throw new Error( - `Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`, - ); + const decodeGapSec = + lastDecodedFrameSec === null ? Infinity : requiredEndSec - lastDecodedFrameSec; + + // On Windows, tolerate a small decode gap: up to 10% of required duration, capped at 3 seconds. + const maxToleratedGap = Math.min(3.0, requiredEndSec * 0.1); + + if (isWindows && lastDecodedFrameSec !== null && decodeGapSec <= maxToleratedGap) { + console.warn( + `[StreamingVideoDecoder] Decode ended early on Windows with a gap of ${decodeGapSec.toFixed(2)}s ` + + `(max tolerated: ${maxToleratedGap.toFixed(2)}s) – proceeding anyway.`, + ); + } else { + throw new Error( + `Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`, + ); + } } }