mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
## What Studio preview now resolves `<video src="../../assets/x.mp4">` (and the same shape for `<img>`, `<audio>`, inline `style` `url()`, and `<style>` CSS `url()`) against the sub-composition's URL — matching what the server-side bundler already does for the render path. ## Why Authored compositions live at `compositions/frames/*.html` and reference project-root assets either as plain `assets/x.mp4` (already correct because the main document's `<base href>` points at the project preview root) or as `../../assets/x.mp4` (the explicit project-root-relative form). The server-side `inlineSubCompositions` flattens sub-comps into `index.html` and rewrites the `../`-form against the sub-comp's source path so it resolves against the project root in the baked render. The browser-side runtime that mounts external sub-compositions via `fetch` did no such rewriting. So `<video src="../../assets/x.mp4">` authored inside a `compositions/frames/scene.html` resolved against the main document's base href, climbed above the project root, and 404'd in Studio preview — even though the same path rendered correctly in the final video. An OSS user (Miao Yang) hit this in a real project. ## How Added `rewriteSubCompositionAssetPaths` to the runtime `compositionLoader`. After parsing the fetched sub-composition HTML and before extracting any nodes, walk the parsed document and rewrite the same surface the server-side path touches: - `[src]` and `[href]` attributes on every element - `[style]` attribute `url(...)` references - `<style>` element CSS `url(...)` references The rewrite mirrors the producer's semantics exactly: only values that start with `../` (or are literal `..`) are rewritten — against the sub-composition's URL via `new URL(value, compositionUrl)`. Absolute URLs, root-relative paths, `data:`, hash refs, and plain `assets/x.mp4` are left untouched. **Plain relative paths must not be rewritten** because the main document's `<base href>` already covers them; rewriting would double-prefix the URL. The walk recurses into `<template>` content because authored compositions typically wrap their rendered body in a `<template>` and `querySelectorAll` does not enter template content (it lives in a detached `DocumentFragment`). ## Test plan - [x] Unit tests added (6 new tests in `compositionLoader.test.ts`): rewrites `../`-traversing src on template-wrapped sub-comps; leaves plain relative paths untouched (no double-prefix); leaves absolute / data / hash / root-relative URLs untouched; rewrites CSS `url()` in `<style>` blocks and inline `style` attributes; rewrites for non-template (full-HTML-doc) sub-comps. - [x] Full core test suite green (2065 tests). - [x] Full studio test suite green (1148 tests). - [x] Manual verification with the reporter's actual project: before the fix one `<video>` with a `../../assets/...` src returned `MEDIA_ELEMENT_ERROR: Format error`; after the fix all 7 `<video>` elements load (`readyState=4`, correct `currentSrc`). The 6 plain `assets/...` paths are *unchanged* (no double-prefix) and continue to resolve via `<base href>` as before. - [x] `bun run lint`, `bun run format:check`, `bun run typecheck`, `fallow audit` all green. Reported by Miao Yang. — Jerrai (https://claude.com/claude-code)
764 lines
29 KiB
TypeScript
764 lines
29 KiB
TypeScript
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
|
import { readDeclaredDefaults } from "./getVariables";
|
|
|
|
type LoadExternalCompositionsParams = {
|
|
injectedStyles: HTMLStyleElement[];
|
|
injectedScripts: HTMLScriptElement[];
|
|
parseDimensionPx: (value: string | null) => string | null;
|
|
onDiagnostic?: (payload: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
}) => void;
|
|
};
|
|
|
|
type PendingScript =
|
|
| {
|
|
kind: "inline";
|
|
content: string;
|
|
type: string;
|
|
scopeCompositionId: string | null;
|
|
}
|
|
| {
|
|
kind: "external";
|
|
src: string;
|
|
type: string;
|
|
};
|
|
|
|
const EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS = 8000;
|
|
const BARE_RELATIVE_PATH_RE = /^(?![a-zA-Z][a-zA-Z\d+\-.]*:)(?!\/\/)(?!\/)(?!\.\.?\/).+/;
|
|
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
|
const PATH_ATTRS = ["src", "href"] as const;
|
|
|
|
/**
|
|
* Return true for URLs/prefixes that should never be rewritten — absolute
|
|
* URLs, protocol-relative, data:, hash fragments, root-relative. Mirrors
|
|
* the compiler's `isNonRelativeUrl` so server-side bundling and client-side
|
|
* runtime rewrite use the same rules.
|
|
*/
|
|
function isNonRelativeRuntimeUrl(value: string): boolean {
|
|
return (
|
|
!value ||
|
|
value.startsWith("http://") ||
|
|
value.startsWith("https://") ||
|
|
value.startsWith("//") ||
|
|
value.startsWith("data:") ||
|
|
value.startsWith("#") ||
|
|
value.startsWith("/")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve a relative asset path from a sub-composition's URL to one that
|
|
* works in the live document.
|
|
*
|
|
* Server-side `inlineSubCompositions` rewrites `../foo.svg` from
|
|
* `compositions/scene.html` to `foo.svg` (project root). When the runtime
|
|
* mounts a sub-composition by fetching its HTML and importing its nodes
|
|
* into the main document, no such rewriting happens — so a `<video
|
|
* src="../../assets/x.mp4">` authored from `compositions/frames/*.html`
|
|
* resolves against the main document's base, climbing **above** the
|
|
* project root (e.g. `/api/projects/assets/x.mp4`) and 404s. This is the
|
|
* Studio-preview-vs-render divergence noted in the bug report.
|
|
*
|
|
* For each path that traverses up with `../`, resolve against the
|
|
* sub-composition's URL and return an absolute URL the browser can use
|
|
* directly. Plain relative paths (`assets/x.mp4`) and absolute / special
|
|
* URLs are returned unchanged — they already resolve correctly via the
|
|
* main document's base.
|
|
*/
|
|
function rewriteRuntimeAssetPath(value: string, compositionUrl: URL | null): string {
|
|
if (!compositionUrl) return value;
|
|
const trimmed = value.trim();
|
|
if (isNonRelativeRuntimeUrl(trimmed)) return value;
|
|
if (!trimmed.startsWith("../") && trimmed !== "..") return value;
|
|
try {
|
|
return new URL(trimmed, compositionUrl).href;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function rewriteRuntimeCssAssetUrls(cssText: string, compositionUrl: URL | null): string {
|
|
if (!compositionUrl || !cssText) return cssText;
|
|
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
|
const rewritten = rewriteRuntimeAssetPath(rawUrl || "", compositionUrl);
|
|
if (rewritten === rawUrl) return full;
|
|
return `url(${quote || ""}${rewritten}${quote || ""})`;
|
|
});
|
|
}
|
|
|
|
function rewritePathAttrsInTree(root: ParentNode, compositionUrl: URL): void {
|
|
for (const el of Array.from(root.querySelectorAll<Element>("[src], [href]"))) {
|
|
for (const attr of PATH_ATTRS) {
|
|
const value = el.getAttribute(attr);
|
|
if (value == null) continue;
|
|
const rewritten = rewriteRuntimeAssetPath(value, compositionUrl);
|
|
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rewriteInlineStyleUrlsInTree(root: ParentNode, compositionUrl: URL): void {
|
|
for (const el of Array.from(root.querySelectorAll<Element>("[style]"))) {
|
|
const value = el.getAttribute("style");
|
|
if (value == null) continue;
|
|
const rewritten = rewriteRuntimeCssAssetUrls(value, compositionUrl);
|
|
if (rewritten !== value) el.setAttribute("style", rewritten);
|
|
}
|
|
}
|
|
|
|
function rewriteStyleElementUrlsInTree(root: ParentNode, compositionUrl: URL): void {
|
|
for (const styleEl of Array.from(root.querySelectorAll<HTMLStyleElement>("style"))) {
|
|
const text = styleEl.textContent || "";
|
|
const rewritten = rewriteRuntimeCssAssetUrls(text, compositionUrl);
|
|
if (rewritten !== text) styleEl.textContent = rewritten;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rewrite relative asset paths in a parsed sub-composition document so
|
|
* that `../`-traversing paths resolve against the sub-composition's URL
|
|
* rather than the main document's base. Touches `[src]`, `[href]`,
|
|
* `[style]` url(...) references, and `<style>` element CSS — the same
|
|
* surface the server-side `inlineSubCompositions` rewrites.
|
|
*
|
|
* Recurses into `<template>` content because authored compositions wrap
|
|
* their rendered body in a `<template>` and querySelectorAll does not
|
|
* enter template content (it lives in a detached DocumentFragment).
|
|
* Without recursion, the rewrite would miss every `<video>` and
|
|
* `<img>` that an author placed inside the canonical template wrapper.
|
|
*/
|
|
function rewriteSubCompositionAssetPaths(root: ParentNode, compositionUrl: URL | null): void {
|
|
if (!compositionUrl) return;
|
|
rewritePathAttrsInTree(root, compositionUrl);
|
|
rewriteInlineStyleUrlsInTree(root, compositionUrl);
|
|
rewriteStyleElementUrlsInTree(root, compositionUrl);
|
|
for (const templateEl of Array.from(root.querySelectorAll<HTMLTemplateElement>("template"))) {
|
|
rewriteSubCompositionAssetPaths(templateEl.content, compositionUrl);
|
|
}
|
|
}
|
|
|
|
function uniqueCompositionId(baseId: string, index: number): string {
|
|
return `${baseId}__hf${index}`;
|
|
}
|
|
|
|
const waitForExternalScriptLoad = (
|
|
scriptEl: HTMLScriptElement,
|
|
): Promise<{ status: "load" | "error" | "timeout"; elapsedMs: number }> =>
|
|
new Promise((resolve) => {
|
|
let settled = false;
|
|
const startedAt = Date.now();
|
|
let timeoutId: number | null = null;
|
|
const settle = (status: "load" | "error" | "timeout") => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeoutId != null) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
resolve({
|
|
status,
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
});
|
|
};
|
|
scriptEl.addEventListener("load", () => settle("load"), { once: true });
|
|
scriptEl.addEventListener("error", () => settle("error"), { once: true });
|
|
timeoutId = window.setTimeout(() => settle("timeout"), EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS);
|
|
});
|
|
|
|
function resetCompositionHost(host: Element) {
|
|
while (host.firstChild) {
|
|
host.removeChild(host.firstChild);
|
|
}
|
|
host.textContent = "";
|
|
}
|
|
|
|
const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
|
|
"data-composition-id",
|
|
"data-composition-file",
|
|
"data-start",
|
|
"data-duration",
|
|
"data-end",
|
|
"data-track-index",
|
|
"data-track",
|
|
"data-composition-src",
|
|
"data-hf-authored-duration",
|
|
"data-hf-authored-end",
|
|
];
|
|
|
|
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
|
|
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
|
const authoredRootId = prepared.getAttribute("id")?.trim();
|
|
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
|
|
prepared.removeAttribute(attrName);
|
|
}
|
|
if (authoredRootId) {
|
|
prepared.removeAttribute("id");
|
|
prepared.setAttribute("data-hf-authored-id", authoredRootId);
|
|
}
|
|
prepared.setAttribute("data-hf-inner-root", "true");
|
|
const w = prepared.getAttribute("data-width");
|
|
const h = prepared.getAttribute("data-height");
|
|
prepared.style.width = w ? `${w}px` : "100%";
|
|
prepared.style.height = h ? `${h}px` : "100%";
|
|
return prepared;
|
|
}
|
|
|
|
function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): string {
|
|
const trimmedSrc = scriptSrc.trim();
|
|
if (!trimmedSrc) return scriptSrc;
|
|
try {
|
|
if (BARE_RELATIVE_PATH_RE.test(trimmedSrc)) {
|
|
// Composition payloads may use root-relative semantics without a leading slash.
|
|
return new URL(trimmedSrc, document.baseURI).toString();
|
|
}
|
|
if (compositionUrl) {
|
|
return new URL(trimmedSrc, compositionUrl).toString();
|
|
}
|
|
return new URL(trimmedSrc, document.baseURI).toString();
|
|
} catch {
|
|
return scriptSrc;
|
|
}
|
|
}
|
|
|
|
function parseHostVariableValues(host: Element): Record<string, unknown> {
|
|
const raw = host.getAttribute("data-variable-values");
|
|
if (!raw) return {};
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return {};
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
type HostCompositionIdentity = {
|
|
authoredCompositionId: string | null;
|
|
runtimeCompositionId: string | null;
|
|
};
|
|
|
|
function getHostCompositionIdentity(host: Element): HostCompositionIdentity {
|
|
const currentCompositionId = (host.getAttribute("data-composition-id") || "").trim() || null;
|
|
const authoredCompositionId =
|
|
(host.getAttribute("data-hf-original-composition-id") || currentCompositionId || "").trim() ||
|
|
null;
|
|
return {
|
|
authoredCompositionId,
|
|
runtimeCompositionId: currentCompositionId,
|
|
};
|
|
}
|
|
|
|
function countAuthoredCompositionIds(hosts: Element[]): Map<string, number> {
|
|
const hostCountsByCompositionId = new Map<string, number>();
|
|
for (const host of hosts) {
|
|
const compId = getHostCompositionIdentity(host).authoredCompositionId || "";
|
|
if (!compId) continue;
|
|
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
|
|
}
|
|
return hostCountsByCompositionId;
|
|
}
|
|
|
|
function hasMatchingInlineTemplate(host: Element): boolean {
|
|
const authoredCompositionId = getHostCompositionIdentity(host).authoredCompositionId;
|
|
if (!authoredCompositionId) return false;
|
|
return !!document.querySelector(`template#${CSS.escape(authoredCompositionId)}-template`);
|
|
}
|
|
|
|
function isMountedInlineCompositionHost(host: Element): boolean {
|
|
return !!host.querySelector('[data-hf-inner-root="true"]');
|
|
}
|
|
|
|
function shouldAssignRuntimeCompositionId(host: Element): boolean {
|
|
if (host.hasAttribute("data-composition-src")) return true;
|
|
if (!hasMatchingInlineTemplate(host)) return false;
|
|
if (host.children.length === 0) return true;
|
|
if (host.hasAttribute("data-hf-original-composition-id")) return true;
|
|
return isMountedInlineCompositionHost(host);
|
|
}
|
|
|
|
function getTrackedCompositionHosts(): Element[] {
|
|
const hosts = Array.from(
|
|
document.querySelectorAll<Element>("[data-composition-src], [data-composition-id]"),
|
|
);
|
|
return hosts.filter((host) => {
|
|
if (host.hasAttribute("data-composition-src")) return true;
|
|
return hasMatchingInlineTemplate(host);
|
|
});
|
|
}
|
|
|
|
function cleanupDetachedScopedVariables() {
|
|
const byComp = window.__hfVariablesByComp;
|
|
if (!byComp) return;
|
|
|
|
const activeRuntimeCompositionIds = new Set(
|
|
getTrackedCompositionHosts()
|
|
.map((host) => getHostCompositionIdentity(host).runtimeCompositionId)
|
|
.filter((compositionId): compositionId is string => !!compositionId),
|
|
);
|
|
|
|
for (const runtimeCompositionId of Object.keys(byComp)) {
|
|
if (!activeRuntimeCompositionIds.has(runtimeCompositionId)) {
|
|
delete byComp[runtimeCompositionId];
|
|
}
|
|
}
|
|
}
|
|
|
|
function assignRuntimeCompositionIds(
|
|
hosts: Element[],
|
|
hostCountsByCompositionId: Map<string, number> = countAuthoredCompositionIds(hosts),
|
|
): Map<Element, HostCompositionIdentity> {
|
|
const hostInstanceByCompositionId = new Map<string, number>();
|
|
const hostIdentityByElement = new Map<Element, HostCompositionIdentity>();
|
|
|
|
for (const host of hosts) {
|
|
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
|
|
getHostCompositionIdentity(host);
|
|
const shouldAssign = shouldAssignRuntimeCompositionId(host);
|
|
if (!authoredCompositionId) {
|
|
hostIdentityByElement.set(host, {
|
|
authoredCompositionId: null,
|
|
runtimeCompositionId: previousRuntimeCompositionId,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const duplicateInstance = (hostCountsByCompositionId.get(authoredCompositionId) || 0) > 1;
|
|
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
|
|
if (shouldAssign) {
|
|
const instanceIndex = duplicateInstance
|
|
? (hostInstanceByCompositionId.get(authoredCompositionId) || 0) + 1
|
|
: 0;
|
|
if (duplicateInstance) {
|
|
hostInstanceByCompositionId.set(authoredCompositionId, instanceIndex);
|
|
}
|
|
|
|
runtimeCompositionId = duplicateInstance
|
|
? uniqueCompositionId(authoredCompositionId, instanceIndex)
|
|
: authoredCompositionId;
|
|
|
|
if (duplicateInstance) {
|
|
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
|
|
} else {
|
|
host.removeAttribute("data-hf-original-composition-id");
|
|
}
|
|
host.setAttribute("data-composition-id", runtimeCompositionId);
|
|
if (
|
|
previousRuntimeCompositionId &&
|
|
previousRuntimeCompositionId !== runtimeCompositionId &&
|
|
window.__hfVariablesByComp
|
|
) {
|
|
delete window.__hfVariablesByComp[previousRuntimeCompositionId];
|
|
}
|
|
}
|
|
|
|
hostIdentityByElement.set(host, {
|
|
authoredCompositionId,
|
|
runtimeCompositionId,
|
|
});
|
|
}
|
|
|
|
return hostIdentityByElement;
|
|
}
|
|
|
|
async function mountCompositionContent(params: {
|
|
host: Element;
|
|
authoredCompositionId: string | null;
|
|
runtimeCompositionId: string | null;
|
|
hostCompositionSrc: string;
|
|
sourceNode: ParentNode;
|
|
hasTemplate: boolean;
|
|
fallbackBodyInnerHtml: string;
|
|
compositionUrl: URL | null;
|
|
injectedStyles: HTMLStyleElement[];
|
|
injectedScripts: HTMLScriptElement[];
|
|
parseDimensionPx: (value: string | null) => string | null;
|
|
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
|
|
headStyles?: HTMLStyleElement[];
|
|
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
|
|
headScripts?: HTMLScriptElement[];
|
|
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
|
|
headLinks?: HTMLLinkElement[];
|
|
/**
|
|
* Defaults extracted from the sub-composition's own
|
|
* `<html data-composition-variables="...">` attribute. Layered under the
|
|
* host element's `data-variable-values` to produce the per-instance
|
|
* variables visible inside the sub-comp's scoped `getVariables()`.
|
|
* Populated only by `loadExternalCompositions`; inline templates have no
|
|
* separate document root so no declared defaults are passed.
|
|
*/
|
|
declaredVariableDefaults?: Record<string, unknown>;
|
|
onDiagnostic?: (payload: {
|
|
code: string;
|
|
details: Record<string, string | number | boolean | null | string[]>;
|
|
}) => void;
|
|
}): Promise<void> {
|
|
let innerRoot: Element | null = null;
|
|
if (params.authoredCompositionId) {
|
|
const candidateRoots = Array.from(
|
|
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
|
|
);
|
|
innerRoot =
|
|
candidateRoots.find(
|
|
(candidate) =>
|
|
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
|
|
) ?? null;
|
|
}
|
|
const contentNode = innerRoot ?? params.sourceNode;
|
|
const authoredScopeCompositionId =
|
|
innerRoot?.getAttribute("data-composition-id")?.trim() || params.authoredCompositionId || null;
|
|
const runtimeScopeCompositionId =
|
|
params.runtimeCompositionId || authoredScopeCompositionId || null;
|
|
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
|
|
const runtimeScopeSelector = runtimeScopeCompositionId
|
|
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
|
|
: undefined;
|
|
|
|
if (params.headLinks) {
|
|
for (const link of params.headLinks) {
|
|
const href = link.getAttribute("href") || "";
|
|
if (!href) continue;
|
|
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
|
document.head.appendChild(link.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
// Inject <head> styles from non-template sub-compositions first (they define
|
|
// element styles like backgrounds and positioning that the composition needs).
|
|
if (params.headStyles) {
|
|
for (const style of params.headStyles) {
|
|
const clonedStyle = style.cloneNode(true);
|
|
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
if (authoredScopeCompositionId) {
|
|
clonedStyle.textContent = scopeCssToComposition(
|
|
clonedStyle.textContent || "",
|
|
authoredScopeCompositionId,
|
|
runtimeScopeSelector,
|
|
authoredRootId,
|
|
);
|
|
}
|
|
document.head.appendChild(clonedStyle);
|
|
params.injectedStyles.push(clonedStyle);
|
|
}
|
|
}
|
|
|
|
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
|
for (const style of styles) {
|
|
const clonedStyle = style.cloneNode(true);
|
|
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
|
if (authoredScopeCompositionId) {
|
|
clonedStyle.textContent = scopeCssToComposition(
|
|
clonedStyle.textContent || "",
|
|
authoredScopeCompositionId,
|
|
runtimeScopeSelector,
|
|
authoredRootId,
|
|
);
|
|
}
|
|
document.head.appendChild(clonedStyle);
|
|
params.injectedStyles.push(clonedStyle);
|
|
}
|
|
|
|
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
|
// then content scripts. Head scripts must execute before content scripts.
|
|
const headScriptPayloads: PendingScript[] = [];
|
|
if (params.headScripts) {
|
|
for (const script of params.headScripts) {
|
|
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
|
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
if (scriptSrc) {
|
|
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
|
|
} else {
|
|
const scriptText = script.textContent?.trim() ?? "";
|
|
if (scriptText) {
|
|
headScriptPayloads.push({
|
|
kind: "inline",
|
|
content: scriptText,
|
|
type: scriptType,
|
|
scopeCompositionId: authoredScopeCompositionId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
|
|
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
|
|
for (const script of scripts) {
|
|
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
|
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
if (scriptSrc) {
|
|
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
scriptPayloads.push({
|
|
kind: "external",
|
|
src: resolvedSrc,
|
|
type: scriptType,
|
|
});
|
|
} else {
|
|
const scriptText = script.textContent?.trim() ?? "";
|
|
if (scriptText) {
|
|
scriptPayloads.push({
|
|
kind: "inline",
|
|
content: scriptText,
|
|
type: scriptType,
|
|
scopeCompositionId: authoredScopeCompositionId,
|
|
});
|
|
}
|
|
}
|
|
script.parentNode?.removeChild(script);
|
|
}
|
|
const remainingStyles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
|
for (const style of remainingStyles) {
|
|
style.parentNode?.removeChild(style);
|
|
}
|
|
|
|
if (innerRoot) {
|
|
const widthRaw = innerRoot.getAttribute("data-width");
|
|
const heightRaw = innerRoot.getAttribute("data-height");
|
|
const widthPx = params.parseDimensionPx(widthRaw);
|
|
const heightPx = params.parseDimensionPx(heightRaw);
|
|
if (widthRaw) params.host.setAttribute("data-width", widthRaw);
|
|
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
|
|
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
|
|
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
|
|
if (innerRoot.hasAttribute("data-timeline-locked")) {
|
|
params.host.setAttribute("data-timeline-locked", "");
|
|
}
|
|
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
|
|
} else if (params.hasTemplate) {
|
|
params.host.appendChild(document.importNode(contentNode, true));
|
|
} else {
|
|
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
|
}
|
|
|
|
// Stash the per-instance variables BEFORE running scripts. The scoped
|
|
// `getVariables()` injected by `compositionScoping.ts` reads from
|
|
// `window.__hfVariablesByComp[compId]`, so this table must be populated
|
|
// before the wrapped IIFE evaluates.
|
|
if (runtimeScopeCompositionId) {
|
|
const merged = {
|
|
...(params.declaredVariableDefaults ?? {}),
|
|
...parseHostVariableValues(params.host),
|
|
};
|
|
if (Object.keys(merged).length > 0) {
|
|
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
|
|
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
|
|
} else if (window.__hfVariablesByComp) {
|
|
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
|
|
}
|
|
}
|
|
|
|
for (const scriptPayload of scriptPayloads) {
|
|
const injectedScript = document.createElement("script");
|
|
if (scriptPayload.type) {
|
|
injectedScript.type = scriptPayload.type;
|
|
}
|
|
// Preserve deterministic script execution order across injected composition scripts.
|
|
injectedScript.async = false;
|
|
if (scriptPayload.kind === "external") {
|
|
injectedScript.src = scriptPayload.src;
|
|
} else if (scriptPayload.type.toLowerCase() === "module") {
|
|
injectedScript.textContent = scriptPayload.content;
|
|
} else if (scriptPayload.scopeCompositionId) {
|
|
injectedScript.textContent = wrapScopedCompositionScript(
|
|
scriptPayload.content,
|
|
scriptPayload.scopeCompositionId,
|
|
"[HyperFrames] composition script error:",
|
|
runtimeScopeSelector,
|
|
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
|
|
authoredRootId,
|
|
);
|
|
} else {
|
|
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
|
|
}
|
|
document.body.appendChild(injectedScript);
|
|
params.injectedScripts.push(injectedScript);
|
|
if (scriptPayload.kind === "external") {
|
|
const loadResult = await waitForExternalScriptLoad(injectedScript);
|
|
if (loadResult.status !== "load") {
|
|
params.onDiagnostic?.({
|
|
code: "external_composition_script_load_issue",
|
|
details: {
|
|
hostCompositionId: params.authoredCompositionId,
|
|
runtimeCompositionId: params.runtimeCompositionId,
|
|
hostCompositionSrc: params.hostCompositionSrc,
|
|
resolvedScriptSrc: scriptPayload.src,
|
|
loadStatus: loadResult.status,
|
|
elapsedMs: loadResult.elapsedMs,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function loadInlineTemplateCompositions(
|
|
params: LoadExternalCompositionsParams,
|
|
): Promise<void> {
|
|
const trackedHosts = getTrackedCompositionHosts();
|
|
cleanupDetachedScopedVariables();
|
|
if (trackedHosts.length === 0) return;
|
|
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
|
|
const hosts = trackedHosts.filter((host) => {
|
|
if (host.hasAttribute("data-composition-src")) return false;
|
|
if (host.children.length > 0) return false;
|
|
const compId = hostIdentityByElement.get(host)?.authoredCompositionId;
|
|
if (!compId) return false;
|
|
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
|
|
});
|
|
|
|
if (hosts.length === 0) return;
|
|
|
|
for (const host of hosts) {
|
|
const hostIdentity = hostIdentityByElement.get(host);
|
|
const compId = hostIdentity?.authoredCompositionId;
|
|
if (!compId) continue;
|
|
const template = document.querySelector<HTMLTemplateElement>(
|
|
`template#${CSS.escape(compId)}-template`,
|
|
)!;
|
|
|
|
resetCompositionHost(host);
|
|
await mountCompositionContent({
|
|
host,
|
|
authoredCompositionId: compId,
|
|
runtimeCompositionId: hostIdentity?.runtimeCompositionId || compId,
|
|
hostCompositionSrc: `template#${compId}-template`,
|
|
sourceNode: template.content,
|
|
hasTemplate: true,
|
|
fallbackBodyInnerHtml: "",
|
|
compositionUrl: null,
|
|
injectedStyles: params.injectedStyles,
|
|
injectedScripts: params.injectedScripts,
|
|
parseDimensionPx: params.parseDimensionPx,
|
|
onDiagnostic: params.onDiagnostic,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function loadExternalCompositions(
|
|
params: LoadExternalCompositionsParams,
|
|
): Promise<void> {
|
|
const trackedHosts = getTrackedCompositionHosts();
|
|
cleanupDetachedScopedVariables();
|
|
if (trackedHosts.length === 0) return;
|
|
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
|
|
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,
|
|
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;
|
|
// When loading a non-template sub-composition (full HTML document),
|
|
// extract <style> and <script> elements from the parsed document's
|
|
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
|
|
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
|
|
// because mountCompositionContent only looks inside the composition
|
|
// root element.
|
|
const headStyles = !template
|
|
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
|
|
: undefined;
|
|
const headScripts = !template
|
|
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
|
|
: undefined;
|
|
const headLinks = !template
|
|
? Array.from(
|
|
doc.head.querySelectorAll<HTMLLinkElement>(
|
|
'link[rel="stylesheet"], link[rel="preconnect"]',
|
|
),
|
|
)
|
|
: undefined;
|
|
await mountCompositionContent({
|
|
host,
|
|
authoredCompositionId,
|
|
runtimeCompositionId,
|
|
hostCompositionSrc: src,
|
|
sourceNode,
|
|
hasTemplate: Boolean(template),
|
|
fallbackBodyInnerHtml: doc.body.innerHTML,
|
|
compositionUrl,
|
|
injectedStyles: params.injectedStyles,
|
|
injectedScripts: params.injectedScripts,
|
|
parseDimensionPx: params.parseDimensionPx,
|
|
headStyles,
|
|
headScripts,
|
|
headLinks,
|
|
declaredVariableDefaults: readDeclaredDefaults(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);
|
|
}
|
|
}),
|
|
);
|
|
}
|