From be6e922b6bcc4c0f0b9935bc570d55132593a7ee Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 22 Aug 2026 11:35:47 -0400 Subject: [PATCH 1/2] fix(core): unpause timelines nested into the registered root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/runtime/init.test.ts | 25 +++++++++++++++++ packages/core/src/runtime/init.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 0da332ec94..fe1d8a1694 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -223,6 +223,31 @@ describe("initSandboxRuntimeModular", () => { expect(originalParseEase).not.toHaveBeenCalledWith("hold"); }); + it("unpauses timelines nested into the registered root", () => { + // A scene timeline authored as `gsap.timeline({ paused: true })` and nested + // with `.add()` keeps its own playhead frozen, so seeking the root never + // advances it and every captured frame renders the t=0 state — a black + // video that lint, check and validate all pass, none of them seeing pixels. + const child = createMockTimeline(3); + child.paused(true); + const root = createMockTimeline(6); + (root as RuntimeTimelineLike & { getChildren?: () => unknown[] }).getChildren = () => [child]; + + const node = document.createElement("div"); + node.setAttribute("data-composition-id", "main"); + node.setAttribute("data-root", "true"); + node.setAttribute("data-start", "0"); + node.setAttribute("data-duration", "6"); + node.setAttribute("data-width", "1920"); + node.setAttribute("data-height", "1080"); + document.body.appendChild(node); + window.__timelines = { main: root }; + + initSandboxRuntimeModular(); + + expect(child.paused()).toBe(false); + }); + it("repairs a keyframes tween's inner-timeline ease baked to undefined before custom-ease registration", () => { // The composition inline script builds keyframes tweens BEFORE this runtime // registers the custom eases, so a `{keyframes, ease:"hold"}` tween's inner diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cf8636ab23..7b3d1974f3 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -118,6 +118,25 @@ function resolveExportRenderFps(): ExportRenderFpsResolution { }; } +/** + * Clear `paused` on a timeline's directly nested children. + * + * Non-timeline children (tweens) have no `paused` of their own worth clearing + * here, and nested:false keeps this to one level: a grandchild belongs to its + * own parent's playhead, which this same pass fixes when that parent is reached. + */ +function unpauseNestedTimelines(timeline: RuntimeTimelineLike | null): void { + if (!timeline || typeof timeline.getChildren !== "function") return; + try { + for (const child of timeline.getChildren(false)) { + const pausable = child as { paused?: (value?: boolean) => unknown }; + if (typeof pausable.paused === "function") pausable.paused(false); + } + } catch (err) { + swallow("runtime.init.unpauseNested", err); + } +} + export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); // Own the analytics bridge before any best-effort runtime installation so @@ -1030,6 +1049,24 @@ export function initSandboxRuntimeModular(): void { return resolveSoleTimelineFallback("root_missing_composition_id"); } const rootTimeline = timelines[rootCompositionId] ?? null; + // A scene timeline authored as `gsap.timeline({ paused: true })` and nested + // with `.add()` keeps its OWN playhead frozen, so seeking the root never + // advances it. The parent also reads `duration()` as 0, and every captured + // frame renders the t=0 state — a black video that lint, check and validate + // all pass, because none of them looks at pixels. + // + // The contract asks 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 told the author otherwise. + // + // `ensureChildCandidatesActive` below already does this for sub-composition + // children, but it finds them via `[data-composition-id]`, so timelines + // combined by hand are invisible to it. Same fix, the other path. + // + // Placement is load-bearing: reading `duration()` while a child is paused + // caches 0 on the parent permanently — `invalidate()` does not clear it — + // so this has to run before anything reads the root's duration. + unpauseNestedTimelines(rootTimeline); const collectRootChildCandidates = (): Array<{ compositionId: string; timeline: RuntimeTimelineLike; From fac9bb8f44dba762f4f197d58c87e030afd98743 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 22 Aug 2026 11:55:26 -0400 Subject: [PATCH 2/2] feat(core): warn when a registered timeline has children but no duration 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. --- packages/core/src/runtime/init.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7b3d1974f3..3b53570b6e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -137,6 +137,34 @@ function unpauseNestedTimelines(timeline: RuntimeTimelineLike | null): void { } } +/** + * Warn when a registered timeline has children but no duration. + * + * There is no legitimate composition where children span time and the total is + * zero, so this has no false-positive case. 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. + */ +function reportZeroDurationWithChildren( + compositionId: string, + timeline: RuntimeTimelineLike | null, +): void { + if (!timeline || typeof timeline.getChildren !== "function") return; + try { + const childCount = timeline.getChildren(false).length; + if (childCount === 0) return; + console.warn( + `[hyperframes] Composition "${compositionId}" registered a timeline with ` + + `${childCount} child timeline(s) but a duration of 0. Every frame will render ` + + `the t=0 state, producing a blank video that lint/check/validate cannot see. ` + + `A child left paused is the usual cause: only the timeline registered on ` + + `window.__timelines should be paused.`, + ); + } catch (err) { + swallow("runtime.init.zeroDurationReport", err); + } +} + export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); // Own the analytics bridge before any best-effort runtime installation so @@ -1152,6 +1180,15 @@ export function initSandboxRuntimeModular(): void { } } const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline); + // Backstop for the class of bug the unpause above fixes: a registered + // timeline that owns children yet reports no duration cannot be right, + // and the symptom is an entirely black render that lint, check and + // validate all pass because none of them looks at pixels. Anything + // reaching here has a cause the unpause did not cover, so say so rather + // than capturing frozen frames in silence. + if (!isUsableTimelineDuration(rootDurationSeconds)) { + reportZeroDurationWithChildren(rootCompositionId, rootTimeline); + } if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) { const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId); const compositeTimeline = createCompositeTimelineFromCandidates(rootChildCandidates);