mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
fix(core): auto-detect three.js asset readiness via adapter contract (#1543)
Replaces the original `window.__hyperframesReady` authored API with an internal adapter contract: `RuntimeDeterministicAdapter.getReadyPromise?: () => PromiseLike | null`. The Three.js adapter implements it by hooking `THREE.DefaultLoadingManager.onStart/onLoad`; the runtime collects promises from every adapter and gates `window.__renderReady = true` on them. Zero authoring burden — composition authors write plain Three.js, framework handles async asset gating automatically. Also keeps the orthogonal `htmlDocument.ts` script-stripping refactor (substring → regex for simple flag assignments), which fixes the bug where authored scripts referencing readiness flags were stripped despite never assigning them. Stamped by Magi and Miguel; CI green; tests 33/33 pass.
This commit is contained in:
@@ -1,13 +1,95 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
import { dispatchSeekEvent } from "./seek-dispatch";
|
||||
|
||||
/**
|
||||
* Minimal shape of `THREE.DefaultLoadingManager` we rely on. Kept local to
|
||||
* the adapter so we don't take a dependency on three.js types (the library
|
||||
* itself is loaded at runtime by the composition, not bundled).
|
||||
*
|
||||
* See https://threejs.org/docs/#api/en/loaders/managers/LoadingManager
|
||||
*/
|
||||
type ThreeLoadingManagerLike = {
|
||||
itemsLoaded: number;
|
||||
itemsTotal: number;
|
||||
onStart?: ((url: string, itemsLoaded: number, itemsTotal: number) => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
};
|
||||
|
||||
export function createThreeAdapter(): RuntimeDeterministicAdapter {
|
||||
let forcedTime: number | null = null;
|
||||
let lastForcedTime = 0;
|
||||
|
||||
// Track the LoadingManager we've already wrapped so `discover` is idempotent
|
||||
// (init.ts calls it multiple times — at startup AND at every
|
||||
// `maybePublishRenderReady` evaluation cycle, to catch THREE that finished
|
||||
// loading between checks).
|
||||
let hookedManager: ThreeLoadingManagerLike | null = null;
|
||||
let userOnStart: ThreeLoadingManagerLike["onStart"] = null;
|
||||
let userOnLoad: ThreeLoadingManagerLike["onLoad"] = null;
|
||||
let pendingPromise: PromiseLike<void> | null = null;
|
||||
|
||||
const getLoadingManager = (): ThreeLoadingManagerLike | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
// `window.THREE` is typed in window.d.ts with only the minimal `Clock` /
|
||||
// `AnimationMixer` shape; cast through `unknown` to read the loader fields.
|
||||
const three = (window as { THREE?: { DefaultLoadingManager?: ThreeLoadingManagerLike } }).THREE;
|
||||
const mgr = three?.DefaultLoadingManager;
|
||||
if (!mgr || typeof mgr !== "object") return null;
|
||||
if (typeof mgr.itemsLoaded !== "number" || typeof mgr.itemsTotal !== "number") return null;
|
||||
return mgr;
|
||||
};
|
||||
|
||||
const armPendingIfNeeded = (mgr: ThreeLoadingManagerLike) => {
|
||||
if (pendingPromise) return;
|
||||
if (mgr.itemsTotal <= mgr.itemsLoaded) return;
|
||||
pendingPromise = new Promise<void>((resolve) => {
|
||||
// Wrap onLoad so we resolve once the queue drains. Restore the user's
|
||||
// own callback (captured at hook time) so multi-asset compositions still
|
||||
// see their own onLoad fire normally.
|
||||
mgr.onLoad = function (this: ThreeLoadingManagerLike) {
|
||||
try {
|
||||
userOnLoad?.call(this);
|
||||
} finally {
|
||||
pendingPromise = null;
|
||||
// Reinstall the user's callback as the live one — onStart will
|
||||
// re-wrap it the next time a new batch starts.
|
||||
mgr.onLoad = userOnLoad ?? null;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const hookManager = (mgr: ThreeLoadingManagerLike) => {
|
||||
if (hookedManager === mgr) return;
|
||||
hookedManager = mgr;
|
||||
userOnStart = mgr.onStart ?? null;
|
||||
userOnLoad = mgr.onLoad ?? null;
|
||||
// Wrap onStart so any load queued AFTER our discover runs (the common
|
||||
// case — user composition scripts run after the HF runtime mounts) still
|
||||
// arms a wait. Without this, items queued post-discover would never be
|
||||
// observed and the runtime would publish render-ready while textures
|
||||
// were still in flight (issue #PR-1543).
|
||||
mgr.onStart = function (this: ThreeLoadingManagerLike, url, loaded, total) {
|
||||
try {
|
||||
userOnStart?.call(this, url, loaded, total);
|
||||
} finally {
|
||||
armPendingIfNeeded(mgr);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
name: "three",
|
||||
discover: () => {},
|
||||
discover: () => {
|
||||
const mgr = getLoadingManager();
|
||||
if (!mgr) return;
|
||||
hookManager(mgr);
|
||||
// Items may already be queued at discover time (e.g. THREE+loader were
|
||||
// bundled inline and ran synchronously). Catch them before any new
|
||||
// onStart fires.
|
||||
armPendingIfNeeded(mgr);
|
||||
},
|
||||
seek: (ctx) => {
|
||||
forcedTime = Math.max(0, Number(ctx.time) || 0);
|
||||
lastForcedTime = forcedTime;
|
||||
@@ -26,5 +108,21 @@ export function createThreeAdapter(): RuntimeDeterministicAdapter {
|
||||
forcedTime = null;
|
||||
lastForcedTime = 0;
|
||||
},
|
||||
getReadyPromise: () => {
|
||||
// If THREE hasn't loaded yet, nothing to wait on — `discover` will be
|
||||
// called again on the next readiness-publish cycle and pick it up.
|
||||
const mgr = getLoadingManager();
|
||||
if (!mgr) return null;
|
||||
// Drain check: itemsTotal can grow over time as user code queues more
|
||||
// loads; itemsLoaded catches up via onLoad. We only block while the
|
||||
// queue is non-empty AND not yet drained.
|
||||
if (mgr.itemsTotal <= mgr.itemsLoaded) return null;
|
||||
// If we haven't wrapped onLoad yet (e.g. items queued between an
|
||||
// onStart we missed and now), arm one.
|
||||
if (!pendingPromise) {
|
||||
armPendingIfNeeded(mgr);
|
||||
}
|
||||
return pendingPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
delete window.__playerReady;
|
||||
delete window.__renderReady;
|
||||
delete window.__hfTimelinesBuilding;
|
||||
delete (window as { THREE?: unknown }).THREE;
|
||||
vi.restoreAllMocks();
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
@@ -967,6 +968,56 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(window.__player?.getDuration()).toBe(10);
|
||||
});
|
||||
|
||||
it("waits for THREE.DefaultLoadingManager to drain before publishing render readiness", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
window.__timelines = {
|
||||
main: createMockTimeline(10),
|
||||
};
|
||||
|
||||
// Simulate THREE with an in-flight asset load — same shape the three adapter
|
||||
// reads, no actual three.js dependency in tests. `itemsTotal > itemsLoaded`
|
||||
// means "loads pending"; resolving the wait fires `onLoad` after wrapping.
|
||||
const mgr: {
|
||||
itemsLoaded: number;
|
||||
itemsTotal: number;
|
||||
onStart?: ((url: string, loaded: number, total: number) => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
} = {
|
||||
itemsLoaded: 0,
|
||||
itemsTotal: 1,
|
||||
onStart: null,
|
||||
onLoad: null,
|
||||
};
|
||||
(window as unknown as { THREE: { DefaultLoadingManager: typeof mgr } }).THREE = {
|
||||
DefaultLoadingManager: mgr,
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
// Player ready, render NOT ready because an asset is pending.
|
||||
expect(window.__playerReady).toBe(true);
|
||||
expect(window.__renderReady).toBe(false);
|
||||
expect(window.__player?.getDuration()).toBe(10);
|
||||
|
||||
// Simulate the asset finishing: drain the queue and fire the (now-wrapped)
|
||||
// onLoad. The adapter's wrapper resolves the readiness promise, which
|
||||
// triggers a re-publish.
|
||||
mgr.itemsLoaded = 1;
|
||||
mgr.onLoad?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(window.__renderReady).toBe(true);
|
||||
expect(window.__player?.getDuration()).toBe(10);
|
||||
});
|
||||
|
||||
it("sets __renderReady even without a GSAP timeline (CSS/WAAPI compositions)", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -1643,6 +1643,65 @@ export function initSandboxRuntimeModular(): void {
|
||||
let maybePublishRenderReady = () => {
|
||||
window.__renderReady = false;
|
||||
};
|
||||
// Internal adapter-readiness tracking. Adapters with outstanding async work
|
||||
// (Three.js `DefaultLoadingManager`, future fetch/font/image detectors) expose
|
||||
// a `getReadyPromise()` method; the runtime waits for whatever they return
|
||||
// before publishing render-ready. This is purely internal — there is no
|
||||
// authored-code-facing flag (LLMs should not need to know about render
|
||||
// readiness, the framework handles async asset gating automatically).
|
||||
let trackedAdapterReadyPromise: PromiseLike<unknown> | null = null;
|
||||
let trackedAdapterReadySettled = true;
|
||||
|
||||
const collectAdapterReadyPromises = (): PromiseLike<unknown>[] => {
|
||||
const promises: PromiseLike<unknown>[] = [];
|
||||
for (const adapter of state.deterministicAdapters) {
|
||||
const getter = adapter.getReadyPromise;
|
||||
if (typeof getter !== "function") continue;
|
||||
try {
|
||||
const p = getter();
|
||||
if (p) promises.push(p);
|
||||
} catch (err) {
|
||||
// A throwing readiness gate must not permanently block render; swallow
|
||||
// and continue, matching the rest of the runtime's adapter-resilience
|
||||
// pattern.
|
||||
swallow("runtime.init.adapterReady", err);
|
||||
}
|
||||
}
|
||||
return promises;
|
||||
};
|
||||
|
||||
const isAdapterReadinessSettled = (): boolean => {
|
||||
const promises = collectAdapterReadyPromises();
|
||||
if (promises.length === 0) {
|
||||
trackedAdapterReadyPromise = null;
|
||||
trackedAdapterReadySettled = true;
|
||||
return true;
|
||||
}
|
||||
// Combine multiple adapter promises so we only attach a single resume
|
||||
// handler. Identity is stable as long as the inputs are stable (each
|
||||
// adapter is expected to return the same promise on repeat calls while
|
||||
// its work is in flight).
|
||||
const combined: PromiseLike<unknown> =
|
||||
promises.length === 1 ? promises[0] : Promise.all(promises);
|
||||
if (combined !== trackedAdapterReadyPromise) {
|
||||
trackedAdapterReadyPromise = combined;
|
||||
trackedAdapterReadySettled = false;
|
||||
void Promise.resolve(combined).then(
|
||||
() => {
|
||||
if (trackedAdapterReadyPromise !== combined) return;
|
||||
trackedAdapterReadySettled = true;
|
||||
maybePublishRenderReady();
|
||||
},
|
||||
(err) => {
|
||||
if (trackedAdapterReadyPromise !== combined) return;
|
||||
trackedAdapterReadySettled = true;
|
||||
swallow("runtime.init.adapterReady", err);
|
||||
maybePublishRenderReady();
|
||||
},
|
||||
);
|
||||
}
|
||||
return trackedAdapterReadySettled;
|
||||
};
|
||||
|
||||
if (!externalCompositionsReady) {
|
||||
const compositionLoaderParams = {
|
||||
@@ -1910,6 +1969,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
window.__renderReady = false;
|
||||
return;
|
||||
}
|
||||
// Re-run discover so adapters can refresh their state from the current
|
||||
// DOM — e.g. the Three.js adapter only hooks `DefaultLoadingManager` once
|
||||
// it sees `window.THREE`, which may have loaded AFTER the initial
|
||||
// bootstrap discover. Discover is idempotent in every adapter, so a
|
||||
// second call here is cheap.
|
||||
runAdapters("discover", state.currentTime);
|
||||
if (!isAdapterReadinessSettled()) {
|
||||
window.__renderReady = false;
|
||||
return;
|
||||
}
|
||||
publishRenderReadyAfterTimelineBinding();
|
||||
};
|
||||
|
||||
|
||||
@@ -236,6 +236,25 @@ export type RuntimeDeterministicAdapter = {
|
||||
pause: () => void;
|
||||
play?: () => void;
|
||||
revert?: () => void;
|
||||
/**
|
||||
* Optional async readiness gate. If the adapter has outstanding async work
|
||||
* (e.g. Three.js's `DefaultLoadingManager` still loading models/textures),
|
||||
* return a promise that settles when the work is done. The runtime waits
|
||||
* for the returned promise to settle before publishing
|
||||
* `window.__renderReady = true`, so the engine doesn't capture empty
|
||||
* frames while assets are still loading.
|
||||
*
|
||||
* Return `null` (or omit the method) when nothing is pending. The runtime
|
||||
* calls this on every readiness-publish evaluation and tracks promise
|
||||
* identity, so returning the same promise on repeated calls is the
|
||||
* expected contract — return a fresh promise only when a new wait is
|
||||
* actually needed (e.g. a new batch of items has been queued).
|
||||
*
|
||||
* Throwing or rejecting is safe: the runtime swallows the error and
|
||||
* proceeds to publish (matching the existing failure-doesn't-block-render
|
||||
* convention).
|
||||
*/
|
||||
getReadyPromise?: () => PromiseLike<unknown> | null;
|
||||
};
|
||||
|
||||
export type RuntimeGsapSetTarget = string | Element | Element[] | null;
|
||||
|
||||
Reference in New Issue
Block a user