Skip to content

fix(core): unpause timelines nested into the registered root - #3427

Open
miguel-heygen wants to merge 2 commits into
mainfrom
fix/unpause-nested-root-timelines
Open

fix(core): unpause timelines nested into the registered root#3427
miguel-heygen wants to merge 2 commits into
mainfrom
fix/unpause-nested-root-timelines

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

Timelines nested into the registered root are unpaused before anything reads the root's
duration, so a composition built from paused scene timelines renders instead of coming out
black.

Closes #3419.

Why

A scene timeline authored as gsap.timeline({ paused: true }) and nested with .add()
keeps its own playhead frozen. Seeking the root never advances it, the root reads
duration() as 0, and every captured frame renders the t=0 state. The output is an entirely
black video, and lint, check and validate all pass, because none of them looks at
pixels.

The trap is that our own contract invites it. We ask for the registered root to be paused
so the renderer owns the playhead. Applying that to scene timelines too is the natural
misreading:

const bg   = gsap.timeline({ paused: true });   // scene 1
const text = gsap.timeline({ paused: true });   // scene 2
const master = gsap.timeline({ paused: true });
master.add(bg, 0).add(text, 3);
window.__timelines["main"] = master;            // black video, zero warnings

Investigation

Not a GSAP regression. Measured identical on every version back to 3.11.5:

gsap 3.11.5 3.12.5 3.13.0 3.14.0 3.14.2 3.15.0
parent.duration() 0 0 0 0 0 0

Not shipped breakage. Nothing in registry/ nests a paused timeline.

The framework already solved this for the neighbouring path.
ensureChildCandidatesActive (init.ts) has been calling paused(false) on child timelines
all along — but it finds them via collectRootChildCandidates, which walks
[data-composition-id] nodes and looks up timelines[childId]. That is the sub-composition
path. Timelines combined by hand are never registered under a composition id, so the
collector cannot see them.

How

One helper, called the moment the root timeline is resolved.

Placement is load-bearing and was the subtle part. Reading duration() while a child is
still paused caches 0 on the parent permanentlyinvalidate() does not clear it:

sequence result
unpause, then read duration dur=6, renders
read duration, then unpause dur=0, still black
read duration, unpause, then invalidate() dur=0, still black

So the unpause runs before anything can touch the root's duration. nested:false keeps it
to one level: a grandchild belongs to its own parent's playhead, which the same pass fixes
when that parent is reached.

Test plan

End to end, at the pixel level, on the reported pattern — two paused scene timelines
added to a paused master, 6s at 30fps. Frame 150 sampled with ffmpeg:

frame 150 RGB file
hyperframes@0.8.10 000000 (black) 7.1 KB
this branch 71ae31 (content) 17.7 KB

Unit test in init.test.ts asserts a nested paused child ends up unpaused after init.
Verified it fails on the unfixed code rather than assuming:

× initSandboxRuntimeModular > unpauses timelines nested into the registered root
  → expected true to be false

Full packages/core suite green: 2491 passed.

Not covered

  • The contract wording still says "GSAP timelines must be paused", which is what invites the
    misreading. Saying the registered root would prevent it at the source. Left out of this
    PR because it touches skills and docs surfaces that need their own sync.
  • No lint rule. The runtime fix cannot be evaded, including by timelines assembled
    dynamically, so a static rule would only duplicate it for the subset it can see.

A scene timeline authored as `gsap.timeline({ paused: true })` and nested with
`.add()` keeps its OWN playhead frozen. Seeking the root never advances it, the
root reads `duration()` as 0, and every captured frame renders the t=0 state.
The result is an entirely black video that lint, check and validate all pass,
because none of them looks at pixels.

The trap is that our contract invites it. We ask for the registered root to be
paused so the renderer owns the playhead; applying that to scene timelines too
is the natural misreading, and nothing said otherwise.

`ensureChildCandidatesActive` already did exactly this for sub-composition
children, but it finds them through `[data-composition-id]`, so timelines
combined by hand are invisible to it. This is the same fix for the other path.

Placement is load-bearing and was the subtle part: reading `duration()` while a
child is still paused caches 0 on the parent permanently, and `invalidate()`
does not clear it. So the unpause runs the moment the root timeline is
resolved, before anything can read its duration.

Not a GSAP regression — measured identical on 3.11.5, 3.12.5, 3.13.0, 3.14.0,
3.14.2 and the 3.15.0 we ship. No shipped block is affected; nothing in
registry/ nests a paused timeline.

Verified end to end at the pixel level on the reported pattern (two paused
scene timelines added to a paused master). Frame 150 of a 6s render:
0.8.10 gives 000000, this branch gives 71ae31.

Closes #3419.
Backstop for the same defect the unpause fixes. A timeline that owns children
yet reports a duration of 0 cannot be correct, and the symptom is an entirely
black render that lint, check and validate all pass, because none of them looks
at pixels.

There is no legitimate composition where children span time and the total is
zero, so the check has no false-positive case. Anything that reaches it has a
cause the unpause did not cover, which is exactly the case worth hearing about
rather than capturing frozen frames in silence.

Emitted to the console because the capture session forwards browser console
output into the producer's diagnostics, which is the channel a render actually
surfaces.

Verified by disabling the unpause and rendering the reported composition: the
warning fires through the real pipeline, naming the composition id and the
child count.
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.

Nested paused GSAP timelines report duration 0, producing a silent all-black render

1 participant