mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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}})();`;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user