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