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 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
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
renderno 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 on0.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
loopexists.Repro
A non-looping short clip in a longer slot renders fine (thanks to #2606):
Add
loop— same source, same slot — and it aborts:Actual
(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:For a loop,
expectedFramesstaysslotFrames(300) whilecapturedFramesisframePaths.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 framesmetric can't distinguish an intended loop from a blank clip, so every short-source loop is blocked unless the guard is globally disabled viaHF_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.loopexclusion 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 deliversslotFramesunique 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).Verified
pr-loop-test.html(3s clip,data-duration="10",loop) renders clean by default onhyperframes@0.7.59(10s MP4, no abort); aborts with the message above on0.7.60,0.7.64.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).0.7.64, confirming only the loop branch is affected.Environment
0.7.64(regressed in0.7.60; last-good0.7.59)v24.15.0