diff --git a/packages/producer/src/services/render/videoFrameCoverage.test.ts b/packages/producer/src/services/render/videoFrameCoverage.test.ts index 7ba284e760..9b018d17e9 100644 --- a/packages/producer/src/services/render/videoFrameCoverage.test.ts +++ b/packages/producer/src/services/render/videoFrameCoverage.test.ts @@ -155,12 +155,25 @@ describe("computeVideoFrameCoverage", () => { expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 }); }); - it("still requires the full authored slot for looping clips", () => { + it("credits a looping short clip against the source portion — the delivered frame set covers every repeat (#2665)", () => { + // Regression #2665: a looping video shorter than its slot delivered all + // its source frames (extractor complete), but the pre-fix gate measured + // 90 unique / 300 slot = 30% and aborted. Every one of the 300 output + // frames maps to one of the 90 source frames — coverage is 100%. const videos = [makeVideo({ id: "loop", start: 0, end: 10, loop: true })]; const extracted = [makeExtracted("loop", 90, { durationSeconds: 3 })]; const reports = computeVideoFrameCoverage(videos, extracted, 30); - expect(reports[0]).toMatchObject({ expectedFrames: 300, capturedFrames: 90 }); - expect(reports[0]!.ratio).toBeCloseTo(0.3, 5); + expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 }); + }); + + it("still fails when a looping clip's source extraction is truncated", () => { + // Fail-loud preserved for a genuinely-broken loop: only 60/90 source + // frames arrived, so the delivered set does NOT cover every repeat. + const videos = [makeVideo({ id: "truncated-loop", start: 0, end: 10, loop: true })]; + const extracted = [makeExtracted("truncated-loop", 60, { durationSeconds: 3 })]; + const reports = computeVideoFrameCoverage(videos, extracted, 30); + expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 60 }); + expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError); }); it("still fails when extraction is truncated before the held-tail source", () => { diff --git a/packages/producer/src/services/render/videoFrameCoverage.ts b/packages/producer/src/services/render/videoFrameCoverage.ts index 0d75a05474..3e8f1896d6 100644 --- a/packages/producer/src/services/render/videoFrameCoverage.ts +++ b/packages/producer/src/services/render/videoFrameCoverage.ts @@ -141,17 +141,19 @@ export function computeVideoFrameCoverage( for (const video of videos) { const entry = byId.get(video.id); const slotFrames = expectedFramesForClip(video.start, video.end, fps); - // Non-looping clips intentionally hold their final decoded frame when the - // authored slot outlasts the source (#2516). Coverage must therefore - // measure the source portion, while still requiring the full slot for - // looping clips and for missing extractions (where no hold is possible). + // A short source in a longer slot has a legitimate delivery ceiling of + // the source portion, not the full slot: a non-looping clip holds its + // final decoded frame across the tail (#2516/#2606), and a looping clip + // reuses its full source frame set per repeat (#2665). In both cases the + // full source *has* been delivered — the same 90 unique source frames + // cover the 300-frame slot — so coverage must measure source-source, not + // slot-source. A missing extraction (no `entry`) still requires the full + // slot; there's no delivered set to credit. const sourceDuration = entry ? entry.metadata.durationSeconds - video.mediaStart : NaN; const hasUsableSourceDuration = Number.isFinite(sourceDuration) && sourceDuration > 0; const sourceFrames = - entry && !video.loop && hasUsableSourceDuration - ? expectedFramesForClip(0, sourceDuration, fps) - : slotFrames; - const expectedFrames = entry && !video.loop ? Math.min(slotFrames, sourceFrames) : slotFrames; + entry && hasUsableSourceDuration ? expectedFramesForClip(0, sourceDuration, fps) : slotFrames; + const expectedFrames = entry ? Math.min(slotFrames, sourceFrames) : slotFrames; // framePaths is a Map — `size` is the number of distinct captured frames // delivered to the runtime injector, which is the load-bearing count // (some extractors report a total that includes cache-hit-skipped frames