fix(producer): credit looping short videos in coverage gate (#2665) (#2732)

#2606 taught the video-coverage gate that a non-looping short video holds
its final decoded frame across the tail, so the delivered source frames
are enough to cover the authored slot. But that fix gated the credit on
'!video.loop' — a looping short video was still measured as
unique-source-frames / slot-frames and aborted at ratio << threshold.

Reproduced on 0.7.64 with the reporter's exact composition (3s clip in a
10s slot, loop attribute): render aborts with 'captured 90 of expected
300 frames (coverage 30.0%)'. Same source without loop renders clean via
#2606's freeze credit. This is the mainline 'loop a short clip to fill a
longer scene' case, the reason loop exists.

Fix: extend #2606's source-credit to loops symmetrically — the delivered
set (all N source frames) covers every repeat within the slot, so
expectedFrames = min(slotFrames, sourceFrames) for both hold and loop.
Fail-loud preserved for a genuinely-broken loop (extractor truncated
below its own source): a 60/90 delivery still aborts at 66.7% < 95%
because the delivered set no longer covers the full source period the
loop reuses. Missing extractions still require the full slot.

Test updates:
- 'still requires the full authored slot for looping clips' locked in
  the buggy behavior; replaced with 'credits a looping short clip
  against the source portion' which asserts the correct 90/90/1.0.
- Added 'still fails when a looping clip's source extraction is
  truncated' as the new fail-loud floor.

Fixes #2665. Regression window: 0.7.60 (#2606's original ship)
through 0.7.67 (current).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-07-22 00:19:51 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 69446e7726
commit a637f394ee
2 changed files with 26 additions and 11 deletions
@@ -155,12 +155,25 @@ describe("computeVideoFrameCoverage", () => {
expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 }); 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 videos = [makeVideo({ id: "loop", start: 0, end: 10, loop: true })];
const extracted = [makeExtracted("loop", 90, { durationSeconds: 3 })]; const extracted = [makeExtracted("loop", 90, { durationSeconds: 3 })];
const reports = computeVideoFrameCoverage(videos, extracted, 30); const reports = computeVideoFrameCoverage(videos, extracted, 30);
expect(reports[0]).toMatchObject({ expectedFrames: 300, capturedFrames: 90 }); expect(reports[0]).toMatchObject({ expectedFrames: 90, capturedFrames: 90, ratio: 1 });
expect(reports[0]!.ratio).toBeCloseTo(0.3, 5); });
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", () => { it("still fails when extraction is truncated before the held-tail source", () => {
@@ -141,17 +141,19 @@ export function computeVideoFrameCoverage(
for (const video of videos) { for (const video of videos) {
const entry = byId.get(video.id); const entry = byId.get(video.id);
const slotFrames = expectedFramesForClip(video.start, video.end, fps); const slotFrames = expectedFramesForClip(video.start, video.end, fps);
// Non-looping clips intentionally hold their final decoded frame when the // A short source in a longer slot has a legitimate delivery ceiling of
// authored slot outlasts the source (#2516). Coverage must therefore // the source portion, not the full slot: a non-looping clip holds its
// measure the source portion, while still requiring the full slot for // final decoded frame across the tail (#2516/#2606), and a looping clip
// looping clips and for missing extractions (where no hold is possible). // 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 sourceDuration = entry ? entry.metadata.durationSeconds - video.mediaStart : NaN;
const hasUsableSourceDuration = Number.isFinite(sourceDuration) && sourceDuration > 0; const hasUsableSourceDuration = Number.isFinite(sourceDuration) && sourceDuration > 0;
const sourceFrames = const sourceFrames =
entry && !video.loop && hasUsableSourceDuration entry && hasUsableSourceDuration ? expectedFramesForClip(0, sourceDuration, fps) : slotFrames;
? expectedFramesForClip(0, sourceDuration, fps) const expectedFrames = entry ? Math.min(slotFrames, sourceFrames) : slotFrames;
: slotFrames;
const expectedFrames = entry && !video.loop ? Math.min(slotFrames, sourceFrames) : slotFrames;
// framePaths is a Map — `size` is the number of distinct captured frames // framePaths is a Map — `size` is the number of distinct captured frames
// delivered to the runtime injector, which is the load-bearing count // delivered to the runtime injector, which is the load-bearing count
// (some extractors report a total that includes cache-hit-skipped frames // (some extractors report a total that includes cache-hit-skipped frames