Repository navigation
Description
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.60–0.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 [email protected] 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.30For 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 like0.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[email protected](10s MP4, no abort); aborts with the message above on0.7.60,0.7.64. - Loop content is correct, only the gate blocks it: with
HF_VIDEO_COVERAGE_THRESHOLD=0on0.7.64it 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 in0.7.60; last-good0.7.59) - macOS (arm64), Node
v24.15.0
Activity
[-]Regression: video coverage gate aborts renders of a **looping** video shorter than its slot (#2516 / #2606)[/-][+]Regression: video coverage gate aborts renders of a looping video shorter than its slot (#2516 / #2606)[/+]fix(producer): credit looping short videos in coverage gate (heygen-c…
fix(producer): credit looping short videos in coverage gate (heygen-c…