From fff3636fe727512af54c876982c73050ca2c8fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 16 Oct 2026 16:48:41 +0000 Subject: [PATCH] fix(core): exported scenes keep cards that fly in, instead of one frame of each (#5692) * fix(core): exported scenes keep cards that fly in, instead of one frame of each Under the producer's GSAP stub every timeline is a stand-in. GSAP's bundle sets window.gsap twice, so the stub wrapped twice; and the runtime compared those stand-ins against GSAP's real children. Nesting a scene into the root never read back as nested, so the scene ended up parked under an abandoned composite at time 0, and each render seek's play() snapped it back to 0. Tweens whose from-values set opacity lost them after their first frame. The stub now wraps a GSAP instance once, and the runtime compares the timeline behind a stand-in. * fix(core): keep the stand-in note to one line --- .../init.subCompositionPlacement.test.ts | 61 +++++++++++++++++++ packages/core/src/runtime/init.ts | 19 ++++-- .../src/generated/hf-early-stub-inline.ts | 2 +- .../producer/src/services/fileServer.test.ts | 23 +++++++ packages/producer/stubs/hf-early-stub.ts | 6 +- 5 files changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts index c1d0d05f88..db25959a12 100644 --- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts +++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts @@ -399,3 +399,64 @@ describe("runtime sub-composition placement", () => { expect(video("fade").volume).toBeCloseTo(0.5, 6); }); }); + +// The producer's tween-batching stub registers stand-ins: the same methods on another object, the timeline behind it. +function standIn(real: gsap.core.Timeline): RuntimeTimelineLike { + const proxy: Record = { __hfIsProxy: true, __hfReal: real }; + const unwrap = (arg: unknown) => (arg as { __hfReal?: unknown } | null)?.__hfReal ?? arg; + for (let o: object | null = real; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) { + for (const key of Object.getOwnPropertyNames(o)) { + const method = (real as unknown as Record)[key]; + if (key === "constructor" || key in proxy || typeof method !== "function") continue; + proxy[key] = (...args: unknown[]) => { + const out = (method as (...a: unknown[]) => unknown).apply(real, args.map(unwrap)); + return out === real ? proxy : out; + }; + } + } + return proxy as unknown as RuntimeTimelineLike; +} + +describe("runtime placement of timelines registered as stand-ins", () => { + beforeEach(() => { + resetRuntimeFixtureDom(); + window.gsap = gsap as unknown as typeof window.gsap; + }); + + afterEach(() => { + window.__hfRuntimeTeardown?.(); + vi.restoreAllMocks(); + delete window.__player; + delete window.__playerReady; + delete window.__hf; + }); + + // A card flies in at 0.5 s, its from-vars showing it, hidden from 0 by a fromTo authored after it. + function cardScene(card: Element | object) { + const scene = gsap.timeline({ paused: true }); + scene.fromTo(card, { x: -600, opacity: 1 }, { x: 0, duration: 0.34 }, 0.5); + scene.fromTo([card], { opacity: 0 }, { opacity: 0, duration: 0.01 }, 0); + return scene; + } + + it("keeps a scene's fly-in on every rendered frame under an empty root, as playback does", () => { + document.body.innerHTML = + `
` + + `
` + + `
`; + const card = document.getElementById("card")!; + const scene = cardScene(card); + window.__timelines = { + main: standIn(gsap.timeline({ paused: true })), + scene: standIn(scene), + }; + initSandboxRuntimeModular(); + const played = { x: 0, opacity: 0 }; + const playedScene = cardScene(played); + for (let frame = 0; frame <= 30; frame++) { + playedScene.totalTime(frame / 30); + window.__player?.renderSeek(frame / 30, { suppressEvents: true }); + expect(card.style.opacity, `frame ${frame}`).toBe(String(played.opacity)); + } + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e3d109a141..40c4eb8e0a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1416,6 +1416,9 @@ export function initSandboxRuntimeModular(): void { // Sub-composition timelines the runtime nested into the root: the host composition id, and where. const autoNested = new WeakMap(); + // The producer's GSAP stub registers stand-ins; GSAP's tree holds the timelines behind them. + const gsapTimelineOf = (timeline: object): object => + (timeline as { __hfReal?: object }).__hfReal ?? timeline; // The composite that holds the children of a root with no timeline, and how many it holds. let missingRootComposite: { timeline: RuntimeTimelineLike; size: number } | null = null; @@ -1471,16 +1474,20 @@ export function initSandboxRuntimeModular(): void { ): void => { const at = resolveCompositionStartSeconds(candidate.compositionId); parent.add(candidate.timeline, at); - autoNested.set(candidate.timeline, { hostId: candidate.compositionId, parent, at }); + autoNested.set(gsapTimelineOf(candidate.timeline), { + hostId: candidate.compositionId, + parent, + at, + }); }; /** Placed by the runtime in `root` and still there; under another root a re-run script owns its place. */ const placedIn = ( root: RuntimeTimelineLike, candidate: { compositionId: string; timeline: RuntimeTimelineLike }, ) => { - const nested = candidate.timeline as RuntimeTimelineLike & RuntimeTimelineChildLike; + const nested = gsapTimelineOf(candidate.timeline) as RuntimeTimelineChildLike; const placed = autoNested.get(nested); - return placed && placed.parent === root && nested.parent === (root as unknown) + return placed && placed.parent === root && nested.parent === gsapTimelineOf(root) ? placed : null; }; @@ -1551,7 +1558,9 @@ export function initSandboxRuntimeModular(): void { if (!parent || typeof parent.getChildren !== "function") return []; try { const held = parent.getChildren(true, true, true); - return Array.isArray(held) ? candidates.filter((c) => held.includes(c.timeline)) : []; + return Array.isArray(held) + ? candidates.filter((c) => held.includes(gsapTimelineOf(c.timeline))) + : []; } catch { return []; } @@ -1574,7 +1583,7 @@ export function initSandboxRuntimeModular(): void { if (!Array.isArray(existingChildren)) return none; const addedIds: string[] = []; for (const candidate of candidates) { - if (existingChildren.includes(candidate.timeline)) { + if (existingChildren.includes(gsapTimelineOf(candidate.timeline))) { followHostStart(rootTimeline, candidate); continue; } diff --git a/packages/producer/src/generated/hf-early-stub-inline.ts b/packages/producer/src/generated/hf-early-stub-inline.ts index c7611657f8..3253cb5409 100644 --- a/packages/producer/src/generated/hf-early-stub-inline.ts +++ b/packages/producer/src/generated/hf-early-stub-inline.ts @@ -1,6 +1,6 @@ // AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit const HF_EARLY_STUB_IIFE: string = - '"use strict";(()=>{var P=100,x=[],s=[],w=!1,f=!1,T=new Set;function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function b(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function O(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n,e){n!=="add"&&v(e)}function h(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let e=n;return"rotationX"in e||"rotationY"in e||"transformPerspective"in e}var d=new Set;function v(n){let e=n[0];if(e==null)return;let o=window;d.has(e)||(d.add(e),o.__hfAllTweenTargets=Array.from(d)),(h(n[1])||h(n[2]))&&(T.add(e),o.__hf3dTweenTargets=Array.from(T))}function _(n){let e=n.proxy.__hfReal,o=e[n.method];if(typeof o=="function"){let i=n.method==="add"?n.args.map(O):n.args;o.call(e,...i)}}function l(n,e,o){m(e,o);let i={proxy:n,method:e,args:o};return n.__hfQueue.push(i),s.push(i),R(),n}function y(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function t(){for(;s.length>0;){let n=s.shift();n&&(y(n),_(n))}A()}function k(){f=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function A(){f||(f=!0,b(()=>{s.length===0?k():f=!1}))}function g(){w=!1;let n=s.splice(0,P);for(let e of n)y(e),_(e);s.length>0?(w=!0,p(g)):k()}function R(){w||(w=!0,window.__hfTimelinesBuilding=!0,p(g))}var S=new Set(["to","from","fromTo","set","add"]);function I(n,e){let o=e;for(;o!==null&&o!==Object.prototype;){for(let i of Object.getOwnPropertyNames(o)){if(i==="constructor"||i==="then"||i in n||S.has(i)||i.charAt(0)==="_")continue;let u=Object.getOwnPropertyDescriptor(o,i);if(!u||typeof u.value!="function")continue;let r=u.value;n[i]=function(...c){t();let a=r.call(e,...c);return a===e?n:a}}o=Object.getPrototypeOf(o)}}function G(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...o){return l(e,"to",o)},from(...o){return l(e,"from",o)},fromTo(...o){return l(e,"fromTo",o)},set(...o){return l(e,"set",o)},add(...o){return l(e,"add",o)},pause(...o){return t(),n.pause(...o),e},play(...o){return t(),n.play(...o),e},seek(...o){return t(),n.seek(...o),e},totalTime(...o){return t(),o.length>0?(n.totalTime(...o),e):n.totalTime()},time(...o){return t(),o.length>0?(n.time(...o),e):n.time()},duration(...o){return t(),o.length>0?(n.duration(...o),e):n.duration()},getChildren(...o){t();let i=n.getChildren(...o);return Array.isArray(i)?i:[]},paused(...o){return t(),o.length>0?(n.paused(...o),e):n.paused()},timeScale(...o){return t(),o.length>0?(n.timeScale(...o),e):n.timeScale()},kill(){t(),n.kill()}};return I(e,n),x.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1,window.__hfFlushSync=()=>{t(),s.length===0&&window.__hfTimelinesBuilding&&k()};let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let o=e.timeline.bind(e);e.timeline=u=>G(o(u));for(let u of["to","from","set"]){let r=e[u];if(typeof r!="function")continue;let c=r.bind(e);e[u]=(...a)=>(m(u,a),c(...a))}let i=e.fromTo;if(typeof i=="function"){let u=i.bind(e);e.fromTo=(...r)=>(m("fromTo",r),u(...r))}}})}catch{}}})();\n'; + '"use strict";(()=>{var x=100,b=[],s=[],c=!1,w=!1,h=new Set;function _(n){let o=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof o=="function"?o(n):requestAnimationFrame(n)}function O(n){let o=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof o=="function"){o(n,0);return}setTimeout(n,0)}function v(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n,o){n!=="add"&&A(o)}function p(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let o=n;return"rotationX"in o||"rotationY"in o||"transformPerspective"in o}var d=new Set;function A(n){let o=n[0];if(o==null)return;let e=window;d.has(o)||(d.add(o),e.__hfAllTweenTargets=Array.from(d)),(p(n[1])||p(n[2]))&&(h.add(o),e.__hf3dTweenTargets=Array.from(h))}function y(n){let o=n.proxy.__hfReal,e=o[n.method];if(typeof e=="function"){let i=n.method==="add"?n.args.map(v):n.args;e.call(o,...i)}}function l(n,o,e){m(o,e);let i={proxy:n,method:o,args:e};return n.__hfQueue.push(i),s.push(i),R(),n}function g(n){let o=n.proxy.__hfQueue.indexOf(n);o>=0&&n.proxy.__hfQueue.splice(o,1)}function t(){for(;s.length>0;){let n=s.shift();n&&(g(n),y(n))}S()}function k(){w=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function S(){w||(w=!0,O(()=>{s.length===0?k():w=!1}))}function P(){c=!1;let n=s.splice(0,x);for(let o of n)g(o),y(o);s.length>0?(c=!0,_(P)):k()}function R(){c||(c=!0,window.__hfTimelinesBuilding=!0,_(P))}var I=new Set(["to","from","fromTo","set","add"]);function G(n,o){let e=o;for(;e!==null&&e!==Object.prototype;){for(let i of Object.getOwnPropertyNames(e)){if(i==="constructor"||i==="then"||i in n||I.has(i)||i.charAt(0)==="_")continue;let a=Object.getOwnPropertyDescriptor(e,i);if(!a||typeof a.value!="function")continue;let u=a.value;n[i]=function(...r){t();let f=u.call(o,...r);return f===o?n:f}}e=Object.getPrototypeOf(e)}}function B(n){let o={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...e){return l(o,"to",e)},from(...e){return l(o,"from",e)},fromTo(...e){return l(o,"fromTo",e)},set(...e){return l(o,"set",e)},add(...e){return l(o,"add",e)},pause(...e){return t(),n.pause(...e),o},play(...e){return t(),n.play(...e),o},seek(...e){return t(),n.seek(...e),o},totalTime(...e){return t(),e.length>0?(n.totalTime(...e),o):n.totalTime()},time(...e){return t(),e.length>0?(n.time(...e),o):n.time()},duration(...e){return t(),e.length>0?(n.duration(...e),o):n.duration()},getChildren(...e){t();let i=n.getChildren(...e);return Array.isArray(i)?i:[]},paused(...e){return t(),e.length>0?(n.paused(...e),o):n.paused()},timeScale(...e){return t(),e.length>0?(n.timeScale(...e),o):n.timeScale()},kill(){t(),n.kill()}};return G(o,n),b.push(o),o}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1,window.__hfFlushSync=()=>{t(),s.length===0&&window.__hfTimelinesBuilding&&k()};let n=null,o=new WeakSet;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function"||o.has(e))return;o.add(e);let i=e.timeline.bind(e);e.timeline=u=>B(i(u));for(let u of["to","from","set"]){let r=e[u];if(typeof r!="function")continue;let f=r.bind(e);e[u]=(...T)=>(m(u,T),f(...T))}let a=e.fromTo;if(typeof a=="function"){let u=a.bind(e);e.fromTo=(...r)=>(m("fromTo",r),u(...r))}}})}catch{}}})();\n'; /** * Returns the pre-built HyperFrames early stub IIFE as a string constant. diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 977a89b447..e6c02582e8 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -1028,4 +1028,27 @@ describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => { expect(sandbox.window.__hf?.duration).toBe(13); }); + + it("stands in for the GSAP timeline itself when the GSAP bundle sets window.gsap twice", () => { + class FakeTimeline { + parent: FakeTimeline | null = null; + add(child: FakeTimeline) { + child.parent = this; + return this; + } + } + const gsap = { timeline: () => new FakeTimeline() }; + const win: Record = { + __HF_VIRTUAL_TIME__: { originalRequestAnimationFrame: () => 0, originalSetTimeout: () => {} }, + }; + new Function("window", HF_EARLY_STUB)(win); + win.gsap = gsap; + win.gsap = gsap; + const parent = gsap.timeline() as unknown as { __hfReal: unknown; add: (c: unknown) => void }; + const child = gsap.timeline() as unknown as { __hfReal: FakeTimeline }; + parent.add(child); + (win.__hfFlushSync as () => void)(); + expect(child.__hfReal).toBeInstanceOf(FakeTimeline); + expect(child.__hfReal.parent).toBe(parent.__hfReal); + }); }); diff --git a/packages/producer/stubs/hf-early-stub.ts b/packages/producer/stubs/hf-early-stub.ts index cb844f336a..4edf646181 100644 --- a/packages/producer/stubs/hf-early-stub.ts +++ b/packages/producer/stubs/hf-early-stub.ts @@ -463,6 +463,9 @@ if (typeof window !== "undefined") { // `gsap.timeline()` before any user script calls it. GSAP is not yet // loaded when this stub runs — it loads via a