Skip to content

fix(core): a root without data-duration stays on screen after its GSAP timeline ends - #6354

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/root-stays-on-screen-after-timeline-end
Oct 25, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/root-stays-on-screen-after-timeline-end

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

A root composition without data-duration no longer goes fully black once its GSAP timeline ends while its media still runs. That covers a sub-composition opened in its own Studio tab, where the scene is the root. In a framed preview, clips inside a GSAP-animated wrapper also stay visible past the timeline's end.

Why

Repro: a scene file whose root has no data-duration, holding two side-by-side <video data-start="0"> (6 s sources) inside clip-path wrappers, a title and a grid, with a GSAP timeline that ends at 2 s. Open it in Studio's composition tab. From 2.0 s the preview is fully black (mean luma 0) while the player keeps running to 6 s. snapshot --composition shows the scene because it renders the full project, where the scene's mount carries a duration. The same happens to a plain index.html root with no data-duration, in preview and in render: frames after 2 s have mean luma 4.

Two things hide the picture:

  1. The root hides itself. The runtime stamps data-start="0" on a root that lacks it (packages/core/src/runtime/init.ts:673-676 on main). That makes it a timed element in the visibility pass. runtimeElementWindow (packages/core/src/runtime/timeline.ts) ends a composition element's window at its registered timeline's length. That length comes from the start resolver's resolveRegisteredTimelineDuration and from the live-duration fallback right after it. For the root, that is 2 s, while the composition (floored by its media) is 6 s. Past 2 s the root gets visibility:hidden and data-hf-clip-hidden, taking everything with it.
  2. Discovery stamps hide what they wrap. When the runtime runs in a frame (Studio), it stamps every GSAP target and every id'd child of the root that has no timing with data-start="0", data-duration=<timeline length at bind> and data-hf-autostamped, so Studio's timeline can list them (init.ts:2185-2250). Those stamps are meant to hide nothing. The clip's own visibility pass skips them (isDiscoveryStamped, init.ts:3204), and so does the scene clock (init.ts:5033). But clipChainVisibleAt, which hides a clip whose timed ancestor has ended, did not. So once the composition outlived the bind-time timeline length, the clip-path wrappers' stamps hid the videos inside them. This only happens in a frame, so it shows in Studio and not in a top-level page.

How

  • runtimeElementWindow: the root composition's window has no end. The composition's length bounds it through isClipVisibleAt, which already keeps the terminal frame. Nested composition hosts still end with their timeline. The resolver is unchanged, so data-start references that read a composition's duration resolve as before.
  • clipChainVisibleAt skips discovery-stamped ancestors, like the other two readers of a clip's window.

What changes in output

  • A root with no data-duration whose media outlasts its GSAP timeline shows its content to the end, in preview, snapshot and render. Before, every frame after the timeline's end was black.
  • In Studio, clips inside an element the runtime stamped for the timeline list stay visible for their own window, not the stamp's.
  • Roots with data-duration, and nested scenes, are unchanged.

Measured

Studio composition tab (hyperframes preview from this tree, headless Chrome), the repro scene at seeks 0.5 / 1.5 / 2.2 / 3 / 4.5 / 5.5 s and during playback:

0.5 s 1.5 s 2.2 s 3 s 4.5 s 5.5 s
main and 0.8.198 55 74 0 0 0 0
root fix only 55 74 35 (videos hidden) 35 35 35
this PR 55 74 75 75 75 75

Numbers are mean luma; 35 is the scene's background, grid and title without the videos. Scenes with authored durations (three timed videos in clip-path wrappers, a mount shorter than the scene) give the same values at every step on main and on this PR. A plain index.html root with no data-duration and a 2 s timeline over a 6 s video stays at luma 57 to 6 s; on main it is 0 from 2.2 s.

Producer regression suite (docker:test, linux/amd64, CI's 10-shard plan, this branch rebased on main at 059a6e5): 54 of 54 fixtures pass, 0 failed frames in 53. sub-comp-t0 has the same 3 frames under its PSNR floor that it has on main (0.119 s, 0.238 s, 2.8 s, within its allowance of 5).

Test plan

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

  • coreRuntimeBrowser.test.ts (real Chrome, the built runtime):

    • A red root with no data-duration, a 2 s timeline and a 6 s timed audio paints all 57,600 pixels at renderSeek(4). Without the root change it paints 0.
    • In a frame, a red 6 s clip inside a wrapper stamped data-start="0" data-duration="2" data-hf-autostamped paints all 57,600 pixels at seek(4). Without the chain change it paints 0.
    • Both passed 3 runs in a row.
  • timeline.test.ts: runtimeElementWindow gives the root an open end and a nested scene its timeline's end. It fails on main (end: 2).

  • packages/core src/runtime: 101 files, 2,472 tests pass.

Small on purpose: two one-line runtime fixes for one report, each pinned by a browser test; it shares nothing with any other open change.

…P timeline ends

A root composition with no data-duration took its own GSAP timeline's length as its visibility
window, so when media made the composition longer, the whole frame went black from the end of
that timeline in preview and render. The root's window is now open-ended, bounded by the
composition's length. In a framed preview, the duration the runtime stamps on GSAP targets for
Studio's timeline no longer hides the clips inside them either: the ancestor chain check now
skips discovery stamps, as the clip's own visibility pass already does.
@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 196 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 blocker · 1 important · 0 nits · head fc21231e

Both fixes are correct. With isDiscoveryStamped in the chain check, all three readers of a clip's window now treat stamps the same way. A root with authored data-duration still ends at the composition length through isClipVisibleAt's terminal rule. One perf note ↓ inline.

Scope

Read the full diff, plus runtimeElementWindow's callers (timedVisibilityAt, decisionEdgesOf, which filters out the infinite edge, and createFarSceneSkip), isClipVisibleAt, and findRootCompositionElement. timeline.test.ts passes locally (72/72). Every Actions run on this head is green: CI, regression, Windows render, preview-regression, CodeQL, and the perf runs. No prior reviews.

duration = liveDuration;
}
}
const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — This calls findRootCompositionElement (one or two document-wide querySelectors) on every runtimeElementWindow call. That includes each timed node and each timed ancestor walked by clipChainVisibleAt, on every pass. When the page has no [data-root="true"], the first query scans the whole document each time, so a full pass (every renderSeek frame) grows roughly as timed nodes × DOM size. That cost is why init.ts caches the root in resolveRootCompositionElement. Only a data-composition-id element can be the root, so computing this inside the compId branch removes the cost for every other node:

Suggested change
const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument);
const isRoot = compId !== null && rawNode === findRootCompositionElement(rawNode.ownerDocument);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied your suggestion in 9588f71: the root lookup now runs only for data-composition-id elements, so no other node pays for it. Both browser tests still pass 3 in a row; timeline.test.ts 72/72.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 0ae48c9 Oct 25, 2026
125 checks passed
@miguel-heygen
miguel-heygen deleted the fix/root-stays-on-screen-after-timeline-end branch October 25, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant