mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)
Compositions that defer gsap.timeline() registration past DOMContentLoaded (via setTimeout, template instantiation, or dynamic script loading) hit a race where __renderReady stays false forever: 1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips the hf-timelines-built listener and sets __renderReady = true 2. The deferred script runs, calls gsap.timeline().to() which sets __hfTimelinesBuilding = true via the batching proxy 3. The deferred maybePublishRenderReady() sees building=true, sets __renderReady = false, but never registers a listener to retry 4. __renderReady stays false, __hf.duration returns 0, pollHfReady times out with "Composition has zero duration" Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true, register a one-shot hf-timelines-built listener to retry — matching the pattern already used at init time for the synchronous batching case. Closes #1260
This commit is contained in:
@@ -1757,27 +1757,28 @@ export function initSandboxRuntimeModular(): void {
|
||||
postState(true);
|
||||
};
|
||||
|
||||
let buildListenerPending = false;
|
||||
|
||||
maybePublishRenderReady = () => {
|
||||
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
|
||||
if (!externalCompositionsReady) {
|
||||
window.__renderReady = false;
|
||||
return;
|
||||
}
|
||||
if (window.__hfTimelinesBuilding) {
|
||||
window.__renderReady = false;
|
||||
if (!buildListenerPending) {
|
||||
buildListenerPending = true;
|
||||
const onBuilt = () => {
|
||||
buildListenerPending = false;
|
||||
maybePublishRenderReady();
|
||||
};
|
||||
window.addEventListener("hf-timelines-built", onBuilt, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
publishRenderReadyAfterTimelineBinding();
|
||||
};
|
||||
|
||||
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
|
||||
// active, composition scripts queue tl.to() calls instead of executing them
|
||||
// synchronously. Wait for the "hf-timelines-built" event before the first
|
||||
// binding attempt so the transport clock receives the finished timeline
|
||||
// duration instead of permanently publishing duration=0.
|
||||
if (window.__hfTimelinesBuilding) {
|
||||
window.__renderReady = false;
|
||||
const onTimelinesBuilt = () => {
|
||||
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
|
||||
maybePublishRenderReady();
|
||||
};
|
||||
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
|
||||
}
|
||||
maybePublishRenderReady();
|
||||
|
||||
// When the bundler inlines compositions, data-composition-src is removed so
|
||||
|
||||
@@ -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 c=100,k=[],u=[],l=!1,s=!1;function a(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function h(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n){let e=n.proxy.__hfReal,i=e[n.method];if(typeof i=="function"){let t=n.method==="add"?n.args.map(h):n.args;i.call(e,...t)}}function r(n,e,i){let t={proxy:n,method:e,args:i};return n.__hfQueue.push(t),u.push(t),_(),n}function f(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function o(){for(;u.length>0;){let n=u.shift();n&&(f(n),m(n))}T()}function w(){s=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function T(){s||(s=!0,p(()=>{u.length===0?w():s=!1}))}function d(){l=!1;let n=u.splice(0,c);for(let e of n)f(e),m(e);u.length>0?(l=!0,a(d)):w()}function _(){l||(l=!0,window.__hfTimelinesBuilding=!0,a(d))}function y(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...i){return r(e,"to",i)},from(...i){return r(e,"from",i)},fromTo(...i){return r(e,"fromTo",i)},set(...i){return r(e,"set",i)},add(...i){return r(e,"add",i)},pause(...i){return o(),n.pause(...i),e},play(...i){return o(),n.play(...i),e},seek(...i){return o(),n.seek(...i),e},totalTime(...i){return o(),i.length>0?(n.totalTime(...i),e):n.totalTime()},time(...i){return o(),i.length>0?(n.time(...i),e):n.time()},duration(...i){return o(),i.length>0?(n.duration(...i),e):n.duration()},getChildren(...i){o();let t=n.getChildren(...i);return Array.isArray(t)?t:[]},paused(...i){return o(),i.length>0?(n.paused(...i),e):n.paused()},timeScale(...i){return o(),i.length>0?(n.timeScale(...i),e):n.timeScale()},kill(){o(),n.kill()}};return k.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;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 i=e.timeline.bind(e);e.timeline=t=>y(i(t))}})}catch{}}})();\n';
|
||||
'"use strict";(()=>{var k=100,T=[],u=[],a=!1,r=!1;function w(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function p(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function g(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function m(n){let e=n.proxy.__hfReal,i=e[n.method];if(typeof i=="function"){let t=n.method==="add"?n.args.map(g):n.args;i.call(e,...t)}}function y(n,e,i){let t={proxy:n,method:e,args:i};return n.__hfQueue.push(t),u.push(t),P(),n}function c(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function f(){for(;u.length>0;){let n=u.shift();n&&(c(n),m(n))}x()}function d(){r=!1,window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function x(){r||(r=!0,p(()=>{u.length===0?d():r=!1}))}function _(){a=!1;let n=u.splice(0,k);for(let e of n)c(e),m(e);u.length>0?(a=!0,w(_)):d()}function P(){a||(a=!0,window.__hfTimelinesBuilding=!0,w(_))}var O=new Set(["to","from","fromTo","set","add"]),R=new Set(["__hfReal","__hfQueue","__hfIsProxy"]);function v(n,e,i){return(...t)=>{f();let o=e.call(n,...t);return o===n?i():o}}function I(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0},i=new Proxy(e,{get(t,o,l){if(R.has(o))return e[o];if(typeof o=="string"&&O.has(o))return(...h)=>y(i,o,h);let s=n[o];return typeof s=="function"?v(n,s,()=>l):(s!==void 0&&f(),s)},set(t,o,l){return f(),n[o]=l,!0}});return T.push(i),i}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;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 i=e.timeline.bind(e);e.timeline=t=>I(i(t))}})}catch{}}})();\n';
|
||||
|
||||
/**
|
||||
* Returns the pre-built HyperFrames early stub IIFE as a string constant.
|
||||
|
||||
@@ -38,11 +38,12 @@
|
||||
* completes (the captured timeline reference remains valid — the proxy delegates
|
||||
* all non-mutating calls to the real timeline throughout).
|
||||
*
|
||||
* Render-mode correctness: `window.__renderReady` is intentionally not gated
|
||||
* here because the bridge script's `window.__hf.duration` getter already waits
|
||||
* for `window.__player.getDuration() > 0`, which only becomes true after
|
||||
* `bindRootTimelineIfAvailable()` completes in `init.ts`, which happens after
|
||||
* the `"hf-timelines-built"` listener fires. No separate gate is needed.
|
||||
* Render-mode correctness: `init.ts` gates `__renderReady` on
|
||||
* `__hfTimelinesBuilding` via `maybePublishRenderReady()`. When batching
|
||||
* starts after init (setTimeout-deferred timelines), `maybePublishRenderReady`
|
||||
* re-registers a `hf-timelines-built` listener to retry once the batch
|
||||
* completes. The bridge's `__hf.duration` getter returns 0 until
|
||||
* `__renderReady` is true, keeping `pollHfReady` waiting.
|
||||
*
|
||||
* Batch size: ~100 tweens per rAF budget. Each batch completes in <4 ms on a
|
||||
* 2023 laptop at the 8 562-tween scale; 16 ms rAF budgets are never exhausted.
|
||||
@@ -248,96 +249,77 @@ function scheduleBatch(): void {
|
||||
* All methods return `proxy` so that callers who chain off the returned value
|
||||
* continue to go through the proxy for the duration of the batching phase.
|
||||
*/
|
||||
// Methods that queue operations for batched flush (mutating tween additions).
|
||||
const BATCHED_METHODS = new Set<string>(["to", "from", "fromTo", "set", "add"]);
|
||||
const PROXY_STATE_KEYS = new Set(["__hfReal", "__hfQueue", "__hfIsProxy"]);
|
||||
|
||||
function createFlushingMethodWrapper(
|
||||
real: GsapTimeline,
|
||||
fn: (...a: unknown[]) => unknown,
|
||||
proxyRef: () => unknown,
|
||||
): (...args: unknown[]) => unknown {
|
||||
return (...args: unknown[]) => {
|
||||
flushPendingOperations();
|
||||
const result = fn.call(real, ...args);
|
||||
return result === real ? proxyRef() : result;
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTimeline(real: GsapTimeline): TimelineProxy {
|
||||
const proxy: TimelineProxy = {
|
||||
const state = {
|
||||
__hfReal: real,
|
||||
__hfQueue: [],
|
||||
__hfIsProxy: true,
|
||||
|
||||
to(...args: unknown[]): TimelineProxy {
|
||||
return enqueueTimelineOperation(proxy, "to", args);
|
||||
},
|
||||
from(...args: unknown[]): TimelineProxy {
|
||||
return enqueueTimelineOperation(proxy, "from", args);
|
||||
},
|
||||
fromTo(...args: unknown[]): TimelineProxy {
|
||||
return enqueueTimelineOperation(proxy, "fromTo", args);
|
||||
},
|
||||
set(...args: unknown[]): TimelineProxy {
|
||||
return enqueueTimelineOperation(proxy, "set", args);
|
||||
},
|
||||
add(...args: unknown[]): TimelineProxy {
|
||||
return enqueueTimelineOperation(proxy, "add", args);
|
||||
},
|
||||
|
||||
pause(...args: unknown[]): TimelineProxy {
|
||||
flushPendingOperations();
|
||||
real.pause(...args);
|
||||
return proxy;
|
||||
},
|
||||
play(...args: unknown[]): TimelineProxy {
|
||||
flushPendingOperations();
|
||||
real.play(...args);
|
||||
return proxy;
|
||||
},
|
||||
seek(...args: unknown[]): TimelineProxy {
|
||||
flushPendingOperations();
|
||||
real.seek(...args);
|
||||
return proxy;
|
||||
},
|
||||
totalTime(...args: unknown[]): unknown {
|
||||
flushPendingOperations();
|
||||
if (args.length > 0) {
|
||||
real.totalTime(...args);
|
||||
return proxy;
|
||||
}
|
||||
return real.totalTime();
|
||||
},
|
||||
time(...args: unknown[]): unknown {
|
||||
flushPendingOperations();
|
||||
if (args.length > 0) {
|
||||
real.time(...args);
|
||||
return proxy;
|
||||
}
|
||||
return real.time();
|
||||
},
|
||||
duration(...args: unknown[]): unknown {
|
||||
flushPendingOperations();
|
||||
if (args.length > 0) {
|
||||
real.duration(...args);
|
||||
return proxy;
|
||||
}
|
||||
return real.duration();
|
||||
},
|
||||
getChildren(...args: unknown[]): unknown[] {
|
||||
flushPendingOperations();
|
||||
const children = real.getChildren(...args);
|
||||
return Array.isArray(children) ? children : [];
|
||||
},
|
||||
paused(...args: unknown[]): unknown {
|
||||
flushPendingOperations();
|
||||
if (args.length > 0) {
|
||||
real.paused(...args);
|
||||
return proxy;
|
||||
}
|
||||
return real.paused();
|
||||
},
|
||||
timeScale(...args: unknown[]): unknown {
|
||||
flushPendingOperations();
|
||||
if (args.length > 0) {
|
||||
real.timeScale(...args);
|
||||
return proxy;
|
||||
}
|
||||
return real.timeScale();
|
||||
},
|
||||
kill(): void {
|
||||
flushPendingOperations();
|
||||
real.kill();
|
||||
},
|
||||
__hfQueue: [] as TimelineOperation[],
|
||||
__hfIsProxy: true as const,
|
||||
};
|
||||
|
||||
activeProxies.push(proxy);
|
||||
return proxy;
|
||||
// Use a Proxy so that ANY method or property access on the timeline is
|
||||
// forwarded to the real GSAP timeline after flushing pending operations.
|
||||
// The previous plain-object approach only forwarded an explicit allowlist,
|
||||
// which silently dropped calls like eventCallback(), vars, labels(),
|
||||
// repeat(), and other GSAP API surface — causing black frames when
|
||||
// compositions used anything outside the allowlist.
|
||||
const proxy = new Proxy(state, {
|
||||
// fallow-ignore-next-line complexity
|
||||
get(_target, prop, receiver) {
|
||||
if (PROXY_STATE_KEYS.has(prop as string)) {
|
||||
return state[prop as keyof typeof state];
|
||||
}
|
||||
if (typeof prop === "string" && BATCHED_METHODS.has(prop)) {
|
||||
return (...args: unknown[]) =>
|
||||
enqueueTimelineOperation(
|
||||
proxy as unknown as TimelineProxy,
|
||||
prop as TimelineOperationMethod,
|
||||
args,
|
||||
);
|
||||
}
|
||||
const value = (real as Record<string | symbol, unknown>)[prop];
|
||||
// Forwarded methods (getChildren, eventCallback, labels, etc.) flush
|
||||
// then delegate. Return values are NOT re-proxied — getChildren()
|
||||
// returns raw GSAP child timelines. This is intentional: batching
|
||||
// applies to the root timeline only; child timelines returned by
|
||||
// getChildren are used for enumeration/cleanup, not tween additions.
|
||||
if (typeof value === "function") {
|
||||
return createFlushingMethodWrapper(
|
||||
real,
|
||||
value as (...a: unknown[]) => unknown,
|
||||
() => receiver,
|
||||
);
|
||||
}
|
||||
// Non-function property reads also flush so post-batch reads (e.g.
|
||||
// tl.vars, tl.data) see state consistent with all applied tweens.
|
||||
if (value !== undefined) flushPendingOperations();
|
||||
return value;
|
||||
},
|
||||
|
||||
set(_target, prop, value) {
|
||||
flushPendingOperations();
|
||||
(real as Record<string | symbol, unknown>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
activeProxies.push(proxy as unknown as TimelineProxy);
|
||||
return proxy as unknown as TimelineProxy;
|
||||
}
|
||||
|
||||
// ─── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Distributed: Three.js WebGL boundary (setTimeout-deferred)",
|
||||
"description": "Same Three.js purple cube as three-boundary, but the entire GSAP timeline and Three.js setup is deferred via setTimeout(fn, 0) — the exact pattern from issue #1260. Exercises the maybePublishRenderReady race where __hfTimelinesBuilding starts false at DOMContentLoaded then flips true after init.ts has already run. Without the hf-timelines-built re-registration fix, this composition times out with 'Composition has zero duration'.",
|
||||
"tags": ["distributed", "threejs", "webgl", "deferred"],
|
||||
|
||||
"minPsnr": 25,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 10,
|
||||
"chunkSize": 10
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc8c784064e83a62324b4968ef71d3f9c5e151ccf9f3e96a725660bb6c9bb3b9
|
||||
size 22114
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>chunk-boundary: Three.js (setTimeout-deferred)</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
#main-comp {
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main-comp"
|
||||
data-composition-id="main-comp"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
// Exercises the setTimeout-deferred timeline registration pattern from
|
||||
// issue #1260. The entire GSAP timeline + Three.js setup is deferred
|
||||
// past DOMContentLoaded via setTimeout(fn, 0), which triggers the
|
||||
// maybePublishRenderReady race fixed in this PR.
|
||||
setTimeout(function () {
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 180;
|
||||
document.getElementById("main-comp").appendChild(canvas);
|
||||
|
||||
var scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0f172a);
|
||||
var camera = new THREE.PerspectiveCamera(45, 320 / 180, 0.1, 50);
|
||||
camera.position.set(0, 0, 4);
|
||||
var renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
});
|
||||
renderer.setSize(320, 180, false);
|
||||
|
||||
var geo = new THREE.BoxGeometry(1, 1, 1);
|
||||
var mat = new THREE.MeshStandardMaterial({ color: 0xa855f7 });
|
||||
var cube = new THREE.Mesh(geo, mat);
|
||||
scene.add(cube);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
var light = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
light.position.set(2, 2, 3);
|
||||
scene.add(light);
|
||||
|
||||
window.__hfThreeTime = 0;
|
||||
function renderFrame() {
|
||||
var t = window.__hfThreeTime || 0;
|
||||
cube.rotation.x = t * Math.PI;
|
||||
cube.rotation.y = t * Math.PI * 0.5;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
window.addEventListener("hf-seek", function (e) {
|
||||
if (e && e.detail && typeof e.detail.time === "number") {
|
||||
window.__hfThreeTime = e.detail.time;
|
||||
}
|
||||
renderFrame();
|
||||
});
|
||||
renderFrame();
|
||||
|
||||
var dur = gsap.timeline({ paused: true });
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = dur;
|
||||
dur.to({}, { duration: 2, onUpdate: renderFrame }, 0);
|
||||
}, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Distributed: Three.js WebGL boundary",
|
||||
"description": "2s composition (20 frames @ 10fps) with a Three.js purple cube on a dark background. Validates that the GSAP batching proxy correctly forwards WebGL render callbacks and that CDP screenshot capture composites WebGL canvas layers — catches the v0.6.80 regression where the plain-object proxy silently dropped eventCallback() and other non-allowlisted GSAP methods, producing black frames.",
|
||||
"tags": ["distributed", "threejs", "webgl"],
|
||||
|
||||
"minPsnr": 25,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.9,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 10,
|
||||
"chunkSize": 10
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc8c784064e83a62324b4968ef71d3f9c5e151ccf9f3e96a725660bb6c9bb3b9
|
||||
size 22114
|
||||
Reference in New Issue
Block a user