Compare commits

...
4 changed files with 109 additions and 8 deletions
+2 -2
View File
@@ -1040,7 +1040,7 @@ export default function VideoEditor() {
if (saveResult.canceled) { if (saveResult.canceled) {
toast.info("Export canceled"); toast.info("Export canceled");
} else if (saveResult.success) { } else if (saveResult.success && saveResult.path) {
handleExportSaved("GIF", saveResult.path); handleExportSaved("GIF", saveResult.path);
} else { } else {
setExportError(saveResult.message || "Failed to save GIF"); setExportError(saveResult.message || "Failed to save GIF");
@@ -1167,7 +1167,7 @@ export default function VideoEditor() {
if (saveResult.canceled) { if (saveResult.canceled) {
toast.info("Export canceled"); toast.info("Export canceled");
} else if (saveResult.success) { } else if (saveResult.success && saveResult.path) {
handleExportSaved("Video", saveResult.path); handleExportSaved("Video", saveResult.path);
} else { } else {
setExportError(saveResult.message || "Failed to save video"); setExportError(saveResult.message || "Failed to save video");
+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,
}); });