Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/src/compiler/htmlDocument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("htmlDocument helpers", () => {
<script src="hyperframe-runtime.modular-runtime.inline.js"></script>
<script data-hyperframes-preview-runtime="1"></script>
<script>window.__playerReady = true;</script >
<script>window.__renderReady = false;</script>
<script>window.authored = true;</script>`;

const stripped = stripEmbeddedRuntimeScripts(html);
Expand All @@ -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 = `
<script>
window.__timelines = window.__timelines || {};
if (window.__renderReady) window.authoredReadySeen = true;
window.__timelines["main"] = {};
</script>`;

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 = "<scripture>window.__playerReady = true;</scripture>";

Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/compiler/htmlDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*;?$/,
];

/**
Expand Down Expand Up @@ -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("</script");
const end = closeTagStart === -1 ? block.length : closeTagStart;
return block.slice(startTagEnd + 1, end);
}

function isTagBoundary(char: string): boolean {
return char === "" || char === ">" || char === "/" || isHtmlWhitespace(char);
}
Expand Down
100 changes: 99 additions & 1 deletion packages/core/src/runtime/adapters/three.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
},
};
}
51 changes: 51 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
};

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading