Describe the bug
A <video class="clip"> silently stops honoring GSAP tweens (opacity, filter, transform) once its source media ends, while its data-start/data-duration window is still open. An identical tween on a sibling <div class="clip"> applies normally at the same instant.
The trigger is data-duration > (source media duration). This is easy to hit unintentionally: place a 5.2s screen capture into a 5.85s clip slot and every authored crossfade on it silently becomes a hard cut. There is no error, no warning, and lint/check report 0 findings — the composition is fully valid.
What made this expensive to diagnose is that the sibling <div> in the same clip keeps fading. The two layers desynchronize, so the artifact reads as "the previous scene is showing through the transition" rather than as a video bug.
Link to reproduction
Not provided as a repo — the repro below is fully self-contained and synthetic (~30 lines + two ffmpeg lines that generate the only assets; no external or proprietary media). Happy to push it to a public repo if that's needed for triage.
Steps to reproduce
npx hyperframes init hfrepro --non-interactive
cd hfrepro
mkdir -p assets
# the only variable in the whole repro: source length
ffmpeg -f lavfi -i color=c=white:s=640x360:r=30:d=2 -c:v libx264 -pix_fmt yuv420p assets/clip2s.mp4
ffmpeg -f lavfi -i color=c=white:s=640x360:r=30:d=4 -c:v libx264 -pix_fmt yuv420p assets/clip4s.mp4
Replace index.html with:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
.clip { position: absolute; }
#vid { left: 0; top: 0; width: 960px; height: 1080px; object-fit: cover; }
#ctl { left: 960px; top: 0; width: 960px; height: 1080px; background: #fff; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="4"
data-width="1920" data-height="1080">
<!-- Both clips occupy the same 0..4s window. Swap the src to flip the result. -->
<video id="vid" class="clip" src="assets/clip2s.mp4" muted
data-start="0" data-duration="4" data-track-index="0"></video>
<div id="ctl" class="clip" data-start="0" data-duration="4" data-track-index="1"></div>
</div>
<script>
gsap.config({ lazy: false });
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Identical linear fade on both, straddling the 2.0s media end of clip2s.mp4.
tl.to("#vid", { opacity: 0, duration: 0.8, ease: "none" }, 1.6);
tl.to("#ctl", { opacity: 0, duration: 0.8, ease: "none" }, 1.6);
window.__timelines["main"] = tl;
tl.seek(0);
</script>
</body>
</html>
Then render each variant and sample the two halves:
npx hyperframes render --format png-sequence --output .frames
# left half = the <video>, right half = the control <div>
ffmpeg -i .frames/frame_000073.png -vf "crop=800:800:80:140,scale=1:1,format=rgb24" -f rawvideo - | od -An -tu1 | head -1
ffmpeg -i .frames/frame_000073.png -vf "crop=800:800:1040:140,scale=1:1,format=rgb24" -f rawvideo - | od -An -tu1 | head -1
npx hyperframes check passes clean on this composition: Lint 0 errors, 0 warnings / Runtime 0 errors, 0 warnings.
Expected behavior
A clip whose data-duration exceeds its source duration should keep compositing (its last frame is fine) and keep honoring tweens on the element for the rest of its declared window — the same way the sibling <div> does. If instead this configuration is unsupported by design, lint should flag data-duration > media duration as an error, because today it is silent and the symptom appears somewhere else entirely.
Actual behavior
Past its media end the <video> ignores the tween completely — it never even reaches the tween's end state.
Single variable changed (src), composition HTML otherwise byte-identical. R channel of a flat white patch, 30fps, fade authored 1.6s -> 2.4s, media end at 2.0s:
| frame |
t |
src = clip2s.mp4 (2.0s, duration > media) |
src = clip4s.mp4 (4.0s, duration == media) |
|
|
<video> / <div> |
<video> / <div> |
| 49 |
1.600 |
255 / 255 |
255 / 255 |
| 61 |
2.000 |
255 / 255 |
255 / 255 |
| 69 |
2.266 |
255 / 255 |
255 / 255 |
| 73 |
2.400 |
255 / 0 |
0 / 0 |
| 77 |
2.533 |
255 / 0 |
0 / 0 |
With a source at least as long as the clip, the video tracks the control exactly. With a shorter source, it is pinned opaque forever.
Confirmed on a real 6-scene project as well: moving an identical tween wholly before the media end made it fade correctly, so the boundary is the trigger, not the tween.
Environment
✓ Version 0.8.46 (latest)
✓ Node.js v26.3.1 (darwin arm64)
✓ CPU 10 cores · Apple M5 @ 2400MHz
✓ Memory 16.0 GB total
✓ FFmpeg ffmpeg 8.1.2
✓ Chrome bundled chrome-headless-shell mac_arm-152.0.7977.30
✓ Capture beginframe
Additional context
Workaround: pad the asset so its duration is at least the clip duration, e.g. ffmpeg -i in.mp4 -vf tpad=stop=-1:stop_mode=clone -t <clip-duration> out.mp4.
Two unrelated defects found on the same 0.8.46 run, filed here only so they are not lost — happy to split into separate issues:
-
MP4 encoder tints the frame, including pure CSS/SVG content. A wordmark authored #f0f0f0 over #000 leaves Chrome at (235.96, 235.96, 235.96) and decodes out of the rendered MP4 at (233.68, 235.86, 235.67) — G and B intact, R low by 2.3. No footage involved, so this is not source tagging. --sdr does not change it. SSIM against the PNG-sequence source: CLI MP4 0.999968 overall / 0.999894 on V; a direct x264 encode pinning scale=in_range=full:out_range=limited:out_color_matrix=bt709 gives 0.999990 / 1.000000 on V. V (the R-Y axis) being the only errored plane points at an unpinned RGB->YUV conversion rather than at rate control. Workaround: render --format png-sequence and encode separately.
-
--video-bitrate is a no-op. --quality delivery --video-bitrate 20M and --video-bitrate 28M both produce files that ffprobe reports at exactly 10.0 Mbps, identical to omitting the flag. --crf is honored, so it is specific to the bitrate path. The flag is accepted silently.
Describe the bug
A
<video class="clip">silently stops honoring GSAP tweens (opacity, filter, transform) once its source media ends, while itsdata-start/data-durationwindow is still open. An identical tween on a sibling<div class="clip">applies normally at the same instant.The trigger is
data-duration > (source media duration). This is easy to hit unintentionally: place a 5.2s screen capture into a 5.85s clip slot and every authored crossfade on it silently becomes a hard cut. There is no error, no warning, andlint/checkreport 0 findings — the composition is fully valid.What made this expensive to diagnose is that the sibling
<div>in the same clip keeps fading. The two layers desynchronize, so the artifact reads as "the previous scene is showing through the transition" rather than as a video bug.Link to reproduction
Not provided as a repo — the repro below is fully self-contained and synthetic (~30 lines + two
ffmpeglines that generate the only assets; no external or proprietary media). Happy to push it to a public repo if that's needed for triage.Steps to reproduce
Replace
index.htmlwith:Then render each variant and sample the two halves:
npx hyperframes checkpasses clean on this composition:Lint 0 errors, 0 warnings / Runtime 0 errors, 0 warnings.Expected behavior
A clip whose
data-durationexceeds its source duration should keep compositing (its last frame is fine) and keep honoring tweens on the element for the rest of its declared window — the same way the sibling<div>does. If instead this configuration is unsupported by design,lintshould flagdata-duration > media durationas an error, because today it is silent and the symptom appears somewhere else entirely.Actual behavior
Past its media end the
<video>ignores the tween completely — it never even reaches the tween's end state.Single variable changed (
src), composition HTML otherwise byte-identical. R channel of a flat white patch, 30fps, fade authored 1.6s -> 2.4s, media end at 2.0s:<video>/<div><video>/<div>With a source at least as long as the clip, the video tracks the control exactly. With a shorter source, it is pinned opaque forever.
Confirmed on a real 6-scene project as well: moving an identical tween wholly before the media end made it fade correctly, so the boundary is the trigger, not the tween.
Environment
Additional context
Workaround: pad the asset so its duration is at least the clip duration, e.g.
ffmpeg -i in.mp4 -vf tpad=stop=-1:stop_mode=clone -t <clip-duration> out.mp4.Two unrelated defects found on the same 0.8.46 run, filed here only so they are not lost — happy to split into separate issues:
MP4 encoder tints the frame, including pure CSS/SVG content. A wordmark authored
#f0f0f0over#000leaves Chrome at (235.96, 235.96, 235.96) and decodes out of the rendered MP4 at (233.68, 235.86, 235.67) — G and B intact, R low by 2.3. No footage involved, so this is not source tagging.--sdrdoes not change it. SSIM against the PNG-sequence source: CLI MP4 0.999968 overall / 0.999894 on V; a direct x264 encode pinningscale=in_range=full:out_range=limited:out_color_matrix=bt709gives 0.999990 / 1.000000 on V. V (the R-Y axis) being the only errored plane points at an unpinned RGB->YUV conversion rather than at rate control. Workaround: render--format png-sequenceand encode separately.--video-bitrateis a no-op.--quality delivery --video-bitrate 20Mand--video-bitrate 28Mboth produce files thatffprobereports at exactly 10.0 Mbps, identical to omitting the flag.--crfis honored, so it is specific to the bitrate path. The flag is accepted silently.