Skip to content

Commit f3bd423

Browse files
fix(engine): hold finite video slots past EOF (heygen-com#3962)
Allow explicit finite non-looping video slots to use the existing held-final-frame extraction path when data-media-start is at or beyond source duration. Warn during project lint for local static slots that will use this behavior. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
1 parent cccc78c commit f3bd423

5 files changed

Lines changed: 409 additions & 32 deletions

File tree

‎packages/engine/src/services/videoFrameExtractor.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,34 @@ describe("resolveVideoExtractionDuration", () => {
334334
).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false }));
335335
});
336336

337+
it("plans a one-frame held tail when an explicit non-looping slot starts past EOF", () => {
338+
expect(resolveVideoExtractionWindow(video({ end: 6, mediaStart: 5 }), metadata(2))).toEqual({
339+
compositionStart: 0,
340+
mediaStart: 1.999999,
341+
durationSeconds: 0.000001,
342+
preserveTimelineEnd: true,
343+
ensureFinalFrame: true,
344+
});
345+
});
346+
347+
it("keeps the playable suffix when an explicit non-looping slot starts just inside EOF", () => {
348+
expect(
349+
resolveVideoExtractionWindow(video({ end: 6, mediaStart: 1.9 }), metadata(2), 6),
350+
).toEqual({
351+
compositionStart: 0,
352+
mediaStart: 1.9,
353+
durationSeconds: 0.10000000000000009,
354+
preserveTimelineEnd: true,
355+
ensureFinalFrame: true,
356+
});
357+
});
358+
359+
it("rejects a looping explicit slot that starts at source EOF", () => {
360+
expect(() =>
361+
resolveVideoExtractionWindow(video({ end: 6, mediaStart: 2, loop: true }), metadata(2), 6),
362+
).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false }));
363+
});
364+
337365
it("rebases a loop phase when the visible window stays within one cycle", () => {
338366
expect(
339367
resolveVideoExtractionWindow(
@@ -1548,6 +1576,48 @@ describe.skipIf(!HAS_FFMPEG)("held tails on sparse-timestamp sources", () => {
15481576
},
15491577
30_000,
15501578
);
1579+
1580+
it("renders the same final decoded frame just inside and past EOF", async () => {
1581+
const metadata = await extractVideoMetadata(cfrFixture);
1582+
const sourceDuration = metadata.videoStreamDurationSeconds;
1583+
const outputDir = mkdtempSync(join(fixtureDir, "eof-out-"));
1584+
const videos: VideoElement[] = [
1585+
{
1586+
id: "just-inside-eof",
1587+
src: cfrFixture,
1588+
start: 0,
1589+
end: 5,
1590+
mediaStart: sourceDuration - 0.001,
1591+
loop: false,
1592+
hasAudio: false,
1593+
},
1594+
{
1595+
id: "past-eof",
1596+
src: cfrFixture,
1597+
start: 0,
1598+
end: 5,
1599+
mediaStart: sourceDuration + 1,
1600+
loop: false,
1601+
hasAudio: false,
1602+
},
1603+
];
1604+
1605+
const result = await extractAllVideoFrames(videos, fixtureDir, {
1606+
fps: 30,
1607+
format: "png",
1608+
outputDir,
1609+
timelineEnd: 5,
1610+
});
1611+
1612+
expect(result.errors).toEqual([]);
1613+
expect(result.extracted).toHaveLength(2);
1614+
const insideFrame = result.extracted[0]?.framePaths.get(0);
1615+
const pastFrame = result.extracted[1]?.framePaths.get(0);
1616+
expect(insideFrame).toBeDefined();
1617+
expect(pastFrame).toBeDefined();
1618+
if (!insideFrame || !pastFrame) throw new Error("expected both final-frame outputs");
1619+
expect(readFileSync(pastFrame)).toEqual(readFileSync(insideFrame));
1620+
}, 30_000);
15511621
});
15521622

15531623
// Regression test for the VFR (variable frame rate) freeze bug.

‎packages/engine/src/services/videoFrameExtractor.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,11 @@ type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> &
10151015
Partial<Pick<VideoElement, "playbackRate">> &
10161016
Partial<Pick<VideoElement, "loop">>;
10171017

1018+
function canHoldFinalFramePastEof(video: TimelineWindowVideo): boolean {
1019+
const timelineDuration = video.end - video.start;
1020+
return !video.loop && Number.isFinite(timelineDuration) && timelineDuration > 0;
1021+
}
1022+
10181023
// Logical duration assigned to a one-frame held-tail representation. This is
10191024
// deliberately below any supported output frame interval: coverage expects
10201025
// one frame, while FFmpeg seeks to the separately probed real frame timestamp.
@@ -1118,6 +1123,18 @@ export function resolveTimelineExtractionWindow(
11181123
},
11191124
visibleDuration,
11201125
);
1126+
} else if (canHoldFinalFramePastEof(video)) {
1127+
const logicalDuration = Math.min(sourceDuration, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
1128+
return withTimelineDuration(
1129+
{
1130+
compositionStart: video.start,
1131+
mediaStart: sourceDuration - logicalDuration,
1132+
durationSeconds: logicalDuration,
1133+
preserveTimelineEnd: true,
1134+
ensureFinalFrame: true,
1135+
},
1136+
visibleDuration,
1137+
);
11211138
}
11221139
}
11231140
return withTimelineDuration(
@@ -1155,7 +1172,10 @@ export async function resolveFinalFrameExtractionWindow(
11551172
if (window.mediaStart < finalFrameTimestamp - 1e-9) return window;
11561173

11571174
const sourceRemaining = playableDuration - video.mediaStart;
1158-
const logicalDuration = Math.min(sourceRemaining, FINAL_FRAME_LOGICAL_DURATION_SECONDS);
1175+
const logicalDuration = Math.min(
1176+
Math.max(sourceRemaining, window.durationSeconds),
1177+
FINAL_FRAME_LOGICAL_DURATION_SECONDS,
1178+
);
11591179
return {
11601180
compositionStart: Math.max(0, video.start),
11611181
mediaStart: playableDuration - logicalDuration,
@@ -1184,7 +1204,9 @@ export function resolveVideoExtractionWindow(
11841204
`Playable video stream duration is ${playableDuration}s`,
11851205
);
11861206
}
1187-
if (video.mediaStart >= playableDuration) {
1207+
const requestedTimelineDuration = video.end - video.start;
1208+
const heldPastEof = video.mediaStart >= playableDuration && canHoldFinalFramePastEof(video);
1209+
if (video.mediaStart >= playableDuration && !heldPastEof) {
11881210
throw new VideoSourceExtractionError(
11891211
"media_start_out_of_range",
11901212
false,
@@ -1193,13 +1215,17 @@ export function resolveVideoExtractionWindow(
11931215
);
11941216
}
11951217
const playbackRate = normalizePlaybackRate(video.playbackRate ?? 1);
1196-
const requestedTimelineDuration = video.end - video.start;
11971218
const resolvedDuration =
11981219
Number.isFinite(requestedTimelineDuration) && requestedTimelineDuration > 0
11991220
? requestedTimelineDuration
12001221
: resolveSegmentDuration(requestedTimelineDuration, video.mediaStart, playableDuration) /
12011222
playbackRate;
1202-
return resolveTimelineExtractionWindow(video, resolvedDuration, timelineEnd, playableDuration);
1223+
return resolveTimelineExtractionWindow(
1224+
video,
1225+
resolvedDuration,
1226+
timelineEnd ?? (heldPastEof ? video.end : undefined),
1227+
playableDuration,
1228+
);
12031229
}
12041230

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

1726-
// Guard against mediaStart past EOF — FFmpeg's `-ss` silently produces
1727-
// a 0-byte file when seeking beyond the source duration, and the
1728-
// downstream extractor then points at a broken input.
1752+
// Guard past-EOF windows that cannot use the non-looping held-tail plan.
1753+
// FFmpeg's `-ss` otherwise silently produces a 0-byte intermediate.
17291754
const playableDuration = resolvePlayableVideoDuration(metadata);
1730-
if (entry.video.mediaStart >= playableDuration) {
1755+
if (entry.video.mediaStart >= playableDuration && !canHoldFinalFramePastEof(entry.video)) {
17311756
errors.push({
17321757
videoId: entry.video.id,
17331758
kind: "media_start_out_of_range",

0 commit comments

Comments
 (0)