Repository navigation
fix(core): a root without data-duration stays on screen after its GSAP timeline ends - #6354
Conversation
…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.
Edit accuracy: accurate 271 (base branch 271), smooth 196 of thoseThe gate passes. Quarantined, measured but not gated (0) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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:
| const isRoot = rawNode === findRootCompositionElement(rawNode.ownerDocument); | |
| const isRoot = compId !== null && rawNode === findRootCompositionElement(rawNode.ownerDocument); |
There was a problem hiding this comment.
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.
What
A root composition without
data-durationno 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 --compositionshows the scene because it renders the full project, where the scene's mount carries a duration. The same happens to a plainindex.htmlroot with nodata-duration, in preview and in render: frames after 2 s have mean luma 4.Two things hide the picture:
data-start="0"on a root that lacks it (packages/core/src/runtime/init.ts:673-676onmain). 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'sresolveRegisteredTimelineDurationand 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 getsvisibility:hiddenanddata-hf-clip-hidden, taking everything with it.data-start="0",data-duration=<timeline length at bind>anddata-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). ButclipChainVisibleAt, 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 throughisClipVisibleAt, which already keeps the terminal frame. Nested composition hosts still end with their timeline. The resolver is unchanged, sodata-startreferences that read a composition's duration resolve as before.clipChainVisibleAtskips discovery-stamped ancestors, like the other two readers of a clip's window.What changes in output
data-durationwhose 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.data-duration, and nested scenes, are unchanged.Measured
Studio composition tab (
hyperframes previewfrom this tree, headless Chrome), the repro scene at seeks 0.5 / 1.5 / 2.2 / 3 / 4.5 / 5.5 s and during playback:mainand 0.8.198Numbers 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
mainand on this PR. A plainindex.htmlroot with nodata-durationand a 2 s timeline over a 6 s video stays at luma 57 to 6 s; onmainit is 0 from 2.2 s.Producer regression suite (
docker:test, linux/amd64, CI's 10-shard plan, this branch rebased onmainat 059a6e5): 54 of 54 fixtures pass, 0 failed frames in 53.sub-comp-t0has the same 3 frames under its PSNR floor that it has onmain(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):data-duration, a 2 s timeline and a 6 s timed audio paints all 57,600 pixels atrenderSeek(4). Without the root change it paints 0.data-start="0" data-duration="2" data-hf-autostampedpaints all 57,600 pixels atseek(4). Without the chain change it paints 0.timeline.test.ts:runtimeElementWindowgives the root an open end and a nested scene its timeline's end. It fails onmain(end: 2).packages/coresrc/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.