mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user