diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts new file mode 100644 index 0000000..18b0672 --- /dev/null +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -0,0 +1,40 @@ +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, + segmentsLength: 1, + completedSegments: 1, + lastDecodedFrameSec: 5.33, + requiredEndSec: 6.498, + }), + ).toBe(false); + }); + + it("fails when decode stops before the remaining segments can be covered", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + segmentsLength: 2, + completedSegments: 1, + lastDecodedFrameSec: 5.33, + requiredEndSec: 6.498, + }), + ).toBe(true); + }); + + it("fails when no frame could be decoded for a non-empty timeline", () => { + expect( + shouldFailDecodeEndedEarly({ + cancelled: false, + segmentsLength: 1, + completedSegments: 0, + lastDecodedFrameSec: null, + requiredEndSec: 1, + }), + ).toBe(true); + }); +}); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 7ebd78d..b4d7549 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -12,6 +12,38 @@ export interface DecodedVideoInfo { audioCodec?: string; } +type EarlyDecodeEndCheck = { + cancelled: boolean; + segmentsLength: number; + completedSegments: number; + lastDecodedFrameSec: number | null; + requiredEndSec: number; +}; + +export function shouldFailDecodeEndedEarly({ + cancelled, + segmentsLength, + completedSegments, + lastDecodedFrameSec, + requiredEndSec, +}: EarlyDecodeEndCheck): boolean { + if (cancelled || segmentsLength === 0) { + return false; + } + + // If we already satisfied every segment, the exporter has successfully + // filled any short metadata tail using the last decoded frame. + if (completedSegments >= segmentsLength) { + return false; + } + + if (lastDecodedFrameSec === null) { + return true; + } + + return requiredEndSec - lastDecodedFrameSec > 1; +} + /** Caller must close the VideoFrame after use. */ type OnFrameCallback = ( frame: VideoFrame, @@ -366,12 +398,18 @@ 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, + segmentsLength: segments.length, + completedSegments: segmentIdx, + lastDecodedFrameSec, + requiredEndSec, + }) ) { + 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).`, ); } }