mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 14:40:47 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f1b4d20f |
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user