Skip to content
Merged
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
70 changes: 70 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,34 @@ describe("resolveVideoExtractionDuration", () => {
).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false }));
});

it("plans a one-frame held tail when an explicit non-looping slot starts past EOF", () => {
expect(resolveVideoExtractionWindow(video({ end: 6, mediaStart: 5 }), metadata(2))).toEqual({
compositionStart: 0,
mediaStart: 1.999999,
durationSeconds: 0.000001,
preserveTimelineEnd: true,
ensureFinalFrame: true,
});
});

it("keeps the playable suffix when an explicit non-looping slot starts just inside EOF", () => {
expect(
resolveVideoExtractionWindow(video({ end: 6, mediaStart: 1.9 }), metadata(2), 6),
).toEqual({
compositionStart: 0,
mediaStart: 1.9,
durationSeconds: 0.10000000000000009,
preserveTimelineEnd: true,
ensureFinalFrame: true,
});
});

it("rejects a looping explicit slot that starts at source EOF", () => {
expect(() =>
resolveVideoExtractionWindow(video({ end: 6, mediaStart: 2, loop: true }), metadata(2), 6),
).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false }));
});

it("rebases a loop phase when the visible window stays within one cycle", () => {
expect(
resolveVideoExtractionWindow(
Expand Down Expand Up @@ -1548,6 +1576,48 @@ describe.skipIf(!HAS_FFMPEG)("held tails on sparse-timestamp sources", () => {
},
30_000,
);

it("renders the same final decoded frame just inside and past EOF", async () => {
const metadata = await extractVideoMetadata(cfrFixture);
const sourceDuration = metadata.videoStreamDurationSeconds;
const outputDir = mkdtempSync(join(fixtureDir, "eof-out-"));
const videos: VideoElement[] = [
{
id: "just-inside-eof",
src: cfrFixture,
start: 0,
end: 5,
mediaStart: sourceDuration - 0.001,
loop: false,
hasAudio: false,
},
{
id: "past-eof",
src: cfrFixture,
start: 0,
end: 5,
mediaStart: sourceDuration + 1,
loop: false,
hasAudio: false,
},
];

const result = await extractAllVideoFrames(videos, fixtureDir, {
fps: 30,
format: "png",
outputDir,
timelineEnd: 5,
});

expect(result.errors).toEqual([]);
expect(result.extracted).toHaveLength(2);
const insideFrame = result.extracted[0]?.framePaths.get(0);
const pastFrame = result.extracted[1]?.framePaths.get(0);
expect(insideFrame).toBeDefined();
expect(pastFrame).toBeDefined();
if (!insideFrame || !pastFrame) throw new Error("expected both final-frame outputs");
expect(readFileSync(pastFrame)).toEqual(readFileSync(insideFrame));
}, 30_000);
});

// Regression test for the VFR (variable frame rate) freeze bug.
Expand Down
41 changes: 33 additions & 8 deletions packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,11 @@ type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> &
Partial<Pick<VideoElement, "playbackRate">> &
Partial<Pick<VideoElement, "loop">>;

function canHoldFinalFramePastEof(video: TimelineWindowVideo): boolean {
const timelineDuration = video.end - video.start;
return !video.loop && Number.isFinite(timelineDuration) && timelineDuration > 0;
}

// Logical duration assigned to a one-frame held-tail representation. This is
// deliberately below any supported output frame interval: coverage expects
// one frame, while FFmpeg seeks to the separately probed real frame timestamp.
Expand Down Expand Up @@ -1118,6 +1123,18 @@ export function resolveTimelineExtractionWindow(
},
visibleDuration,
);
} else if (canHoldFinalFramePastEof(video)) {
const logicalDuration = Math.min(sourceDuration, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
return withTimelineDuration(
{
compositionStart: video.start,
mediaStart: sourceDuration - logicalDuration,
durationSeconds: logicalDuration,
preserveTimelineEnd: true,
ensureFinalFrame: true,
},
visibleDuration,
);
}
}
return withTimelineDuration(
Expand Down Expand Up @@ -1155,7 +1172,10 @@ export async function resolveFinalFrameExtractionWindow(
if (window.mediaStart < finalFrameTimestamp - 1e-9) return window;

const sourceRemaining = playableDuration - video.mediaStart;
const logicalDuration = Math.min(sourceRemaining, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
const logicalDuration = Math.min(
Math.max(sourceRemaining, window.durationSeconds),
FINAL_FRAME_LOGICAL_DURATION_SECONDS,
);
return {
compositionStart: Math.max(0, video.start),
mediaStart: playableDuration - logicalDuration,
Expand Down Expand Up @@ -1184,7 +1204,9 @@ export function resolveVideoExtractionWindow(
`Playable video stream duration is ${playableDuration}s`,
);
}
if (video.mediaStart >= playableDuration) {
const requestedTimelineDuration = video.end - video.start;
const heldPastEof = video.mediaStart >= playableDuration && canHoldFinalFramePastEof(video);
if (video.mediaStart >= playableDuration && !heldPastEof) {
throw new VideoSourceExtractionError(
"media_start_out_of_range",
false,
Expand All @@ -1193,13 +1215,17 @@ export function resolveVideoExtractionWindow(
);
}
const playbackRate = normalizePlaybackRate(video.playbackRate ?? 1);
const requestedTimelineDuration = video.end - video.start;
const resolvedDuration =
Number.isFinite(requestedTimelineDuration) && requestedTimelineDuration > 0
? requestedTimelineDuration
: resolveSegmentDuration(requestedTimelineDuration, video.mediaStart, playableDuration) /
playbackRate;
return resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, playableDuration);
return resolveTimelineExtractionWindow(
video,
resolvedDuration,
timelineEnd ?? (heldPastEof ? video.end : undefined),
playableDuration,
);
}

export function resolveVideoExtractionDuration(
Expand Down Expand Up @@ -1723,11 +1749,10 @@ export async function extractAllVideoFrames(
const metadata = videoMetadata[i];
if (!entry || !metadata) continue;

// Guard against 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.
// Guard past-EOF windows that cannot use the non-looping held-tail plan.
// FFmpeg's `-ss` otherwise silently produces a 0-byte intermediate.
const playableDuration = resolvePlayableVideoDuration(metadata);
if (entry.video.mediaStart >= playableDuration) {
if (entry.video.mediaStart >= playableDuration && !canHoldFinalFramePastEof(entry.video)) {
errors.push({
videoId: entry.video.id,
kind: "media_start_out_of_range",
Expand Down
Loading
Loading