feat(core): a composition's script can load its own files with __hyperframes.assetUrl (#4434)

* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path
This commit is contained in:
Miguel Ángel
2026-09-24 21:13:27 -04:00
committed by GitHub
parent 177464b4c1
commit b8dd023a97
12 changed files with 153 additions and 1 deletions
+29
View File
@@ -242,6 +242,35 @@ script, CLI overrides, and which value wins. If you are building tooling on
`@hyperframes/core`, `extractCompositionMetadata()` reads the same
`data-composition-variables` array — that is how Studio builds its editing UI.
## Load a composition's own files from its script
When a composition is mounted, a `src` or `href` attribute that points at a
file next to it is rewritten to where that file lives, so
`<img src="assets/logo.png">` keeps working. A path your script builds is a
plain string and is never rewritten, so resolve it with
`window.__hyperframes.assetUrl()`. It takes a path written relative to the
composition's own file and returns a URL the page can load, whether the file is
mounted, previewed or opened on its own.
A path meant from the project root, such as `assets/logo.png` in a composition
at `compositions/intro.html` whose image sits in the project's own `assets/`
folder, already resolves from the page. Leave it as a plain string:
`assetUrl()` would look for it beside the composition and miss.
```html compositions/globe/globe.html
<div data-composition-id="globe" data-width="1920" data-height="1080">
<script>
const texture = new THREE.TextureLoader().load(
__hyperframes.assetUrl("assets/earth.jpg"),
);
</script>
</div>
```
A separate script file (`<script src="...">`) has no composition of its own to
resolve against. Resolve the URLs in the composition's inline script and hand
them over, for example on a global the file reads.
## See every composition in a project
```bash
@@ -165,6 +165,39 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("resolves __hyperframes.assetUrl against the mounted composition's own file", () => {
const run = (compositionSrc?: string) => {
const { document } = parseHTML(`<div data-composition-id="blk"></div>`);
Object.defineProperty(document, "baseURI", {
value: "https://p.test/api/projects/x/preview/",
});
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hyperframes: { assetUrl: () => "TOP-LEVEL", getVariables: () => ({}) },
};
const wrapped = wrapScopedCompositionScript(
`window.__url = __hyperframes.assetUrl("assets/env.hdr");`,
"blk",
undefined,
undefined,
undefined,
null,
compositionSrc,
);
new Function("window", wrapped)(fakeWindow);
return fakeWindow.__url;
};
expect(run("compositions/blk/blk.html")).toBe(
"https://p.test/api/projects/x/preview/compositions/blk/assets/env.hdr",
);
expect(run("https://cdn.test/blocks/blk/blk.html")).toBe(
"https://cdn.test/blocks/blk/assets/env.hdr",
);
expect(run()).toBe("https://p.test/api/projects/x/preview/assets/env.hdr");
});
it("routes the documented window.__hyperframes.getVariables() to the scoped variant too", () => {
// Regression: the docs (variables-and-media.md) show `window.__hyperframes.
// getVariables()`, but inside a sub-comp the scoped `window` proxy used to
@@ -283,6 +283,7 @@ export function wrapScopedCompositionScript(
scopeSelectorOverride?: string,
timelineCompositionId = compositionId,
authoredRootId?: string | null,
compositionSrc?: string | null,
): string {
const compositionIdLiteral = jsonScriptLiteral(compositionId);
const timelineCompositionIdLiteral = jsonScriptLiteral(timelineCompositionId);
@@ -304,6 +305,7 @@ export function wrapScopedCompositionScript(
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfAuthoredRootId = ${authoredRootIdLiteral};
var __hfCompositionSrc = ${jsonScriptLiteral(compositionSrc?.trim() || null)};
var __hfAuthoredRootAttr = ${jsonScriptLiteral(AUTHORED_ROOT_ID_ATTR)};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
@@ -612,6 +614,10 @@ export function wrapScopedCompositionScript(
var __hfScopedHyperframes = !__hfBaseHyperframes
? __hfBaseHyperframes
: Object.assign({}, __hfBaseHyperframes, {
assetUrl: function(path) {
var page = window.document.baseURI;
return new URL(path, __hfCompositionSrc ? new URL(__hfCompositionSrc, page) : page).href;
},
getVariables: function() {
var byComp = window.__hfVariablesByComp;
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
@@ -179,6 +179,51 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('document.getElementById("scene")');
});
it("binds a mounted composition's scripts to its own file for __hyperframes.assetUrl", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="blk-host" data-composition-id="blk" data-composition-src="compositions/blk/blk.html"
data-start="0" data-duration="5"></div>
</div>
</body></html>`,
"compositions/blk/blk.html": `<div data-composition-id="blk" data-width="1920" data-height="1080">
<script>window.__envUrl = __hyperframes.assetUrl("assets/env.hdr");</script>
</div>`,
});
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toContain('var __hfCompositionSrc = "compositions/blk/blk.html";');
});
it("binds a <template> composition authored in a mounted file to that file", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="blk-host" data-composition-id="blk" data-composition-src="compositions/blk/blk.html"
data-start="0" data-duration="5"></div>
</div>
</body></html>`,
"compositions/blk/blk.html": `<div data-composition-id="blk" data-width="1920" data-height="1080">
<template id="chip-template">
<div data-composition-id="chip" data-width="200" data-height="200">
<script>window.__chipUrl = __hyperframes.assetUrl("assets/chip.png");</script>
</div>
</template>
<div data-composition-id="chip"></div>
</div>`,
});
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toMatch(
/var __hfCompId = "chip";(?:(?!__hfCompId)[\s\S])*var __hfCompositionSrc = "compositions\/blk\/blk\.html";/,
);
});
it("inlines an in-project sub-composition script but not one reached through a symlink escaping the project root", async () => {
// Security: a shared/cloned project may carry a symlink pointing outside the
// root (e.g. ext -> /etc). The bundler reads+inlines local assets, so it must
@@ -358,6 +358,7 @@ export function inlineSubCompositions(
runtimeScope || undefined,
runtimeCompId || scopeCompId || scriptCompositionId,
authoredRootId,
src,
)
: wrapInlineScriptWithErrorBoundary(scriptEl.textContent || "", scriptErrorLabel);
scripts.push(wrappedScript);
+8
View File
@@ -0,0 +1,8 @@
/**
* Resolves a path the calling composition wrote relative to its own file. At the top level, and
* for a composition opened on its own, that file is the page. Inside a mounted sub-composition
* the scoped wrapper (compiler/compositionScoping.ts) replaces this with one bound to that file.
*/
export function assetUrl(path: string): string {
return new URL(path, document.baseURI).href;
}
@@ -36,6 +36,24 @@ describe("loadExternalCompositions", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("binds a fetched composition's scripts to its own URL for __hyperframes.assetUrl", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/blocks/blk/blk.html");
host.setAttribute("data-composition-id", "blk");
document.body.appendChild(host);
const compositionHtml =
`<div data-composition-id="blk"><script>window.__url = __hyperframes.assetUrl("assets/env.hdr");</scr` +
`ipt></div>`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({ ...defaultParams, injectedScripts });
expect(injectedScripts.map((script) => script.textContent).join("")).toContain(
'var __hfCompositionSrc = "https://example.com/blocks/blk/blk.html";',
);
});
it("fetches and mounts external composition HTML", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
@@ -566,6 +566,7 @@ async function mountCompositionContent(params: {
runtimeScopeSelector,
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
authoredRootId,
params.compositionUrl?.href,
);
} else {
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
+3
View File
@@ -2,12 +2,14 @@ import { initSandboxRuntimeModular } from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { fitTextFontSize } from "../text/fitTextFontSize";
import { pretext } from "../text/pretext";
import { assetUrl } from "./assetUrl";
import { getVariables } from "./getVariables";
import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./runtimeData";
type HyperframeWindow = Window & {
__hyperframeRuntimeBootstrapped?: boolean;
__hyperframes?: {
assetUrl: typeof assetUrl;
fitTextFontSize: typeof fitTextFontSize;
getVariables: typeof getVariables;
pretext: typeof pretext;
@@ -30,6 +32,7 @@ installAuthoredOpacityCapture();
// before DOMContentLoaded (font sizing runs during script evaluation, and
// getVariables is read by composition setup before the timeline is built).
(window as HyperframeWindow).__hyperframes = {
assetUrl,
fitTextFontSize,
getVariables,
pretext,
+2
View File
@@ -31,6 +31,8 @@ declare global {
__timelines: Record<string, RuntimeTimelineLike>;
__player?: PlayerAPI;
__hyperframes?: {
/** A path the calling composition wrote relative to its own file, as a URL the page can load. */
assetUrl?: (path: string) => string;
registerRuntimeDataHandler?: (
channel: string,
handler: (payload: unknown) => void,
+1 -1
View File
@@ -46,7 +46,7 @@
"files": 3
},
"hyperframes-registry": {
"hash": "097538fb238a4ee8",
"hash": "4a1a7daf23a9e569",
"files": 12
},
"hyperframes-studio": {
@@ -89,6 +89,12 @@ Apply the correct template based on type. See [templates.md](templates.md) for c
#### All types
- **Load files the block ships through `__hyperframes.assetUrl("assets/...")`** whenever a script
builds the path (textures, fonts, JSON, models). Attributes like `src` and `href` are rebased
when the block is mounted; strings in a script are not, so a bare `"assets/..."` 404s once the
block lives under `compositions/<name>/`. A separate `.js` file gets the URLs from the block's
inline script. A block whose assets install to the project's own `assets/` (target
`compositions/<name>.html`) keeps plain project-root paths; `assetUrl` would look beside the file.
- **Placeholder content is monochrome** — read
[placeholder-material.md](placeholder-material.md) before choosing a single colour. Stand-in
screens, images, cards, avatars, logos and chart series use four alpha steps of the