Skip to content

Commit 2b3df99

Browse files
committed
fix(engine): exports no longer fail on a video clip outside the composition's length
A clip that starts at or after the composition's end (or ends before 0) is never on screen, so extraction skips it. Its timeline window stayed as authored, so the frame-coverage gate still expected its full length and aborted the render with "captured 0 of expected N frames". Such a clip now leaves extraction with an empty window, the same way a partly visible clip is already trimmed to its visible part, so it owes no frames.
1 parent bb9e507 commit 2b3df99

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2286,6 +2286,31 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
22862286
});
22872287

22882288
expect(result).toMatchObject({ success: true, extracted: [], errors: [] });
2289+
// Frame coverage reads this window; a clip never on screen owes no frames.
2290+
expect(video).toMatchObject({ start: -2, end: -2 });
2291+
});
2292+
2293+
it("gives a clip starting after the timeline end an empty window, not an error", async () => {
2294+
const outputDir = join(FIXTURE_DIR, "out-after-timeline");
2295+
mkdirSync(outputDir, { recursive: true });
2296+
const video: VideoElement = {
2297+
id: "after-timeline",
2298+
src: VFR_FIXTURE,
2299+
start: 4,
2300+
end: 6,
2301+
mediaStart: 0,
2302+
loop: false,
2303+
hasAudio: false,
2304+
};
2305+
2306+
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
2307+
fps: 1,
2308+
outputDir,
2309+
timelineEnd: 3,
2310+
});
2311+
2312+
expect(result).toMatchObject({ success: true, extracted: [], errors: [] });
2313+
expect(video).toMatchObject({ start: 4, end: 4 });
22892314
});
22902315

22912316
it("preserves loop phase when negative preroll crosses the source boundary", async () => {

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,11 @@ function canHoldFinalFramePastEof(video: TimelineWindowVideo): boolean {
13481348
// one frame, while FFmpeg seeks to the separately probed real frame timestamp.
13491349
const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
13501350

1351+
/** A clip entirely outside the render timeline is never on screen, so it owes no frames. */
1352+
function clearVideoWindow(video: VideoElement): void {
1353+
video.end = video.start;
1354+
}
1355+
13511356
/** Move a clip to its extraction window; a rate lane is shifted so it keeps integrating from the new origin. */
13521357
export function rebaseVideoToWindow(video: VideoElement, window: TimelineExtractionWindow): void {
13531358
if (window.preserveTimelinePhase) return;
@@ -1868,7 +1873,10 @@ export async function extractAllVideoFrames(
18681873
const warnedSrcs = new Set<string>();
18691874
for (const video of videos) {
18701875
if (signal?.aborted) break;
1871-
if (options.timelineEnd !== undefined && video.start >= options.timelineEnd) continue;
1876+
if (options.timelineEnd !== undefined && video.start >= options.timelineEnd) {
1877+
clearVideoWindow(video);
1878+
continue;
1879+
}
18721880
try {
18731881
let videoPath = video.src;
18741882
if (!isHttpUrl(videoPath)) {
@@ -2452,6 +2460,7 @@ export async function extractAllVideoFrames(
24522460
);
24532461
const videoDuration = window.durationSeconds;
24542462
if (videoDuration <= 0) {
2463+
clearVideoWindow(video);
24552464
return { skipped: true };
24562465
}
24572466
rebaseVideoToWindow(video, window);

0 commit comments

Comments
 (0)