diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index e364851156..526080f924 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -322,15 +322,61 @@ describe("resolveVideoExtractionDuration", () => { } }); - it("rejects a media start at source EOF before planning extraction", () => { - expect(() => - resolveVideoExtractionWindow(video({ mediaStart: 3 }), metadata(3), 10), - ).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false })); + it("holds the final frame when a non-loop media start sits at source EOF", () => { + // The runtime clamps currentTime and holds the last frame for the slot; + // encode plans one held frame instead of failing the render. + expect(resolveVideoExtractionWindow(video({ end: 6, mediaStart: 3 }), metadata(3), 10)).toEqual( + { + compositionStart: 0, + mediaStart: 3 - 1e-6, + durationSeconds: 1e-6, + preserveTimelineEnd: true, + ensureFinalFrame: true, + }, + ); }); - it("rejects a media start at video-stream EOF even when the container continues", () => { + it("holds the final frame at video-stream EOF even when the container continues", () => { + expect( + resolveVideoExtractionWindow(video({ end: 6, mediaStart: 3 }), metadata(60, 3), 10), + ).toEqual({ + compositionStart: 0, + mediaStart: 3 - 1e-6, + durationSeconds: 1e-6, + preserveTimelineEnd: true, + ensureFinalFrame: true, + }); + }); + + it("holds the final frame for a media start well past EOF on a later slot", () => { + expect( + resolveVideoExtractionWindow( + video({ start: 104, end: 120, mediaStart: 14 }), + metadata(9.6), + 270, + ), + ).toEqual({ + compositionStart: 104, + mediaStart: 9.6 - 1e-6, + durationSeconds: 1e-6, + preserveTimelineEnd: true, + ensureFinalFrame: true, + }); + }); + + it("skips a past-EOF hold whose slot has no interval inside the composition", () => { + expect( + resolveVideoExtractionWindow(video({ start: 12, end: 20, mediaStart: 3 }), metadata(3), 10) + .durationSeconds, + ).toBe(0); + expect( + resolveVideoExtractionWindow(video({ mediaStart: 3 }), metadata(3), 10).durationSeconds, + ).toBe(0); + }); + + it("still rejects a looping media start at source EOF", () => { expect(() => - resolveVideoExtractionWindow(video({ mediaStart: 3 }), metadata(60, 3), 10), + resolveVideoExtractionWindow(video({ end: 6, mediaStart: 3, loop: true }), metadata(3), 10), ).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false })); }); @@ -1806,6 +1852,22 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => { }); }, 60_000); + it("holds the final frame for a non-loop media start past source EOF", async () => { + const src = await synthCfrClip("past-eof-hold-src.mp4", 1); + const outputDir = join(FIXTURE_DIR, "out-past-eof-hold"); + const video = cfrClipElement("past-eof-hold", src, 3, 2); + + const result = await extractAllVideoFrames([video], FIXTURE_DIR, { fps: 30, outputDir }); + + expect(result.errors).toEqual([]); + expect(result.extracted).toHaveLength(1); + // The slot keeps its authored window; the source collapses to one held frame. + expect(video.start).toBe(0); + expect(video.end).toBe(3); + const frames = readdirSync(join(outputDir, "past-eof-hold")).filter((f) => f.endsWith(".jpg")); + expect(frames).toHaveLength(1); + }, 60_000); + it("preserves legacy metadata rejection unless typed aggregation is explicitly enabled", async () => { const src = join(FIXTURE_DIR, "invalid-probe.mp4"); writeFileSync(src, "not a media container"); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index f6e9da8443..0d63a3a8d1 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -1130,6 +1130,39 @@ export function resolveTimelineExtractionWindow( ); } +/** + * Plan a non-looping video whose media start already sits at/after the last + * playable frame. The browser runtime clamps `currentTime` there and holds the + * final frame for the whole slot (`isHeldVideoTail`), so encode holds the same + * frame instead of failing the render. Nothing outside the composition or the + * authored slot is extracted; a source-bounded slot (no finite end) has no + * visible interval left and is skipped, matching the runtime's implicit slot. + */ +function resolvePastEofHoldWindow( + video: TimelineWindowVideo, + playableDuration: number, + timelineEnd: number | undefined, +): TimelineExtractionWindow { + const compositionStart = Math.max(0, video.start); + const authoredDuration = + Number.isFinite(video.end) && video.end > compositionStart ? video.end - compositionStart : 0; + const timelineDuration = + timelineEnd === undefined + ? Number.POSITIVE_INFINITY + : Math.max(0, timelineEnd - compositionStart); + const visibleDuration = Math.min(authoredDuration, timelineDuration); + if (!(visibleDuration > 0)) { + return { compositionStart, mediaStart: playableDuration, durationSeconds: 0 }; + } + return { + compositionStart, + mediaStart: playableDuration - FINAL_FRAME_LOGICAL_DURATION_SECONDS, + durationSeconds: FINAL_FRAME_LOGICAL_DURATION_SECONDS, + preserveTimelineEnd: true, + ensureFinalFrame: true, + }; +} + /** * Replace a held-tail suffix that starts at/after the final decoded timestamp * with one exact frame. This keeps raw HDR scratch O(one frame) without @@ -1154,7 +1187,12 @@ export async function resolveFinalFrameExtractionWindow( ); if (window.mediaStart < finalFrameTimestamp - 1e-9) return window; - const sourceRemaining = playableDuration - video.mediaStart; + // A media start at/after EOF (past-EOF hold) leaves no source remaining; + // the logical duration is still one held frame, never zero or negative. + const sourceRemaining = Math.max( + playableDuration - video.mediaStart, + FINAL_FRAME_LOGICAL_DURATION_SECONDS, + ); const logicalDuration = Math.min(sourceRemaining, FINAL_FRAME_LOGICAL_DURATION_SECONDS); return { compositionStart: Math.max(0, video.start), @@ -1185,12 +1223,15 @@ export function resolveVideoExtractionWindow( ); } if (video.mediaStart >= playableDuration) { - throw new VideoSourceExtractionError( - "media_start_out_of_range", - false, - "Video media start is outside the source duration", - `Video media start ${video.mediaStart}s is outside playable video duration ${playableDuration}s`, - ); + if (video.loop) { + throw new VideoSourceExtractionError( + "media_start_out_of_range", + false, + "Video media start is outside the source duration", + `Video media start ${video.mediaStart}s is outside playable video duration ${playableDuration}s`, + ); + } + return resolvePastEofHoldWindow(video, playableDuration, timelineEnd); } const playbackRate = normalizePlaybackRate(video.playbackRate ?? 1); const requestedTimelineDuration = video.end - video.start; @@ -1723,11 +1764,13 @@ export async function extractAllVideoFrames( const metadata = videoMetadata[i]; if (!entry || !metadata) continue; - // Guard against mediaStart past EOF — FFmpeg's `-ss` silently produces + // Guard a looping mediaStart past EOF — FFmpeg's `-ss` silently produces // a 0-byte file when seeking beyond the source duration, and the - // downstream extractor then points at a broken input. + // downstream extractor then points at a broken input. A non-looping + // past-EOF start is planned as a final-frame hold instead (see + // `resolvePastEofHoldWindow`), which seeks to a real timestamp. const playableDuration = resolvePlayableVideoDuration(metadata); - if (entry.video.mediaStart >= playableDuration) { + if (entry.video.loop && entry.video.mediaStart >= playableDuration) { errors.push({ videoId: entry.video.id, kind: "media_start_out_of_range", diff --git a/packages/producer/src/services/render/stages/captureHdrResources.test.ts b/packages/producer/src/services/render/stages/captureHdrResources.test.ts index 12ac39f1c3..2a0a0cc168 100644 --- a/packages/producer/src/services/render/stages/captureHdrResources.test.ts +++ b/packages/producer/src/services/render/stages/captureHdrResources.test.ts @@ -305,9 +305,39 @@ describe("resolveHdrExtractionWindow", () => { }); }); - it("rejects mediaStart at source EOF before HDR budgeting", () => { + it("plans a one-frame hold for a non-loop mediaStart at source EOF", () => { + expect( + resolveHdrExtractionWindow( + hdrVideo("past-eof", { end: 6, mediaStart: 3 }), + 60, + videoMetadata(3), + ), + ).toEqual({ + compositionStart: 0, + mediaStart: 3 - 1e-6, + durationSeconds: 1e-6, + preserveTimelineEnd: true, + ensureFinalFrame: true, + }); + }); + + it("skips a source-bounded slot whose mediaStart is at source EOF", () => { + expect( + resolveHdrExtractionWindow( + hdrVideo("past-eof-open", { mediaStart: 3 }), + 60, + videoMetadata(3), + ), + ).toBeNull(); + }); + + it("still rejects a looping mediaStart at source EOF before HDR budgeting", () => { expect(() => - resolveHdrExtractionWindow(hdrVideo("past-eof", { mediaStart: 3 }), 60, videoMetadata(3)), + resolveHdrExtractionWindow( + hdrVideo("past-eof-loop", { end: 6, mediaStart: 3, loop: true }), + 60, + videoMetadata(3), + ), ).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false })); });