Skip to content

Regression: video coverage gate aborts renders of a looping video shorter than its slot (#2516 / #2606) #2665

Description

@granganantjyot

Summary

Follow-up to #2565 / #2606. #2606 taught the video-coverage gate to credit the held tail of a non-looping short video (freeze), so render no longer aborts on those. But the credit is gated on !video.loop, so a looping video shorter than its slot still aborts by default.

This is a regression: the same composition rendered clean by default on 0.7.59 (the last release before the #2516 freeze work), and aborts on 0.7.600.7.64.

This is the mainline "loop a short clip to fill a longer scene" use case (source duration < scene duration) — the reason loop exists.

Repro

A non-looping short clip in a longer slot renders fine (thanks to #2606):

<video class="clip" src="short-3s.mp4" data-start="0" data-duration="10" data-track-index="0" muted playsinline></video>

Add loop — same source, same slot — and it aborts:

<video class="clip" src="short-3s.mp4" data-start="0" data-duration="10" data-track-index="0" muted playsinline loop></video>
npx hyperframes@0.7.64 render -c comp.html

Actual

Video "hero-video" captured 90 of expected 300 frames (coverage 30.0%, threshold 95.0%).
check/snapshot may pass while the encoded MP4 renders this clip blank — aborting render
to prevent shipping a wrong MP4. Set HF_VIDEO_COVERAGE_THRESHOLD=0 to disable this gate.

(90 = 3s × 30fps decoded source frames; 300 = the 10s × 30fps authored slot.)

Expected

The render completes. A looping video fills every output frame with real content (the clip repeats), so its real slot coverage is 100% — the low unique-source-frame count is expected and correct, not a blank/truncated clip.

Root cause

packages/producer/src/services/render/videoFrameCoverage.ts — the #2606 source-credit excludes loops via !video.loop:

const sourceFrames   = entry && !video.loop && hasUsableSourceDuration
  ? expectedFramesForClip(0, sourceDuration, fps)
  : slotFrames;
const expectedFrames = entry && !video.loop ? Math.min(slotFrames, sourceFrames) : slotFrames;
const capturedFrames = entry ? entry.framePaths.size : 0;   // unique source frames = 90
const ratio          = capturedFrames / expectedFrames;     // loop: 90 / 300 = 0.30

For a loop, expectedFrames stays slotFrames (300) while capturedFrames is framePaths.size (90 unique source frames, since a loop reuses the same extracted frames). Ratio = 30% < 95% → abort. It's the same false-positive class #2565 fixed for freeze, one branch over.

Why it's a false positive (same as #2565, for loop)

The gate exists to catch genuinely truncated/blank video. As with the held tail, low unique-source-frame coverage inside an explicit slot is now a supported, deliberate state for a looping video too. The gate's unique frames ÷ slot frames metric can't distinguish an intended loop from a blank clip, so every short-source loop is blocked unless the guard is globally disabled via HF_VIDEO_COVERAGE_THRESHOLD=0 — which also disables it for legitimately-broken videos in the same render.

Suggested fix

Extend the #2606 credit to loops — drop the !video.loop exclusion so a looping video also measures coverage against the source portion (Math.min(slotFrames, sourceFrames)), or, more directly, treat a looping clip as always covering 100% of its slot. A truly short/blank clip still trips the gate. (Looping a source longer than its slot is already unaffected — it delivers slotFrames unique frames → 100%.)

Workaround

  • HF_VIDEO_COVERAGE_THRESHOLD=0 (disables the gate for the whole render), or a low value like 0.2 (still catches ~0% blank clips).
  • Or pre-extend the source to ≥ scene length so it renders as a source-≥-slot loop (100% coverage, no override needed).

Verified

  • Regression bisect: pr-loop-test.html (3s clip, data-duration="10", loop) renders clean by default on hyperframes@0.7.59 (10s MP4, no abort); aborts with the message above on 0.7.60, 0.7.64.
  • Loop content is correct, only the gate blocks it: with HF_VIDEO_COVERAGE_THRESHOLD=0 on 0.7.64 it renders and genuinely loops — card-region avg RGB ≈ 129,126,116 across the tail (not blank ≈255); PSNR t=4s-vs-t=5.5s ≈ 21.8 dB (moving, not frozen); PSNR t=4s-vs-t=7s ≈ 37.2 dB (same phase on the 3s loop period → replaying).
  • Non-loop (freeze) equivalent renders clean by default on 0.7.64, confirming only the loop branch is affected.

Environment

  • hyperframes 0.7.64 (regressed in 0.7.60; last-good 0.7.59)
  • macOS (arm64), Node v24.15.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions