diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b21ac1e..7adc558 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1404,6 +1404,12 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; + if (result.warnings) { + for (const warning of result.warnings) { + toast.warning(warning); + } + } + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { @@ -1538,6 +1544,12 @@ export default function VideoEditor() { const timestamp = Date.now(); const fileName = `export-${timestamp}.mp4`; + if (result.warnings) { + for (const warning of result.warnings) { + toast.warning(warning); + } + } + const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index f073d6b..f41b58d 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -118,6 +118,9 @@ export class GifExporter { async export(): Promise { let webcamFrameQueue: AsyncVideoFrameQueue | null = null; + const warnings: string[] = []; + const onWarning = (message: string) => warnings.push(message); + try { const platform = await getPlatform(); @@ -220,6 +223,7 @@ export class GifExporter { } queue.enqueue(webcamFrame); }, + onWarning, ) .catch((error) => { webcamDecodeError = error instanceof Error ? error : new Error(String(error)); @@ -279,6 +283,7 @@ export class GifExporter { webcamFrame?.close(); } }, + onWarning, ); if (this.cancelled) { @@ -325,7 +330,7 @@ export class GifExporter { this.gif!.render(); }); - return { success: true, blob }; + return { success: true, blob, warnings: warnings.length > 0 ? warnings : undefined }; } catch (error) { if (error instanceof BackgroundLoadError) { throw error; diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index dd4df7b..e8093df 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -68,7 +68,7 @@ type EarlyDecodeEndCheck = { }; const EARLY_DECODE_END_THRESHOLD_SEC = 1; -const METADATA_TAIL_TOLERANCE_SEC = 1.5; +const METADATA_TAIL_TOLERANCE_SEC = 2; const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5; // Fallback upper bound for the packet scan when no reliable duration hint is @@ -129,11 +129,8 @@ export function shouldFailDecodeEndedEarly({ const decodedNearStreamEnd = Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; - if ( - decodedNearStreamEnd && - metadataTailSec > 0 && - metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC - ) { + const maxTailSec = Math.max(METADATA_TAIL_TOLERANCE_SEC, requiredEndSec * 0.01); + if (decodedNearStreamEnd && metadataTailSec > 0 && metadataTailSec <= maxTailSec) { return false; } @@ -295,6 +292,7 @@ export class StreamingVideoDecoder { trimRegions: TrimRegion[] | undefined, speedRegions: SpeedRegion[] | undefined, onFrame: OnFrameCallback, + onWarning?: (message: string) => void, ): Promise { if (!this.demuxer || !this.metadata) { throw new Error("Must call loadMetadata() before decodeAll()"); @@ -606,8 +604,6 @@ export class StreamingVideoDecoder { } this.decoder = null; - const isWindows = typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent); - if ( shouldFailDecodeEndedEarly({ cancelled: this.cancelled, @@ -618,22 +614,9 @@ export class StreamingVideoDecoder { ) { const decodedAtLabel = lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.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).`, - ); - } + const message = `Decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s) – export may be slightly shorter than expected.`; + console.warn(`[StreamingVideoDecoder] ${message}`); + onWarning?.(message); } } diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index b6e08e8..3873341 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -19,6 +19,7 @@ export interface ExportResult { success: boolean; blob?: Blob; error?: string; + warnings?: string[]; } export interface VideoFrameData { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index cc8b7cf..d44bf40 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -112,6 +112,8 @@ export class VideoExporter { let webcamDecodeError: Error | null = null; let webcamDecodePromise: Promise | null = null; let webcamDecoder: StreamingVideoDecoder | null = null; + const warnings: string[] = []; + const onWarning = (message: string) => warnings.push(message); this.cleanup(); this.cancelled = false; @@ -199,6 +201,7 @@ export class VideoExporter { } queue.enqueue(webcamFrame); }, + onWarning, ) .catch((error) => { webcamDecodeError = error instanceof Error ? error : new Error(String(error)); @@ -303,6 +306,7 @@ export class VideoExporter { webcamFrame?.close(); } }, + onWarning, ); if (this.cancelled) { @@ -359,7 +363,7 @@ export class VideoExporter { } const blob = await muxer.finalize(); - return { success: true, blob }; + return { success: true, blob, warnings: warnings.length > 0 ? warnings : undefined }; } finally { stopWebcamDecode = true; webcamFrameQueue?.destroy(); diff --git a/tests/fixtures/sample-inflated-duration.webm b/tests/fixtures/sample-inflated-duration.webm new file mode 100644 index 0000000..a90d19e Binary files /dev/null and b/tests/fixtures/sample-inflated-duration.webm differ