Compare commits

..
Author SHA1 Message Date
Siddharth 69f1b4d20f fix 231 2026-03-17 20:25:34 -07:00
6 changed files with 73 additions and 118 deletions
+23 -1
View File
@@ -1232,11 +1232,16 @@ export default function VideoEditor() {
// Build export settings from current state // Build export settings from current state
const sourceWidth = video.videoWidth || 1920; const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080; const sourceHeight = video.videoHeight || 1080;
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
const gifDimensions = calculateOutputDimensions( const gifDimensions = calculateOutputDimensions(
sourceWidth, sourceWidth,
sourceHeight, sourceHeight,
gifSizePreset, gifSizePreset,
GIF_SIZE_PRESETS, GIF_SIZE_PRESETS,
aspectRatioValue,
); );
const settings: ExportSettings = { const settings: ExportSettings = {
@@ -1260,7 +1265,17 @@ export default function VideoEditor() {
// Start export immediately // Start export immediately
handleExport(settings); handleExport(settings);
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); }, [
videoPath,
exportFormat,
exportQuality,
gifFrameRate,
gifLoop,
gifSizePreset,
aspectRatio,
cropRegion,
handleExport,
]);
const handleCancelExport = useCallback(() => { const handleCancelExport = useCallback(() => {
if (exporterRef.current) { if (exporterRef.current) {
@@ -1475,6 +1490,13 @@ export default function VideoEditor() {
videoPlaybackRef.current?.video?.videoHeight || 1080, videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset, gifSizePreset,
GIF_SIZE_PRESETS, GIF_SIZE_PRESETS,
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
)} )}
onExport={handleOpenExportDialog} onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId} selectedAnnotationId={selectedAnnotationId}
+19
View File
@@ -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,
});
});
});
+25 -10
View File
@@ -58,24 +58,39 @@ export function calculateOutputDimensions(
sourceHeight: number, sourceHeight: number,
sizePreset: GifSizePreset, sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS, sizePresets: typeof GIF_SIZE_PRESETS,
targetAspectRatio = sourceWidth / sourceHeight,
): { width: number; height: number } { ): { width: number; height: number } {
const preset = sizePresets[sizePreset]; const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight; const maxHeight = preset.maxHeight;
const aspectRatio =
Number.isFinite(targetAspectRatio) && targetAspectRatio > 0
? targetAspectRatio
: sourceWidth / sourceHeight;
// If original is smaller than max height or preset is 'original', use source dimensions const toEven = (value: number) => {
if (sourceHeight <= maxHeight || sizePreset === "original") { const evenValue = Math.max(2, Math.floor(value / 2) * 2);
return { width: sourceWidth, height: sourceHeight }; return evenValue;
};
if (sizePreset === "original") {
const sourceAspect = sourceWidth / sourceHeight;
if (aspectRatio >= sourceAspect) {
const width = toEven(sourceWidth);
const height = toEven(width / aspectRatio);
return { width, height };
}
const height = toEven(sourceHeight);
const width = toEven(height * aspectRatio);
return { width, height };
} }
// Calculate scaled dimensions preserving aspect ratio const targetHeight = maxHeight;
const aspectRatio = sourceWidth / sourceHeight; const targetWidth = Math.round(targetHeight * aspectRatio);
const newHeight = maxHeight;
const newWidth = Math.round(newHeight * aspectRatio);
// Ensure dimensions are even (required for some encoders)
return { return {
width: newWidth % 2 === 0 ? newWidth : newWidth + 1, width: toEven(targetWidth),
height: newHeight % 2 === 0 ? newHeight : newHeight + 1, height: toEven(targetHeight),
}; };
} }
-47
View File
@@ -1,47 +0,0 @@
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);
});
});
+4 -58
View File
@@ -12,55 +12,6 @@ export interface DecodedVideoInfo {
audioCodec?: string; 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. */ /** Caller must close the VideoFrame after use. */
type OnFrameCallback = ( type OnFrameCallback = (
frame: VideoFrame, frame: VideoFrame,
@@ -415,17 +366,12 @@ export class StreamingVideoDecoder {
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0; const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
if ( if (
shouldFailDecodeEndedEarly({ !this.cancelled &&
cancelled: this.cancelled, lastDecodedFrameSec !== null &&
lastDecodedFrameSec, requiredEndSec - lastDecodedFrameSec > 1
requiredEndSec,
streamDurationSec: this.metadata.streamDuration,
})
) { ) {
const decodedAtLabel =
lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`;
throw new Error( throw new Error(
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`, `Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
); );
} }
} }
+2 -2
View File
@@ -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-gif-format-button").click();
await editorWindow.getByTestId("testId-export-button").click(); await editorWindow.getByTestId("testId-export-button").click();
// ── 6. Wait for the success toast. // ── 6. Wait for the toast to say exported successfully
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({ await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
timeout: 90_000, timeout: 90_000,
}); });