diff --git a/packages/core/src/compiler/htmlDocument.test.ts b/packages/core/src/compiler/htmlDocument.test.ts
index e1f39c92f..449dceb81 100644
--- a/packages/core/src/compiler/htmlDocument.test.ts
+++ b/packages/core/src/compiler/htmlDocument.test.ts
@@ -19,6 +19,7 @@ describe("htmlDocument helpers", () => {
+
`;
const stripped = stripEmbeddedRuntimeScripts(html);
@@ -28,9 +29,24 @@ describe("htmlDocument helpers", () => {
expect(stripped).not.toContain("hyperframe-runtime.modular-runtime.inline.js");
expect(stripped).not.toContain("data-hyperframes-preview-runtime");
expect(stripped).not.toContain("window.__playerReady");
+ expect(stripped).not.toContain("window.__renderReady");
expect(stripped).toContain("window.authored = true");
});
+ it("keeps authored scripts that reference runtime readiness flags", () => {
+ const html = `
+`;
+
+ const stripped = stripEmbeddedRuntimeScripts(html);
+
+ expect(stripped).toContain('window.__timelines["main"]');
+ expect(stripped).toContain("window.__renderReady");
+ });
+
it("does not treat non-script tags as scripts when stripping runtimes", () => {
const html = "window.__playerReady = true;";
diff --git a/packages/core/src/compiler/htmlDocument.ts b/packages/core/src/compiler/htmlDocument.ts
index 065d65ea1..6d2e84da5 100644
--- a/packages/core/src/compiler/htmlDocument.ts
+++ b/packages/core/src/compiler/htmlDocument.ts
@@ -13,9 +13,13 @@ const RUNTIME_INLINE_MARKERS = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
+ "__HF_EXPORT_RENDER_SEEK_CONFIG",
"window.__player =",
- "window.__playerReady",
- "window.__renderReady",
+];
+
+const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [
+ /^window\.__playerReady\s*=\s*(?:true|false)\s*;?$/,
+ /^window\.__renderReady\s*=\s*(?:true|false)\s*;?$/,
];
/**
@@ -115,9 +119,22 @@ function shouldStripRuntimeScriptBlock(block: string): boolean {
for (const marker of RUNTIME_INLINE_MARKERS) {
if (block.includes(marker)) return true;
}
+ const scriptSource = getScriptSource(block).trim();
+ for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) {
+ if (pattern.test(scriptSource)) return true;
+ }
return false;
}
+function getScriptSource(block: string): string {
+ const startTagEnd = findTagEnd(block, 1);
+ if (startTagEnd === -1) return "";
+ const loweredBlock = block.toLowerCase();
+ const closeTagStart = loweredBlock.lastIndexOf("" || char === "/" || isHtmlWhitespace(char);
}
diff --git a/packages/core/src/runtime/adapters/three.ts b/packages/core/src/runtime/adapters/three.ts
index c7ddfc9f7..a044d5a16 100644
--- a/packages/core/src/runtime/adapters/three.ts
+++ b/packages/core/src/runtime/adapters/three.ts
@@ -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 | 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((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;
+ },
};
}
diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index 2d219f7db..2549c21bb 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -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");
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 4d0133a92..fb21ae723 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -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 | null = null;
+ let trackedAdapterReadySettled = true;
+
+ const collectAdapterReadyPromises = (): PromiseLike[] => {
+ const promises: PromiseLike[] = [];
+ 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 =
+ 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();
};
diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts
index 6d6acc899..3c56977bc 100644
--- a/packages/core/src/runtime/types.ts
+++ b/packages/core/src/runtime/types.ts
@@ -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 | null;
};
export type RuntimeGsapSetTarget = string | Element | Element[] | null;