perf(core): let a preview play once its opening scenes attach

The runtime reported ready only after every external scene had loaded. It now
requests scenes in playback order and publishes __playReady (the player's ready
and first-frame gate) once the scenes starting in the first 2 s are attached;
later scenes nest into the root timeline as they arrive. __renderReady still
waits for every scene, so renders and thumbnails are unchanged.
This commit is contained in:
Miguel Ángel
2026-09-23 00:59:55 -04:00
parent bd87e8524f
commit d1cee17007
6 changed files with 219 additions and 98 deletions
+5 -2
View File
@@ -170,6 +170,7 @@ const RENDER_READY_POLL_MS = 50;
// `__hf` is omitted from Window so the runtime program's strict shape can't conflict with `unknown`.
interface RuntimeReadinessWindow extends Omit<Window, "__hf"> {
__renderReady?: boolean;
__playReady?: boolean;
// __renderReady is only ever set by init.ts, which always sets __hf
// first (`window.__hf = window.__hf || {}`) — a doc with no __hf can
// never get __renderReady either, so this alone is enough to gate on
@@ -184,7 +185,9 @@ interface RuntimeReadinessWindow extends Omit<Window, "__hf"> {
export function computeReadinessInput(doc: Document, signal: AbortSignal): Promise<void> | null {
const win = doc.defaultView as RuntimeReadinessWindow | null;
if (!win) return null;
if (win.__renderReady) return null;
// A preview can play once its playable scenes are attached; renders keep waiting on __renderReady.
const computeReady = () => win.__renderReady === true || win.__playReady === true;
if (computeReady()) return null;
if (!win.__hf) return null;
return new Promise<void>((resolve) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -199,7 +202,7 @@ export function computeReadinessInput(doc: Document, signal: AbortSignal): Promi
}
signal.addEventListener("abort", finish, { once: true });
const poll = () => {
if (win.__renderReady) {
if (computeReady()) {
finish();
return;
}
@@ -36,6 +36,37 @@ describe("loadExternalCompositions", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("requests scenes in playback order and reports each one as it attaches", async () => {
for (const [id, start] of [
["third", 10],
["first", 0],
["second", 5],
] as const) {
const host = document.createElement("div");
host.setAttribute("data-composition-src", `https://example.com/${id}.html`);
host.setAttribute("data-composition-id", id);
host.setAttribute("data-start", String(start));
document.body.appendChild(host);
}
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (input) => new Response(`<div>${String(input)}</div>`));
const attached: string[] = [];
await loadExternalCompositions({
...defaultParams,
startOf: (host) => Number(host.getAttribute("data-start")),
onAttached: (host) => attached.push(host.getAttribute("data-composition-id") ?? ""),
});
expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([
"https://example.com/first.html",
"https://example.com/second.html",
"https://example.com/third.html",
]);
expect(attached.sort()).toEqual(["first", "second", "third"]);
});
it("fetches and mounts external composition HTML", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
+99 -92
View File
@@ -13,6 +13,10 @@ import {
import { isElementNode, isHtmlElement, isLinkElement, isStyleElement } from "./domRealm";
type LoadExternalCompositionsParams = {
/** Start time of a scene host; scenes are requested in this order. */
startOf?: (host: Element) => number;
/** Called as each scene finishes loading (or fails), before the whole set settles. */
onAttached?: (host: Element) => void;
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
injectedLinks: HTMLLinkElement[];
@@ -641,111 +645,114 @@ export async function loadExternalCompositions(
const hosts = trackedHosts.filter((host) => host.hasAttribute("data-composition-src"));
if (hosts.length === 0) return;
await Promise.all(
hosts.map(async (host) => {
const src = host.getAttribute("data-composition-src");
if (!src) return;
const hostIdentity = hostIdentityByElement.get(host);
const authoredCompositionId = hostIdentity?.authoredCompositionId || null;
const runtimeCompositionId =
hostIdentity?.runtimeCompositionId || authoredCompositionId || null;
let compositionUrl: URL | null = null;
try {
compositionUrl = new URL(src, document.baseURI);
} catch {
compositionUrl = null;
}
resetCompositionHost(host);
try {
const localTemplate =
authoredCompositionId != null
? document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null;
if (localTemplate) {
await mountCompositionContent({
host,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode: localTemplate.content,
hasTemplate: true,
fallbackBodyInnerHtml: "",
compositionUrl,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
injectedLinks: params.injectedLinks,
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
return;
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Rewrite project-root-traversing (`../`) asset paths against the
// sub-composition's URL before extracting any nodes. Without this,
// `<video src="../../assets/x.mp4">` authored from
// `compositions/frames/scene.html` resolves against the main
// document's base (the project preview root) and climbs above it
// to 404 — the Studio-preview-vs-render divergence reported by
// OSS users. The server-side bundler already does this for the
// baked render via `inlineSubCompositions`; this is the runtime
// mirror so live preview matches.
rewriteSubCompositionAssetPaths(doc, compositionUrl);
const template =
(authoredCompositionId
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
const loadHost = async (host: Element): Promise<void> => {
const src = host.getAttribute("data-composition-src");
if (!src) return;
const hostIdentity = hostIdentityByElement.get(host);
const authoredCompositionId = hostIdentity?.authoredCompositionId || null;
const runtimeCompositionId =
hostIdentity?.runtimeCompositionId || authoredCompositionId || null;
let compositionUrl: URL | null = null;
try {
compositionUrl = new URL(src, document.baseURI);
} catch {
compositionUrl = null;
}
resetCompositionHost(host);
try {
const localTemplate =
authoredCompositionId != null
? document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null;
if (localTemplate) {
await mountCompositionContent({
host,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode,
hasTemplate: Boolean(template),
fallbackBodyInnerHtml: doc.body.innerHTML,
sourceNode: localTemplate.content,
hasTemplate: true,
fallbackBodyInnerHtml: "",
compositionUrl,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
injectedLinks: params.injectedLinks,
parseDimensionPx: params.parseDimensionPx,
// A non-templated composition's <head> carries critical CSS
// (backgrounds, positioning, fonts) and library scripts; every
// composition's <head> can carry a webfont <link>. The shared
// assembly module decides which of those apply.
head: doc.head,
// TODO(template-var-carriers): reads `<html>` only. A template/fragment
// sub-comp that declares on its `[data-composition-id]` root div (the
// dual-carrier contract from #2081) loses its defaults on this lazy
// external-load path — see inlineSubCompositions for the fixed path.
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
variableDeclarer: doc.documentElement,
onDiagnostic: params.onDiagnostic,
});
} catch (error) {
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},
});
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
resetCompositionHost(host);
return;
}
}),
);
const response = await fetch(src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Rewrite project-root-traversing (`../`) asset paths against the
// sub-composition's URL before extracting any nodes. Without this,
// `<video src="../../assets/x.mp4">` authored from
// `compositions/frames/scene.html` resolves against the main
// document's base (the project preview root) and climbs above it
// to 404 — the Studio-preview-vs-render divergence reported by
// OSS users. The server-side bundler already does this for the
// baked render via `inlineSubCompositions`; this is the runtime
// mirror so live preview matches.
rewriteSubCompositionAssetPaths(doc, compositionUrl);
const template =
(authoredCompositionId
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
await mountCompositionContent({
host,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode,
hasTemplate: Boolean(template),
fallbackBodyInnerHtml: doc.body.innerHTML,
compositionUrl,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
injectedLinks: params.injectedLinks,
parseDimensionPx: params.parseDimensionPx,
// A non-templated composition's <head> carries critical CSS
// (backgrounds, positioning, fonts) and library scripts; every
// composition's <head> can carry a webfont <link>. The shared
// assembly module decides which of those apply.
head: doc.head,
// TODO(template-var-carriers): reads `<html>` only. A template/fragment
// sub-comp that declares on its `[data-composition-id]` root div (the
// dual-carrier contract from #2081) loses its defaults on this lazy
// external-load path — see inlineSubCompositions for the fixed path.
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
variableDeclarer: doc.documentElement,
onDiagnostic: params.onDiagnostic,
});
} catch (error) {
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},
});
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
resetCompositionHost(host);
}
};
// Requested in playback order (the browser queues past its connection limit in issue order),
// and each scene reports as it attaches, so the first scenes can play while later ones arrive.
const startOf = params.startOf;
const ordered = startOf ? [...hosts].sort((a, b) => startOf(a) - startOf(b)) : hosts;
await Promise.all(ordered.map((host) => loadHost(host).finally(() => params.onAttached?.(host))));
}
/**
+44
View File
@@ -192,6 +192,7 @@ describe("initSandboxRuntimeModular", () => {
delete window.__player;
delete window.__playerReady;
delete window.__renderReady;
delete window.__playReady;
delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG;
delete window.__hfTimelinesBuilding;
delete (window as { THREE?: unknown }).THREE;
@@ -1080,6 +1081,49 @@ describe("initSandboxRuntimeModular", () => {
expect(child.style.visibility).toBe("visible");
});
it("is ready to play once the opening scenes attach, and ready to render once all do", 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-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
for (const [id, start] of [
["opening", "0"],
["later", "5"],
] as const) {
const host = document.createElement("div");
host.setAttribute("data-composition-id", id);
host.setAttribute("data-composition-src", `https://example.com/${id}.html`);
host.setAttribute("data-start", start);
host.setAttribute("data-duration", "5");
root.appendChild(host);
}
let deliverLater: (response: Response) => void = () => {};
const later = new Promise<Response>((resolve) => (deliverLater = resolve));
const scene = (id: string) =>
new Response(
`<template id="${id}-template"><div data-composition-id="${id}">${id}</div></template>`,
);
vi.spyOn(globalThis, "fetch").mockImplementation((input) =>
String(input).includes("later") ? later : Promise.resolve(scene("opening")),
);
window.__timelines = {
main: createMockTimeline(10),
opening: createMockTimeline(5),
later: createMockTimeline(5),
};
initSandboxRuntimeModular();
await vi.waitFor(() => expect(window.__playReady).toBe(true));
expect(window.__renderReady).toBe(false);
deliverLater(scene("later"));
await vi.waitFor(() => expect(window.__renderReady).toBe(true));
});
it("removes external composition head links during runtime teardown", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+38 -4
View File
@@ -820,6 +820,10 @@ export function initSandboxRuntimeModular(): void {
}
}
let externalCompositionsReady = !hasExternalCompositions && !hasInlineTemplateCompositions;
// Scenes starting within this window must be attached before the player may start; the rest
// attach while it plays. Renders still wait for every scene (`__renderReady`).
const PLAYABLE_WINDOW_SECONDS = 2;
let playableWindowReady = externalCompositionsReady;
const getTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null): number | null => {
if (!timeline || typeof timeline.duration !== "function") return null;
@@ -1740,7 +1744,7 @@ export function initSandboxRuntimeModular(): void {
// init runs early, but if GSAP wasn't ready then (load-order race) it's a
// no-op with no retry — so re-assert here, at the render site. Idempotent.
ensureStudioCustomEase();
if (!externalCompositionsReady) return false;
if (!playableWindowReady) return false;
const currentTimeline = state.capturedTimeline;
const currentDuration = getTimelineDurationSeconds(currentTimeline);
const currentTimelineUsable = isUsableTimelineDuration(currentDuration);
@@ -3021,10 +3025,39 @@ export function initSandboxRuntimeModular(): void {
});
},
};
void loadExternalCompositions(compositionLoaderParams)
const sceneStarts = createRuntimeStartTimeResolver({
timelineRegistry: (window.__timelines ?? {}) as Record<
string,
RuntimeTimelineLike | undefined
>,
includeAuthoredTimingAttrs: true,
});
const sceneStart = (host: Element) => sceneStarts.resolveStartForElement(host, 0);
const windowScenes = new Set(
Array.from(document.querySelectorAll("[data-composition-src]")).filter(
(host) => sceneStart(host) < PLAYABLE_WINDOW_SECONDS,
),
);
const onSceneAttached = (host: Element) => {
windowScenes.delete(host);
if (!playableWindowReady && windowScenes.size > 0) return;
playableWindowReady = true;
// A scene that attaches after the root timeline bound is nested on the next binding pass.
childrenBound = false;
bindMediaMetadataListeners();
applyVariableBindings(document);
maybePublishRenderReady();
};
void loadExternalCompositions({
...compositionLoaderParams,
startOf: sceneStart,
onAttached: onSceneAttached,
})
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
playableWindowReady = true;
childrenBound = false;
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
applyCaptionOverrides();
@@ -3335,7 +3368,8 @@ export function initSandboxRuntimeModular(): void {
// __renderReady = timeline binding attempted, safe for deterministic seeking.
// Set after any GSAP batching has completed. renderSeek works with or
// without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only).
window.__renderReady = true;
window.__renderReady = externalCompositionsReady;
window.__playReady = true;
postTimeline();
postState(true);
};
@@ -3358,7 +3392,7 @@ export function initSandboxRuntimeModular(): void {
});
maybePublishRenderReady = () => {
if (!externalCompositionsReady) {
if (!playableWindowReady) {
window.__renderReady = false;
return;
}
+2
View File
@@ -73,6 +73,8 @@ declare global {
};
__playerReady?: boolean;
__renderReady?: boolean;
/** The scenes a player needs to start playing are attached; later scenes may still load. */
__playReady?: boolean;
__hfRuntimeTeardown?: (() => void) | null;
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
mode?: string;