Skip to content

Commit 4b2e689

Browse files
committed
fix(producer): drop clips outside the timeline before extraction, cloud renders included
The previous commit marked such a clip with end = start, but the window resolver reads a zero-length slot as "use the source's natural length", and the distributed plan still required an extracted entry for every declared clip. Instead, the extract stage (shared by in-process and distributed renders) removes clips that lie entirely outside [0, duration) from the composition before probing and extraction, using one engine predicate that the extractor's own early skip also uses, so a clip ending before 0 is no longer downloaded or probed either.
1 parent 2b3df99 commit 4b2e689

5 files changed

Lines changed: 52 additions & 26 deletions

File tree

‎packages/engine/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ export {
204204
parseImageElements,
205205
extractVideoFramesRange,
206206
extractAllVideoFrames,
207+
isVideoOutsideTimeline,
207208
resolveTimelineExtractionWindow,
208209
resolveVideoExtractionWindow,
209210
resolveFinalFrameExtractionWindow,

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

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2286,31 +2286,28 @@ 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 });
22912289
});
22922290

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");
2291+
it("never opens the source of a clip outside the timeline", async () => {
2292+
const outputDir = join(FIXTURE_DIR, "out-outside-missing");
22952293
mkdirSync(outputDir, { recursive: true });
2296-
const video: VideoElement = {
2297-
id: "after-timeline",
2298-
src: VFR_FIXTURE,
2299-
start: 4,
2300-
end: 6,
2294+
const outside = (id: string, start: number, end: number): VideoElement => ({
2295+
id,
2296+
src: "missing-source.mp4",
2297+
start,
2298+
end,
23012299
mediaStart: 0,
23022300
loop: false,
23032301
hasAudio: false,
2304-
};
2305-
2306-
const result = await extractAllVideoFrames([video], FIXTURE_DIR, {
2307-
fps: 1,
2308-
outputDir,
2309-
timelineEnd: 3,
23102302
});
23112303

2304+
const result = await extractAllVideoFrames(
2305+
[outside("before-zero", -2, -1), outside("after-end", 4, 6)],
2306+
FIXTURE_DIR,
2307+
{ fps: 1, outputDir, timelineEnd: 3 },
2308+
);
2309+
23122310
expect(result).toMatchObject({ success: true, extracted: [], errors: [] });
2313-
expect(video).toMatchObject({ start: 4, end: 4 });
23142311
});
23152312

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

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,11 +1348,6 @@ 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-
13561351
/** Move a clip to its extraction window; a rate lane is shifted so it keeps integrating from the new origin. */
13571352
export function rebaseVideoToWindow(video: VideoElement, window: TimelineExtractionWindow): void {
13581353
if (window.preserveTimelinePhase) return;
@@ -1376,6 +1371,13 @@ export function rebaseVideoToWindow(video: VideoElement, window: TimelineExtract
13761371
* timeline origin separate from the extracted range is what makes both
13771372
* behaviours survive the source-duration cap.
13781373
*/
1374+
export function isVideoOutsideTimeline(
1375+
video: Pick<VideoElement, "start" | "end">,
1376+
timelineEnd: number,
1377+
): boolean {
1378+
return video.start >= timelineEnd || video.end <= 0;
1379+
}
1380+
13791381
export function resolveTimelineExtractionWindow(
13801382
video: TimelineWindowVideo,
13811383
resolvedDuration: number,
@@ -1873,8 +1875,7 @@ export async function extractAllVideoFrames(
18731875
const warnedSrcs = new Set<string>();
18741876
for (const video of videos) {
18751877
if (signal?.aborted) break;
1876-
if (options.timelineEnd !== undefined && video.start >= options.timelineEnd) {
1877-
clearVideoWindow(video);
1878+
if (options.timelineEnd !== undefined && isVideoOutsideTimeline(video, options.timelineEnd)) {
18781879
continue;
18791880
}
18801881
try {
@@ -2460,7 +2461,6 @@ export async function extractAllVideoFrames(
24602461
);
24612462
const videoDuration = window.durationSeconds;
24622463
if (videoDuration <= 0) {
2463-
clearVideoWindow(video);
24642464
return { skipped: true };
24652465
}
24662466
rebaseVideoToWindow(video, window);

‎packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const extractionCalls = vi.hoisted(
1212
);
1313
const toneMapHdrToSdrCalls = vi.hoisted(() => new Array<boolean | undefined>());
1414
const contentKeyedDirsCalls = vi.hoisted(() => new Array<readonly string[] | undefined>());
15+
const requestedVideoIds = vi.hoisted(() => new Array<string[]>());
1516
const fixtureState = vi.hoisted(() => ({ sourceDurationSeconds: 60 }));
1617

1718
vi.mock("@hyperframes/engine", async (importOriginal) => {
@@ -27,6 +28,7 @@ vi.mock("@hyperframes/engine", async (importOriginal) => {
2728
contentKeyedDirs?: readonly string[];
2829
},
2930
): Promise<ExtractionResult> => {
31+
requestedVideoIds.push(videos.map((v) => v.id));
3032
const sourceDurationSeconds = fixtureState.sourceDurationSeconds;
3133
const video = videos[0];
3234
if (!video) throw new Error("timeline-bound fixture requires one video");
@@ -80,8 +82,8 @@ import { runExtractVideosStage } from "./extractVideosStage.js";
8082
async function runStage(
8183
compositionDuration: number,
8284
materializeSymlinks: boolean,
83-
options: { source?: string; log?: ProducerLogger } = {},
84-
): Promise<void> {
85+
options: { source?: string; log?: ProducerLogger; extraVideos?: VideoElement[] } = {},
86+
): Promise<VideoElement[]> {
8587
const composition = {
8688
duration: compositionDuration,
8789
videos: [
@@ -94,6 +96,7 @@ async function runStage(
9496
loop: false,
9597
hasAudio: false,
9698
},
99+
...(options.extraVideos ?? []),
97100
],
98101
audios: [],
99102
images: [],
@@ -115,6 +118,7 @@ async function runStage(
115118
assertNotAborted: () => {},
116119
materializeSymlinks,
117120
});
121+
return composition.videos;
118122
}
119123

120124
describe.each([
@@ -150,6 +154,26 @@ describe.each([
150154
expect(toneMapHdrToSdrCalls).toEqual([true]);
151155
});
152156

157+
it("drops clips outside the timeline before extraction", async () => {
158+
requestedVideoIds.splice(0);
159+
const outside = (id: string, start: number, end: number): VideoElement => ({
160+
id,
161+
src: "outside.mp4",
162+
start,
163+
end,
164+
mediaStart: 0,
165+
loop: false,
166+
hasAudio: false,
167+
});
168+
169+
const videos = await runStage(2, materializeSymlinks, {
170+
extraVideos: [outside("after-end", 2, 4), outside("before-zero", -3, -1)],
171+
});
172+
173+
expect(requestedVideoIds).toEqual([["root-video"]]);
174+
expect(videos.map((v) => v.id)).toEqual(["root-video"]);
175+
});
176+
153177
it("caches the compiled remote-media copies by content", async () => {
154178
contentKeyedDirsCalls.splice(0);
155179

‎packages/producer/src/services/render/stages/extractVideosStage.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
detectTransfer,
4646
extractAllVideoFrames,
4747
extractMediaMetadata,
48+
isVideoOutsideTimeline,
4849
isHdrColorSpace,
4950
resolveProjectRelativeSrc,
5051
runVideoExtractionWithRetry,
@@ -389,6 +390,9 @@ export async function runExtractVideosStage(
389390

390391
const stage2Start = Date.now();
391392
const extractionPolicy = resolveVideoExtractionPolicy();
393+
composition.videos = composition.videos.filter(
394+
(video) => !isVideoOutsideTimeline(video, composition.duration),
395+
);
392396

393397
let frameLookup: FrameLookupTable | null = null;
394398
let extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null = null;

0 commit comments

Comments
 (0)