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
This commit is contained in:
Miguel Ángel
2026-10-16 16:48:41 +00:00
committed by GitHub
parent 4e6fcdb3a9
commit fff3636fe7
5 changed files with 104 additions and 7 deletions
@@ -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<string, unknown> = { __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<string, unknown>)[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 =
`<div data-composition-id="main" data-root="true" data-duration="2">` +
`<div class="clip" data-composition-id="scene" data-start="0" data-duration="2">` +
`<div id="card"></div></div></div>`;
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));
}
});
});
+14 -5
View File
@@ -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<object, { hostId: string; parent: unknown; at: number }>();
// 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;
}
@@ -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.
@@ -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<string, unknown> = {
__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);
});
});
+5 -1
View File
@@ -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 <script> tag in the HTML body.
let _realGsap: GsapInstance | null = null;
// GSAP's browser bundle assigns window.gsap twice: wrapping again would stand a proxy in for a
// proxy, and the runtime would compare GSAP's children against the outer one.
const wrapped = new WeakSet<GsapInstance>();
try {
Object.defineProperty(window, "gsap", {
configurable: true,
@@ -472,7 +475,8 @@ if (typeof window !== "undefined") {
},
set(g: GsapInstance): void {
_realGsap = g;
if (!g || typeof g.timeline !== "function") return;
if (!g || typeof g.timeline !== "function" || wrapped.has(g)) return;
wrapped.add(g);
const origTimeline = g.timeline.bind(g) as (params?: unknown) => GsapTimeline;
g.timeline = (params?: unknown): GsapTimeline => wrapTimeline(origTimeline(params));
// Tween-target tracking for top-level gsap.to/from/set/fromTo calls