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