Skip to content

<video> clip silently ignores GSAP tweens once its source media ends (data-duration > media duration) #4049

Closed

Description

@yukij3

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/[email protected]/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:

  1. 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.

  2. --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.

Activity

  1. added
    bugSomething isn't working
    triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.
    difficulty/mediumBounded work requiring subsystem knowledge, related paths, or additional regression fixtures.
    and removed
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Oct 10, 2026
  2. miguel-heygen commented on Oct 24, 2026

    @miguel-heygen
    Collaborator

    Thanks for the very precise repro. Changing a single variable made this quick to confirm.

    Reproduced on 0.8.193 with your composition and assets (--format png-sequence). With the 2 s source, the <video> stops following the fade when the media ends: from frame 61 on it stays at the opacity it had at that moment (R = 133), while the control div reaches 3 by frame 73. With the 4 s source both match. One small difference from your table: on current versions the video holds its mid-fade opacity rather than staying fully opaque, but the tween is still ignored past media end.

    The cause is that the renderer skips re-injecting a video frame whose frame index hasn't changed. Past the end of the media the index never changes, so the styles on the frozen frame stop updating. #4087 targets this, but it currently conflicts with motion blur over video, which relies on that same skip, so it needs another pass before it can merge. Your workaround (padding the source with tpad) is the right one until then.

    On the two other defects: could you open them as separate issues so each can be tracked on its own? For --video-bitrate, please include whether you rendered on macOS and with or without --gpu, and re-check on npx hyperframes@latest. Your 10.0 Mbps matches the default the macOS hardware encoder uses at 1080p30, which suggests the flag wasn't reaching that encoder in 0.8.46. Current code passes it through, but I haven't confirmed that on a Mac yet.

  3. added a commit that references this issue on Oct 26, 2026
    f65fca9
  4. added a commit that references this issue on Oct 26, 2026
    d8398c9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdifficulty/mediumBounded work requiring subsystem knowledge, related paths, or additional regression fixtures.triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions