Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/producer/src/services/render/videoFrameCoverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
18 changes: 10 additions & 8 deletions packages/producer/src/services/render/videoFrameCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading