diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 1b6de7e4..cbf9b29b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1040,7 +1040,7 @@ export default function VideoEditor() { if (saveResult.canceled) { toast.info("Export canceled"); - } else if (saveResult.success) { + } else if (saveResult.success && saveResult.path) { handleExportSaved("GIF", saveResult.path); } else { setExportError(saveResult.message || "Failed to save GIF"); @@ -1167,7 +1167,7 @@ export default function VideoEditor() { if (saveResult.canceled) { toast.info("Export canceled"); - } else if (saveResult.success) { + } else if (saveResult.success && saveResult.path) { handleExportSaved("Video", saveResult.path); } else { setExportError(saveResult.message || "Failed to save video"); @@ -1232,11 +1232,16 @@ export default function VideoEditor() { // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const aspectRatioValue = + aspectRatio === "native" + ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) + : getAspectRatioValue(aspectRatio); const gifDimensions = calculateOutputDimensions( sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS, + aspectRatioValue, ); const settings: ExportSettings = { @@ -1260,7 +1265,17 @@ export default function VideoEditor() { // Start export immediately handleExport(settings); - }, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); + }, [ + videoPath, + exportFormat, + exportQuality, + gifFrameRate, + gifLoop, + gifSizePreset, + aspectRatio, + cropRegion, + handleExport, + ]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { @@ -1475,6 +1490,13 @@ export default function VideoEditor() { videoPlaybackRef.current?.video?.videoHeight || 1080, gifSizePreset, GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), )} onExport={handleOpenExportDialog} selectedAnnotationId={selectedAnnotationId} diff --git a/src/lib/exporter/gifExporter.test.ts b/src/lib/exporter/gifExporter.test.ts new file mode 100644 index 00000000..1ad16717 --- /dev/null +++ b/src/lib/exporter/gifExporter.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { calculateOutputDimensions } from "./gifExporter"; +import { GIF_SIZE_PRESETS } from "./types"; + +describe("calculateOutputDimensions", () => { + it("uses the selected aspect ratio for scaled GIF exports", () => { + expect(calculateOutputDimensions(1080, 1920, "medium", GIF_SIZE_PRESETS, 16 / 9)).toEqual({ + width: 1280, + height: 720, + }); + }); + + it("fits original-size GIF exports within the source bounds at the selected aspect ratio", () => { + expect(calculateOutputDimensions(1080, 1920, "original", GIF_SIZE_PRESETS, 16 / 9)).toEqual({ + width: 1080, + height: 606, + }); + }); +}); diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index af49ce25..b9067567 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -58,24 +58,39 @@ export function calculateOutputDimensions( sourceHeight: number, sizePreset: GifSizePreset, sizePresets: typeof GIF_SIZE_PRESETS, + targetAspectRatio = sourceWidth / sourceHeight, ): { width: number; height: number } { const preset = sizePresets[sizePreset]; const maxHeight = preset.maxHeight; + const aspectRatio = + Number.isFinite(targetAspectRatio) && targetAspectRatio > 0 + ? targetAspectRatio + : sourceWidth / sourceHeight; + + const toEven = (value: number) => { + const evenValue = Math.max(2, Math.floor(value / 2) * 2); + return evenValue; + }; + + if (sizePreset === "original") { + const sourceAspect = sourceWidth / sourceHeight; + if (aspectRatio >= sourceAspect) { + const width = toEven(sourceWidth); + const height = toEven(width / aspectRatio); + return { width, height }; + } - // If original is smaller than max height or preset is 'original', use source dimensions - if (sourceHeight <= maxHeight || sizePreset === "original") { - return { width: sourceWidth, height: sourceHeight }; + const height = toEven(sourceHeight); + const width = toEven(height * aspectRatio); + return { width, height }; } - // Calculate scaled dimensions preserving aspect ratio - const aspectRatio = sourceWidth / sourceHeight; - const newHeight = maxHeight; - const newWidth = Math.round(newHeight * aspectRatio); + const targetHeight = maxHeight; + const targetWidth = Math.round(targetHeight * aspectRatio); - // Ensure dimensions are even (required for some encoders) return { - width: newWidth % 2 === 0 ? newWidth : newWidth + 1, - height: newHeight % 2 === 0 ? newHeight : newHeight + 1, + width: toEven(targetWidth), + height: toEven(targetHeight), }; } diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts new file mode 100644 index 00000000..1969c84d --- /dev/null +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { shouldFailDecodeEndedEarly } from "./streamingDecoder"; + +describe("shouldFailDecodeEndedEarly", () => { + it("does not fail once every segment has been satisfied", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + lastDecodedFrameSec: 5.33, + requiredEndSec: 6.498, + streamDurationSec: 5.33, + }), + ).toBe(false); + }); + + it("fails when decode stops far before the required end", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + lastDecodedFrameSec: 5.33, + requiredEndSec: 10, + streamDurationSec: 5.33, + }), + ).toBe(true); + }); + + it("fails when no frame could be decoded for a non-empty timeline", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + lastDecodedFrameSec: null, + requiredEndSec: 1, + }), + ).toBe(true); + }); + + it("fails when the decoder has not reached the reported stream end", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + lastDecodedFrameSec: 4.9, + requiredEndSec: 6.498, + streamDurationSec: 5.33, + }), + ).toBe(true); + }); +}); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 7ebd78dc..d994b662 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -12,6 +12,55 @@ export interface DecodedVideoInfo { audioCodec?: string; } +type EarlyDecodeEndCheck = { + cancelled: boolean; + lastDecodedFrameSec: number | null; + requiredEndSec: number; + streamDurationSec?: number; +}; + +const EARLY_DECODE_END_THRESHOLD_SEC = 1; +const METADATA_TAIL_TOLERANCE_SEC = 1.5; +const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25; + +export function shouldFailDecodeEndedEarly({ + cancelled, + lastDecodedFrameSec, + requiredEndSec, + streamDurationSec, +}: EarlyDecodeEndCheck): boolean { + if (cancelled || requiredEndSec <= 0) { + return false; + } + + if (lastDecodedFrameSec === null) { + return true; + } + + const decodeGapSec = requiredEndSec - lastDecodedFrameSec; + if (decodeGapSec <= EARLY_DECODE_END_THRESHOLD_SEC) { + return false; + } + + if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) { + return true; + } + + const metadataTailSec = requiredEndSec - streamDurationSec; + const decodedNearStreamEnd = + Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC; + + if ( + decodedNearStreamEnd && + metadataTailSec > 0 && + metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC + ) { + return false; + } + + return true; +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -366,12 +415,17 @@ export class StreamingVideoDecoder { const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0; if ( - !this.cancelled && - lastDecodedFrameSec !== null && - requiredEndSec - lastDecodedFrameSec > 1 + shouldFailDecodeEndedEarly({ + cancelled: this.cancelled, + lastDecodedFrameSec, + requiredEndSec, + streamDurationSec: this.metadata.streamDuration, + }) ) { + const decodedAtLabel = + lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`; throw new Error( - `Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`, + `Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`, ); } } diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts index b18fd443..a8515462 100644 --- a/tests/e2e/gif-export.spec.ts +++ b/tests/e2e/gif-export.spec.ts @@ -87,8 +87,8 @@ test("exports a GIF from a loaded video", async () => { await editorWindow.getByTestId("testId-gif-format-button").click(); await editorWindow.getByTestId("testId-export-button").click(); - // ── 6. Wait for the toast to say exported successfully - await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({ + // ── 6. Wait for the success toast. + await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({ timeout: 90_000, });