From 0d12a465a3c8d4bce691c7020c4986288f26c764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 19 May 2026 15:42:07 -0400 Subject: [PATCH] fix: activate nested child timelines during renderSeek The renderSeek override in init.ts called seekTimelineAndAdapters() which only did rootTimeline.totalTime(t) without activating child timelines. GSAP does not propagate totalTime() to internally paused children. Also simplifies pollSubCompositionTimelines to always call rebind when timelines are ready, removing the before/after count comparison that could skip the rebind on fast page loads. --- .../__fixtures__/sub-comp-t0/index.html | 88 +++++++++++++++++++ packages/core/src/runtime/init.test.ts | 69 +++++++++++++++ packages/core/src/runtime/init.ts | 32 ++++++- packages/engine/src/services/frameCapture.ts | 15 ++-- 4 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/runtime/__fixtures__/sub-comp-t0/index.html diff --git a/packages/core/src/runtime/__fixtures__/sub-comp-t0/index.html b/packages/core/src/runtime/__fixtures__/sub-comp-t0/index.html new file mode 100644 index 000000000..00d00a260 --- /dev/null +++ b/packages/core/src/runtime/__fixtures__/sub-comp-t0/index.html @@ -0,0 +1,88 @@ + + + + + + + + + +
+ +
+ + +
+
+ + + + diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 03e7a1ad1..6fd967495 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -444,6 +444,75 @@ describe("initSandboxRuntimeModular", () => { expect(video.currentTime).toBe(0); }); + it("activates sub-composition timelines at data-start near 0 during renderSeek", () => { + // Regression: sub-compositions starting at or near t=0 had their GSAP + // sub-timelines ignored during render because renderSeek did not + // activate (unpause) nested child timelines before seeking the root. + // The children were added to the root while paused, and GSAP's + // totalTime() does not propagate to paused children. + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "24"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const hookHost = document.createElement("div"); + hookHost.setAttribute("data-composition-id", "hook"); + hookHost.setAttribute("data-start", "0.001"); + hookHost.setAttribute("data-duration", "2"); + hookHost.setAttribute("data-track-index", "0"); + hookHost.classList.add("clip"); + root.appendChild(hookHost); + + const laterHost = document.createElement("div"); + laterHost.setAttribute("data-composition-id", "tweet"); + laterHost.setAttribute("data-start", "1.5"); + laterHost.setAttribute("data-duration", "4.5"); + laterHost.setAttribute("data-track-index", "1"); + laterHost.classList.add("clip"); + root.appendChild(laterHost); + + const hookTimeline = createMockTimeline(2); + const tweetTimeline = createMockTimeline(4.5); + const rootTimeline = createMockTimeline(24); + + (window as Window & { __timelines?: Record }).__timelines = { + main: rootTimeline, + hook: hookTimeline, + tweet: tweetTimeline, + }; + + initSandboxRuntimeModular(); + + const player = ( + window as Window & { + __player?: { renderSeek: (timeSeconds: number) => void }; + } + ).__player; + expect(player).toBeDefined(); + + // Simulate that the hook timeline was paused (as happens when + // children are added to a paused root timeline in GSAP) + hookTimeline.paused!(true); + tweetTimeline.paused!(true); + + // Seek to 0.5s — well within the hook's window [0.001, 2.001] + player?.renderSeek(0.5); + + // renderSeek should activate (unpause) all child timelines before + // seeking the root. Without the fix, children stay paused and GSAP's + // totalTime() propagation skips them, leaving elements at initial CSS + // state (opacity: 0). + expect(hookTimeline.paused!()).toBe(false); + expect(tweetTimeline.paused!()).toBe(false); + + // The hook host should be visible at t=0.5 + expect(hookHost.style.visibility).toBe("visible"); + }); + it("plays scheduled child timelines without a captured root timeline when audio has failed", () => { const raf = createManualRaf(); vi.spyOn(performance, "now").mockImplementation(() => raf.now()); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 01036dc84..327b4d18f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1724,9 +1724,37 @@ export function initSandboxRuntimeModular(): void { } }; - const seekTimelineAndAdapters = (t: number) => { + const activateNestedChildTimelines = (masterTimeline: RuntimeTimelineLike) => { + const timelines = (window.__timelines ?? {}) as Record; + for (const tl of Object.values(timelines)) { + if (!tl || tl === masterTimeline) continue; + try { + const tlWithPaused = tl as RuntimeTimelineLike & { + paused?: (value?: boolean) => unknown; + }; + if (typeof tlWithPaused.paused === "function") { + tlWithPaused.paused(false); + } + } catch (err) { + swallow("runtime.init.activateNested", err); + } + } + }; + + const seekTimelineAndAdapters = (t: number, activateChildren = false) => { const tl = state.capturedTimeline; if (tl) { + // When rendering frame-by-frame (activateChildren=true), ensure all + // nested child timelines are unpaused before seeking the root. GSAP + // does not propagate totalTime() to children that are internally + // paused, which leaves sub-compositions at their initial CSS state + // (typically opacity:0). This mirrors the activateSiblingTimelines + // call in player.ts renderSeek and is critical for sub-compositions + // whose data-start is at or near 0 — they are added to the root + // while it is paused and may never receive an explicit play(). + if (activateChildren) { + activateNestedChildTimelines(tl); + } try { if (typeof tl.totalTime === "function") { tl.totalTime(t, false); @@ -2001,7 +2029,7 @@ export function initSandboxRuntimeModular(): void { state.currentTime = clock.now(); state.isPlaying = false; state.mediaForceSyncNextTick = true; - seekTimelineAndAdapters(state.currentTime); + seekTimelineAndAdapters(state.currentTime, true); syncMediaForCurrentState(); postState(true); }; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 62b3729e7..9bb268cf8 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -365,14 +365,15 @@ async function pollSubCompositionTimelines( } return true; })()`; - const timelinesBeforePoll = Number( - await page.evaluate(`Object.keys(window.__timelines || {}).length`), - ); const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs); - const timelinesAfterPoll = Number( - await page.evaluate(`Object.keys(window.__timelines || {}).length`), - ); - if (ready && timelinesAfterPoll > timelinesBeforePoll) { + // Always force a timeline rebind once sub-composition timelines are + // confirmed present. The previous implementation only called rebind + // when the timeline count grew during the poll, which missed the case + // where all sub-comp scripts had already executed before the poll + // started — leaving child timelines un-nested in the root and causing + // the earliest sub-composition (data-start near 0) to render without + // its GSAP animations. + if (ready) { await page.evaluate(`(function() { if (typeof window.__hfForceTimelineRebind === "function") { window.__hfForceTimelineRebind();