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();