Skip to content

Commit 7a5277e

Browse files
committed
fix: bound held-tail frame extraction
1 parent 69692fa commit 7a5277e

3 files changed

Lines changed: 258 additions & 50 deletions

File tree

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

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -124,34 +124,67 @@ describe("resolveVideoExtractionDuration", () => {
124124
2,
125125
),
126126
).toEqual({
127-
compositionStart: -5,
128-
mediaStart: 0,
129-
durationSeconds: 3,
130-
preserveTimelinePhase: true,
127+
compositionStart: -3,
128+
mediaStart: 2,
129+
durationSeconds: 1,
130+
preserveTimelineEnd: true,
131131
});
132132
});
133133

134134
it.each([
135-
{ loop: true, label: "loop" },
136-
{ loop: false, label: "held tail" },
137-
])("caps a finite long slot to one short source range for $label playback", ({ loop }) => {
138-
expect(resolveVideoExtractionWindow(video({ end: 60, loop }), metadata(3), 60)).toEqual({
135+
{ loop: true, preservation: { preserveTimelinePhase: true }, label: "loop" },
136+
{ loop: false, preservation: { preserveTimelineEnd: true }, label: "held tail" },
137+
])(
138+
"caps a finite long slot to one short source range for $label playback",
139+
({ loop, preservation }) => {
140+
expect(resolveVideoExtractionWindow(video({ end: 60, loop }), metadata(3), 60)).toEqual({
141+
compositionStart: 0,
142+
mediaStart: 0,
143+
durationSeconds: 3,
144+
...preservation,
145+
});
146+
},
147+
);
148+
149+
it("preserves authored timing when the visible interval partially crosses a held tail", () => {
150+
expect(
151+
resolveVideoExtractionWindow(
152+
video({ start: -2, end: 10, mediaStart: 0, loop: false }),
153+
metadata(3),
154+
2,
155+
),
156+
).toEqual({
139157
compositionStart: 0,
140-
mediaStart: 0,
141-
durationSeconds: 3,
142-
preserveTimelinePhase: true,
158+
mediaStart: 2,
159+
durationSeconds: 1,
160+
preserveTimelineEnd: true,
143161
});
144162
});
145163

146-
it("preserves authored timing when the visible interval partially crosses a held tail", () => {
164+
it("bounds an entirely held long source to its final-frame sample", () => {
147165
expect(
148166
resolveVideoExtractionWindow(
149-
video({ start: -2, end: 10, mediaStart: 0, loop: false }),
167+
video({ start: -600, end: 10, mediaStart: 0, loop: false }),
168+
metadata(120),
169+
2,
170+
),
171+
).toEqual({
172+
compositionStart: -481,
173+
mediaStart: 119,
174+
durationSeconds: 1,
175+
preserveTimelineEnd: true,
176+
});
177+
});
178+
179+
it("preserves a complete loop cycle when visibility ends exactly on a wrap boundary", () => {
180+
expect(
181+
resolveVideoExtractionWindow(
182+
video({ start: -1, end: 10, mediaStart: 0, loop: true }),
150183
metadata(3),
151184
2,
152185
),
153186
).toEqual({
154-
compositionStart: -2,
187+
compositionStart: -1,
155188
mediaStart: 0,
156189
durationSeconds: 3,
157190
preserveTimelinePhase: true,
@@ -167,6 +200,30 @@ describe("resolveVideoExtractionDuration", () => {
167200
});
168201
});
169202

203+
it("never plans more extraction than the playable source range", () => {
204+
for (const loop of [false, true]) {
205+
for (const sourceDuration of [0.5, 3, 120]) {
206+
for (const mediaStart of [0, sourceDuration / 3]) {
207+
for (const start of [-600, -5, -1, 0, 2]) {
208+
const window = resolveVideoExtractionWindow(
209+
video({ start, end: start + 60, mediaStart, loop }),
210+
metadata(sourceDuration),
211+
10,
212+
);
213+
expect(window.durationSeconds).toBeLessThanOrEqual(sourceDuration - mediaStart);
214+
expect(window.durationSeconds).toBeGreaterThanOrEqual(0);
215+
}
216+
}
217+
}
218+
}
219+
});
220+
221+
it("rejects a media start at source EOF before planning extraction", () => {
222+
expect(() =>
223+
resolveVideoExtractionWindow(video({ mediaStart: 3 }), metadata(3), 10),
224+
).toThrowError(expect.objectContaining({ kind: "media_start_out_of_range", retryable: false }));
225+
});
226+
170227
it("rebases a loop phase when the visible window stays within one cycle", () => {
171228
expect(
172229
resolveVideoExtractionWindow(
@@ -1228,7 +1285,10 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
12281285
const extracted = result.extracted[0];
12291286
if (!extracted) throw new Error("expected held-tail source frames");
12301287
const lookup = createFrameLookupTable([video], result.extracted);
1231-
expect(video).toMatchObject({ start: -15, mediaStart: 0, loop: false });
1288+
// The authored slot remains active through end=5, but extraction is
1289+
// rebased to the source's final one-second sample instead of materializing
1290+
// the full ten-second source just to hold its last frame.
1291+
expect(video).toMatchObject({ start: -6, end: 5, mediaStart: 9, loop: false });
12321292
expect(lookup.getFrame("negative-held-tail", 0)).toBe(
12331293
extracted.framePaths.get(extracted.totalFrames - 1),
12341294
);
@@ -1453,6 +1513,52 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
14531513
rmSync(CACHE_DIR, { recursive: true, force: true });
14541514
}, 60_000);
14551515

1516+
it("reuses one-cycle loop extraction across different authored starts", async () => {
1517+
const cacheDir = mkdtempSync(join(tmpdir(), "hf-extract-loop-phase-cache-test-"));
1518+
const src = await synthCfrClip("cache-loop-phase-src.mp4", 3);
1519+
try {
1520+
const firstOutputDir = join(FIXTURE_DIR, "out-cache-loop-phase-first");
1521+
const secondOutputDir = join(FIXTURE_DIR, "out-cache-loop-phase-second");
1522+
mkdirSync(firstOutputDir, { recursive: true });
1523+
mkdirSync(secondOutputDir, { recursive: true });
1524+
1525+
const first = await extractAllVideoFrames(
1526+
[
1527+
{
1528+
...cfrClipElement("loop-cache-first", src, 60),
1529+
loop: true,
1530+
},
1531+
],
1532+
FIXTURE_DIR,
1533+
{ fps: 30, outputDir: firstOutputDir, timelineEnd: 60 },
1534+
undefined,
1535+
{ extractCacheDir: cacheDir },
1536+
);
1537+
expect(first.errors).toEqual([]);
1538+
expect(first.phaseBreakdown.cacheMisses).toBe(1);
1539+
1540+
const second = await extractAllVideoFrames(
1541+
[
1542+
{
1543+
...cfrClipElement("loop-cache-second", src, 66),
1544+
start: -6,
1545+
loop: true,
1546+
},
1547+
],
1548+
FIXTURE_DIR,
1549+
{ fps: 30, outputDir: secondOutputDir, timelineEnd: 60 },
1550+
undefined,
1551+
{ extractCacheDir: cacheDir },
1552+
);
1553+
expect(second.errors).toEqual([]);
1554+
expect(second.phaseBreakdown.cacheHits).toBe(1);
1555+
expect(second.phaseBreakdown.cacheMisses).toBe(0);
1556+
expect(second.extracted[0]?.totalFrames).toBe(first.extracted[0]?.totalFrames);
1557+
} finally {
1558+
rmSync(cacheDir, { recursive: true, force: true });
1559+
}
1560+
}, 60_000);
1561+
14561562
it("updates the cache sentinel mtime on a hit", async () => {
14571563
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-cache-touch-test-"));
14581564
const SRC = await synthCfrClip("cache-touch-src.mp4", 1);

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -740,16 +740,27 @@ export interface TimelineExtractionWindow {
740740
durationSeconds: number;
741741
/**
742742
* Preserve the authored timeline origin and mediaStart for lookup. This is
743-
* required when a negative preroll crosses a source boundary: looped media
744-
* still needs its modulo phase, while a non-looping authored slot still
745-
* needs the extracted final frame for held-tail playback.
743+
* required when a looped visible interval crosses a source boundary and
744+
* still needs modulo phase against the complete extracted source cycle.
746745
*/
747746
preserveTimelinePhase?: boolean;
747+
/**
748+
* Keep the authored end while rebasing start/mediaStart to the extracted
749+
* source suffix. Non-looping lookup then holds the suffix's final frame
750+
* through the remainder of the authored slot.
751+
*/
752+
preserveTimelineEnd?: boolean;
748753
}
749754

750755
type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> &
751756
Partial<Pick<VideoElement, "loop">>;
752757

758+
// A clip whose visible interval begins after source EOF only needs a reliable
759+
// final-frame sample, not its entire source history. One second is long enough
760+
// to tolerate ordinary CFR/VFR timestamp rounding while keeping raw HDR
761+
// scratch bounded independently of source length.
762+
const HELD_TAIL_EXTRACTION_MAX_SECONDS = 1;
763+
753764
/**
754765
* Intersect an authored slot with the render timeline, then select the
755766
* smallest playable source range that preserves timeline lookup semantics.
@@ -793,7 +804,10 @@ export function resolveTimelineExtractionWindow(
793804
if (sourceRemaining > 0 && video.loop) {
794805
const phaseOffset = trimmedPreroll % sourceRemaining;
795806
const phaseRemaining = sourceRemaining - phaseOffset;
796-
if (visibleDuration > phaseRemaining) {
807+
// The element visibility contract includes its end boundary. Preserve a
808+
// complete cycle on equality as well, otherwise a rebased suffix would
809+
// wrap to its own first frame instead of the source cycle's first frame.
810+
if (visibleDuration >= phaseRemaining) {
797811
return {
798812
compositionStart: video.start,
799813
mediaStart: video.mediaStart,
@@ -802,15 +816,31 @@ export function resolveTimelineExtractionWindow(
802816
};
803817
}
804818
mediaStart = video.mediaStart + phaseOffset;
805-
} else if (
806-
sourceRemaining > 0 &&
807-
visibleDuration > Math.max(0, sourceRemaining - trimmedPreroll)
808-
) {
819+
} else if (sourceRemaining > 0) {
820+
const sourceVisibleAfterPreroll = Math.max(0, sourceRemaining - trimmedPreroll);
821+
if (visibleDuration <= sourceVisibleAfterPreroll) {
822+
return {
823+
compositionStart,
824+
mediaStart,
825+
durationSeconds: visibleDuration,
826+
};
827+
}
828+
829+
// The visible interval enters (or is entirely inside) the held tail.
830+
// Extract only the visible source suffix, plus a bounded final-frame
831+
// sample when preroll is already at/past EOF. Rebase lookup to the
832+
// source time represented by frame zero, but retain the authored end so
833+
// non-loop lookup can clamp to the extracted final frame.
834+
const extractionDuration = Math.min(
835+
sourceRemaining,
836+
Math.max(sourceVisibleAfterPreroll, HELD_TAIL_EXTRACTION_MAX_SECONDS),
837+
);
838+
const extractionOffset = sourceRemaining - extractionDuration;
809839
return {
810-
compositionStart: video.start,
811-
mediaStart: video.mediaStart,
812-
durationSeconds: sourceRemaining,
813-
preserveTimelinePhase: true,
840+
compositionStart: video.start + extractionOffset,
841+
mediaStart: video.mediaStart + extractionOffset,
842+
durationSeconds: extractionDuration,
843+
preserveTimelineEnd: true,
814844
};
815845
}
816846
}
@@ -827,6 +857,22 @@ export function resolveVideoExtractionWindow(
827857
metadata: VideoMetadata,
828858
timelineEnd?: number,
829859
): TimelineExtractionWindow {
860+
if (!(metadata.durationSeconds > 0)) {
861+
throw new VideoSourceExtractionError(
862+
"invalid_media",
863+
false,
864+
"Video source has no positive duration",
865+
`Video source duration is ${metadata.durationSeconds}s`,
866+
);
867+
}
868+
if (video.mediaStart >= metadata.durationSeconds) {
869+
throw new VideoSourceExtractionError(
870+
"media_start_out_of_range",
871+
false,
872+
"Video media start is outside the source duration",
873+
`Video media start ${video.mediaStart}s is outside source duration ${metadata.durationSeconds}s`,
874+
);
875+
}
830876
const resolvedDuration = resolveSegmentDuration(
831877
video.end - video.start,
832878
video.mediaStart,
@@ -1649,7 +1695,9 @@ export async function extractAllVideoFrames(
16491695
}
16501696
if (!window.preserveTimelinePhase) {
16511697
video.start = window.compositionStart;
1652-
video.end = window.compositionStart + videoDuration;
1698+
if (!window.preserveTimelineEnd) {
1699+
video.end = window.compositionStart + videoDuration;
1700+
}
16531701
video.mediaStart = window.mediaStart;
16541702
}
16551703
const keyInput = cacheKeyInputs[index];

0 commit comments

Comments
 (0)