Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});

Expand Down Expand Up @@ -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");
Expand Down
63 changes: 53 additions & 10 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});

Expand Down
Loading