Compare commits

..
6 changed files with 118 additions and 73 deletions
+1 -23
View File
@@ -1232,16 +1232,11 @@ 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 = {
@@ -1265,17 +1260,7 @@ 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) {
@@ -1490,13 +1475,6 @@ 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
@@ -1,19 +0,0 @@
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,
});
});
});
+10 -25
View File
@@ -58,39 +58,24 @@ 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;
const toEven = (value: number) => { // If original is smaller than max height or preset is 'original', use source dimensions
const evenValue = Math.max(2, Math.floor(value / 2) * 2); if (sourceHeight <= maxHeight || sizePreset === "original") {
return evenValue; return { width: sourceWidth, height: sourceHeight };
};
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); // Calculate scaled dimensions preserving aspect ratio
const width = toEven(height * aspectRatio); const aspectRatio = sourceWidth / sourceHeight;
return { width, height }; 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 { return {
width: toEven(targetWidth), width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
height: toEven(targetHeight), height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
}; };
} }
+47
View File
@@ -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);
});
});
+58 -4
View File
@@ -12,6 +12,55 @@ 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,
@@ -366,12 +415,17 @@ 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 (
!this.cancelled && shouldFailDecodeEndedEarly({
lastDecodedFrameSec !== null && cancelled: this.cancelled,
requiredEndSec - lastDecodedFrameSec > 1 lastDecodedFrameSec,
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 ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`, `Video decode ended early at ${decodedAtLabel} (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 toast to say exported successfully // ── 6. Wait for the success toast.
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({ await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
timeout: 90_000, timeout: 90_000,
}); });