Skip to content

Commit 209784a

Browse files
fix(producer): credit held video tails in coverage gate (#2606)
* fix(producer): credit held video tails in coverage gate * test(producer): decouple makeExtracted's durationSeconds default from delivered frame count Defaulting durationSeconds to delivered/fps made any test modeling a delivery shortfall silently report full coverage unless it remembered to override durationSeconds afterward. Default to Infinity instead so callers fall into the 'no usable source duration' branch (full slot required) unless they explicitly pass a duration.
1 parent 2b65b4e commit 209784a

2 files changed

Lines changed: 68 additions & 4 deletions

File tree

packages/producer/src/services/render/videoFrameCoverage.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,27 @@ function makeVideo(overrides: Partial<VideoElement> & { id: string }): VideoElem
2222
};
2323
}
2424

25-
function makeExtracted(videoId: string, delivered: number, fps = 30): ExtractedFrames {
25+
// `durationSeconds` defaults to Infinity, not `delivered / fps` — the two are
26+
// unrelated in production (source duration comes from ffprobe; `delivered`
27+
// is how many frames the extractor happened to deliver) and coupling them
28+
// here made any test modeling a delivery shortfall silently report full
29+
// coverage unless it remembered to override durationSeconds afterward. An
30+
// unbounded default instead falls into computeVideoFrameCoverage's "no
31+
// usable source duration" branch, which requires the full authored slot —
32+
// the same behavior every test had before source-duration crediting
33+
// existed. Tests that care about a specific source duration (the held-tail
34+
// cases below) pass it explicitly.
35+
function makeExtracted(
36+
videoId: string,
37+
delivered: number,
38+
options: { fps?: number; durationSeconds?: number } = {},
39+
): ExtractedFrames {
40+
const { fps = 30, durationSeconds = Number.POSITIVE_INFINITY } = options;
2641
const framePaths = new Map<number, string>();
2742
for (let i = 0; i < delivered; i += 1) framePaths.set(i, `/tmp/${videoId}/${i}.jpg`);
2843
const metadata: VideoMetadata = {
29-
durationSeconds: delivered / fps,
30-
videoStreamDurationSeconds: delivered / fps,
44+
durationSeconds,
45+
videoStreamDurationSeconds: durationSeconds,
3146
width: 1280,
3247
height: 720,
3348
fps,
@@ -132,6 +147,44 @@ describe("computeVideoFrameCoverage", () => {
132147
expect(reports[0]!.capturedFrames).toBe(5);
133148
expect(reports[0]!.ratio).toBeCloseTo(5 / 30, 5);
134149
});
150+
151+
it("credits a non-looping held tail against the source portion only", () => {
152+
const videos = [makeVideo({ id: "held", start: 0, end: 10 })];
153+
const extracted = [makeExtracted("held", 90, { durationSeconds: 3 })];
154+
const reports = computeVideoFrameCoverage(videos, extracted, 30);
155+
expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 });
156+
});
157+
158+
it("still requires the full authored slot for looping clips", () => {
159+
const videos = [makeVideo({ id: "loop", start: 0, end: 10, loop: true })];
160+
const extracted = [makeExtracted("loop", 90, { durationSeconds: 3 })];
161+
const reports = computeVideoFrameCoverage(videos, extracted, 30);
162+
expect(reports[0]).toMatchObject({ expectedFrames: 300, capturedFrames: 90 });
163+
expect(reports[0]!.ratio).toBeCloseTo(0.3, 5);
164+
});
165+
166+
it("still fails when extraction is truncated before the held-tail source", () => {
167+
const videos = [makeVideo({ id: "truncated", start: 0, end: 10 })];
168+
const extracted = [makeExtracted("truncated", 60, { durationSeconds: 3 })];
169+
const reports = computeVideoFrameCoverage(videos, extracted, 30);
170+
expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 60 });
171+
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
172+
});
173+
174+
it("fails closed for invalid or exhausted source durations", () => {
175+
const videos = [
176+
makeVideo({ id: "zero", start: 0, end: 10 }),
177+
makeVideo({ id: "trimmed-away", start: 0, end: 10, mediaStart: 4 }),
178+
];
179+
const extracted = [
180+
makeExtracted("zero", 0, { durationSeconds: 0 }),
181+
makeExtracted("trimmed-away", 30, { durationSeconds: 3 }),
182+
];
183+
const reports = computeVideoFrameCoverage(videos, extracted, 30);
184+
expect(reports[0]).toMatchObject({ expectedFrames: 300, capturedFrames: 0 });
185+
expect(reports[1]).toMatchObject({ expectedFrames: 300, capturedFrames: 30 });
186+
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
187+
});
135188
});
136189

137190
describe("assertVideoFrameCoverage", () => {

packages/producer/src/services/render/videoFrameCoverage.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,18 @@ export function computeVideoFrameCoverage(
140140
const reports: VideoFrameCoverageReport[] = [];
141141
for (const video of videos) {
142142
const entry = byId.get(video.id);
143-
const expectedFrames = expectedFramesForClip(video.start, video.end, fps);
143+
const slotFrames = expectedFramesForClip(video.start, video.end, fps);
144+
// Non-looping clips intentionally hold their final decoded frame when the
145+
// authored slot outlasts the source (#2516). Coverage must therefore
146+
// measure the source portion, while still requiring the full slot for
147+
// looping clips and for missing extractions (where no hold is possible).
148+
const sourceDuration = entry ? entry.metadata.durationSeconds - video.mediaStart : NaN;
149+
const hasUsableSourceDuration = Number.isFinite(sourceDuration) && sourceDuration > 0;
150+
const sourceFrames =
151+
entry && !video.loop && hasUsableSourceDuration
152+
? expectedFramesForClip(0, sourceDuration, fps)
153+
: slotFrames;
154+
const expectedFrames = entry && !video.loop ? Math.min(slotFrames, sourceFrames) : slotFrames;
144155
// framePaths is a Map — `size` is the number of distinct captured frames
145156
// delivered to the runtime injector, which is the load-bearing count
146157
// (some extractors report a total that includes cache-hit-skipped frames

0 commit comments

Comments
 (0)