mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): allow a local GSAP dependency mirror (#6247)
This commit is contained in:
@@ -874,6 +874,11 @@ The server picks one of three modes by itself: **embedded** (the default for
|
||||
**monorepo** (the studio dev server, when you run from the source repo).
|
||||
`--background` works with the embedded server only.
|
||||
|
||||
To load the GSAP dependencies Studio adds from a local or private mirror, set
|
||||
`HYPERFRAMES_GSAP_BASE_URL` before starting the server. See
|
||||
[Serve GSAP from your own mirror](/packages/studio-server#serve-gsap-from-your-own-mirror)
|
||||
for the directory layout, restart requirements, and authored-resource scope.
|
||||
|
||||
<Note>
|
||||
Preview plays in real time, so paint-heavy compositions can stutter on your
|
||||
machine. Render seeks and captures one frame at a time, so the same work usually
|
||||
|
||||
@@ -66,6 +66,48 @@ the bundler inlines can be served with ids that a later save does not mint.
|
||||
bundle: (dir, options) => bundleToSingleHtml(dir, { runtime: "inline", ...options }),
|
||||
```
|
||||
|
||||
## Serve GSAP from your own mirror
|
||||
|
||||
Studio can load the GSAP dependencies it adds from a local or private mirror
|
||||
when jsDelivr is unavailable. Set `HYPERFRAMES_GSAP_BASE_URL` before starting
|
||||
the preview server or creating the Studio API:
|
||||
|
||||
```bash
|
||||
HYPERFRAMES_GSAP_BASE_URL=http://127.0.0.1:8080/vendor/npm \
|
||||
npx hyperframes preview ./my-video
|
||||
```
|
||||
|
||||
Serve the GSAP distribution files under that URL with this layout, using the
|
||||
versions your compositions need:
|
||||
|
||||
```text
|
||||
<mirror-root>/gsap@3.15.0/dist/gsap.min.js
|
||||
<mirror-root>/gsap@3.15.0/dist/CustomEase.min.js
|
||||
<mirror-root>/gsap@3.15.0/dist/MotionPathPlugin.min.js
|
||||
```
|
||||
|
||||
Studio uses the mirror for the GSAP, CustomEase, and MotionPathPlugin scripts
|
||||
it adds to previews, including dependencies loaded when you edit motion.
|
||||
Keep `gsap@<version>` in authored GSAP URLs so the server can infer which
|
||||
plugin version to add. When it cannot infer a version, it uses the framework's
|
||||
default, currently `3.15.0`.
|
||||
|
||||
The base must be an absolute HTTP(S) URL without credentials, a query string,
|
||||
or a fragment. Surrounding whitespace and trailing slashes are removed.
|
||||
An unset or empty value uses `https://cdn.jsdelivr.net/npm`; an invalid
|
||||
nonempty value stops API startup with a configuration error.
|
||||
|
||||
The server reads this setting once at startup. Restart it after changing the
|
||||
value. For a managed CLI preview, run `npx hyperframes preview ./my-video --stop`
|
||||
before starting it again with the new setting. Cached preview HTML is keyed
|
||||
by the normalized mirror URL, so a different mirror takes effect after restart
|
||||
without deleting the preview cache.
|
||||
|
||||
This setting changes only dependencies Studio adds. Your composition's own
|
||||
script, font, image, and media URLs stay as authored; serve those locally too
|
||||
for an offline project. If the selected mirror is unavailable or lacks a
|
||||
requested file, Studio does not retry that dependency through jsDelivr.
|
||||
|
||||
## Other entry points
|
||||
|
||||
| Import | Purpose |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gsapCdnDist, motionPathPluginUrl } from "./gsapCdn";
|
||||
import { gsapCdnDist, motionPathPluginUrl, normalizeGsapBaseUrl } from "./gsapCdn";
|
||||
|
||||
describe("gsapCdnDist", () => {
|
||||
it("serves the composition's gsap version", () => {
|
||||
@@ -16,3 +16,36 @@ describe("gsapCdnDist", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GSAP mirrors", () => {
|
||||
it("uses a normalized npm mirror root while retaining numeric versions", () => {
|
||||
expect(gsapCdnDist("3.14.2", " https://mirror.example/vendor/npm/// ")).toBe(
|
||||
"https://mirror.example/vendor/npm/gsap@3.14.2/dist/",
|
||||
);
|
||||
expect(motionPathPluginUrl("3", "http://localhost:8080/npm")).toBe(
|
||||
"http://localhost:8080/npm/gsap@3/dist/MotionPathPlugin.min.js",
|
||||
);
|
||||
expect(gsapCdnDist("latest", "https://mirror.example/npm")).toBe(
|
||||
"https://mirror.example/npm/gsap@3.15.0/dist/",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, "", " "])("keeps the default root for %j", (base) => {
|
||||
expect(normalizeGsapBaseUrl(base)).toBe("https://cdn.jsdelivr.net/npm");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/vendor/npm",
|
||||
"file:///vendor/npm",
|
||||
"https:mirror.example",
|
||||
"not a URL",
|
||||
"https://user:password@mirror.example/npm",
|
||||
"https://mirror.example/npm?token=x",
|
||||
"https://mirror.example/npm#section",
|
||||
"https://mirror.example/npm?",
|
||||
"https://mirror.example/npm#",
|
||||
])("rejects an invalid mirror instead of using the public CDN: %s", (base) => {
|
||||
expect(() => normalizeGsapBaseUrl(base)).toThrow();
|
||||
expect(() => motionPathPluginUrl("3.15.0", base)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
const GSAP_CDN_VERSION = "3.15.0";
|
||||
const GSAP_CDN_BASE_URL = "https://cdn.jsdelivr.net/npm";
|
||||
export const GSAP_BASE_URL_META = "hyperframes-gsap-base-url";
|
||||
|
||||
export function gsapCdnDist(gsapVersion?: string): string {
|
||||
/** An npm-style mirror root; callers choose configuration without coupling this helper to a host. */
|
||||
export function normalizeGsapBaseUrl(baseUrl?: string): string {
|
||||
const value = baseUrl?.trim();
|
||||
if (!value) return GSAP_CDN_BASE_URL;
|
||||
if (!/^https?:\/\//i.test(value) || !URL.canParse(value) || /[?#]/.test(value)) {
|
||||
throw new Error("GSAP base URL must be an absolute HTTP(S) URL without a query or fragment");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.username || url.password) {
|
||||
throw new Error("GSAP base URL must not contain credentials");
|
||||
}
|
||||
return url.href.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function gsapCdnDist(gsapVersion?: string, baseUrl?: string): string {
|
||||
const version = gsapVersion && /^\d+(\.\d+)*$/.test(gsapVersion) ? gsapVersion : GSAP_CDN_VERSION;
|
||||
return `https://cdn.jsdelivr.net/npm/gsap@${version}/dist/`;
|
||||
return `${normalizeGsapBaseUrl(baseUrl)}/gsap@${version}/dist/`;
|
||||
}
|
||||
|
||||
/** MotionPathPlugin at the composition's own gsap version; a skew registers with a GSAP warning. */
|
||||
export function motionPathPluginUrl(gsapVersion?: string): string {
|
||||
return `${gsapCdnDist(gsapVersion)}MotionPathPlugin.min.js`;
|
||||
export function motionPathPluginUrl(gsapVersion?: string, baseUrl?: string): string {
|
||||
return `${gsapCdnDist(gsapVersion, baseUrl)}MotionPathPlugin.min.js`;
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ export function buildSubCompositionHtml(
|
||||
runtimeUrl: string,
|
||||
baseHref?: string,
|
||||
rawOverride?: string,
|
||||
gsapBaseUrl?: string,
|
||||
): string | null {
|
||||
const compFile = join(projectDir, compPath);
|
||||
if (!existsSync(compFile)) return null;
|
||||
@@ -353,7 +354,7 @@ export function buildSubCompositionHtml(
|
||||
|
||||
// Fallback: if no index.html head was found, add minimal deps
|
||||
if (!headContent.includes("gsap")) {
|
||||
headContent += `\n<script src="${gsapCdnDist()}gsap.min.js"></script>`;
|
||||
headContent += `\n<script src="${gsapCdnDist(undefined, gsapBaseUrl).replace(/&/g, "&")}gsap.min.js"></script>`;
|
||||
}
|
||||
|
||||
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
|
||||
|
||||
@@ -42,6 +42,7 @@ const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -2431,3 +2432,203 @@ describe("the desktop app's private folder", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GSAP mirror configuration", () => {
|
||||
const mirror = "http://localhost:8080/vendor/npm";
|
||||
const metaName = "hyperframes-gsap-base-url";
|
||||
const previewUrl = "http://localhost/projects/demo/preview";
|
||||
|
||||
function appForMirror(
|
||||
projectDir: string,
|
||||
base: string,
|
||||
overrides: Partial<StudioApiAdapter> = {},
|
||||
) {
|
||||
vi.stubEnv("HYPERFRAMES_GSAP_BASE_URL", base);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir, overrides));
|
||||
return app;
|
||||
}
|
||||
|
||||
it.each(["/relative/npm", "https://mirror.example/npm?token=1", "file:///npm"])(
|
||||
"rejects invalid configuration before registering routes: %s",
|
||||
(base) => {
|
||||
expect(() => appForMirror(createProjectDir(), base)).toThrow("HYPERFRAMES_GSAP_BASE_URL");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["bundle", "disk fallback", "capture", "subcomposition"])(
|
||||
"uses server metadata and the configured plugin in the %s document",
|
||||
async (mode) => {
|
||||
const projectDir = createProjectDir();
|
||||
const authored = "https://authored.example/gsap@3.14.2/dist/gsap.min.js";
|
||||
const source = `<html><head><meta name="${metaName}" content="https://authored.example/npm"><script src="${authored}"></script></head><body><script>gsap.to("#card", { motionPath: {} });</script></body></html>`;
|
||||
writeFileSync(join(projectDir, "index.html"), source);
|
||||
writeFileSync(join(projectDir, "scene.html"), source);
|
||||
const app = appForMirror(projectDir, mirror, {
|
||||
bundle: async () => {
|
||||
if (mode === "disk fallback") throw new Error("test bundle failure");
|
||||
return source;
|
||||
},
|
||||
});
|
||||
const suffix =
|
||||
mode === "subcomposition" ? "/comp/scene.html" : mode === "capture" ? "?hf-capture=1" : "";
|
||||
const response = await app.request(previewUrl + suffix);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const doc = parseHTML(html).document;
|
||||
expect(doc.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content")).toBe(mirror);
|
||||
expect(doc.querySelector(`script[src="${authored}"]`)).not.toBeNull();
|
||||
expect(doc.querySelector('script[src*="MotionPathPlugin"]')?.getAttribute("src")).toBe(
|
||||
`${mirror}/gsap@3.14.2/dist/MotionPathPlugin.min.js`,
|
||||
);
|
||||
expect(html).not.toContain("cdn.jsdelivr.net");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the mirror for generated GSAP, CustomEase, and standalone GSAP", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
mkdirSync(join(projectDir, ".hyperframes"));
|
||||
writeFileSync(
|
||||
join(projectDir, ".hyperframes", "studio-motion.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
motions: [
|
||||
{
|
||||
kind: "gsap-motion",
|
||||
target: { sourceFile: "index.html", id: "card" },
|
||||
start: 0,
|
||||
duration: 1,
|
||||
customEase: { id: "custom", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
|
||||
from: { y: 32 },
|
||||
to: { y: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(projectDir, "scene.html"), "<template><section>Scene</section></template>");
|
||||
const app = appForMirror(projectDir, mirror);
|
||||
const root = parseHTML(await (await app.request(previewUrl)).text()).document;
|
||||
const urls = [...root.querySelectorAll("script[src]")].map((script) =>
|
||||
script.getAttribute("src"),
|
||||
);
|
||||
expect(urls).toContain(`${mirror}/gsap@3.15.0/dist/gsap.min.js`);
|
||||
expect(urls).toContain(`${mirror}/gsap@3.15.0/dist/CustomEase.min.js`);
|
||||
const scene = parseHTML(
|
||||
await (await app.request(previewUrl + "/comp/scene.html")).text(),
|
||||
).document;
|
||||
expect(scene.querySelector('script[src$="/gsap.min.js"]')?.getAttribute("src")).toBe(
|
||||
`${mirror}/gsap@3.15.0/dist/gsap.min.js`,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves authored plugins and the default CDN when the override is unset", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const plugin = "https://authored.example/MotionPathPlugin.min.js";
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<html><head><script src="${plugin}"></script></head><body><script>gsap.to("#card", { motionPath: {} });</script></body></html>`,
|
||||
);
|
||||
const configured = await (await appForMirror(projectDir, mirror).request(previewUrl)).text();
|
||||
expect(
|
||||
parseHTML(configured).document.querySelectorAll('script[src*="MotionPathPlugin"]'),
|
||||
).toHaveLength(1);
|
||||
expect(configured).toContain(`src="${plugin}"`);
|
||||
const defaults = await (await appForMirror(projectDir, "").request(previewUrl)).text();
|
||||
expect(
|
||||
parseHTML(defaults)
|
||||
.document.querySelector(`meta[name="${metaName}"]`)
|
||||
?.getAttribute("content"),
|
||||
).toBe("https://cdn.jsdelivr.net/npm");
|
||||
expect(defaults).toContain('var cdnBase="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/"');
|
||||
});
|
||||
|
||||
it("normalizes and escapes metadata without letting authored metadata override it", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<html><head><meta name="${metaName}" content="https://authored.example/npm"></head><body></body></html>`,
|
||||
);
|
||||
const app = appForMirror(projectDir, " https://mirror.example/a©/npm/// ");
|
||||
const html = await (await app.request(previewUrl)).text();
|
||||
expect(html).toContain('content="https://mirror.example/a&copy;/npm"');
|
||||
expect(
|
||||
parseHTML(html).document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content"),
|
||||
).toBe("https://mirror.example/a©/npm");
|
||||
});
|
||||
|
||||
it("invalidates root, subcomposition, and persistent preview caches when the mirror changes", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "scene.html"), "<template><section>Scene</section></template>");
|
||||
const bundle = vi.fn(
|
||||
async () =>
|
||||
'<html><head></head><body><div data-hf-scene="a">Cached preview</div></body></html>',
|
||||
);
|
||||
const overrides = {
|
||||
bundle,
|
||||
previewDocuments: createPreviewDocumentStore(projectDir, "same-build"),
|
||||
};
|
||||
const first = appForMirror(projectDir, mirror, overrides);
|
||||
const root = await first.request(previewUrl);
|
||||
const scene = await first.request(previewUrl + "/comp/scene.html");
|
||||
const secondBase = "https://other.example/npm";
|
||||
const second = appForMirror(projectDir, secondBase, overrides);
|
||||
const rootNext = await second.request(previewUrl, {
|
||||
headers: { "If-None-Match": root.headers.get("ETag")! },
|
||||
});
|
||||
const sceneNext = await second.request(previewUrl + "/comp/scene.html", {
|
||||
headers: { "If-None-Match": scene.headers.get("ETag")! },
|
||||
});
|
||||
expect(rootNext.status).toBe(200);
|
||||
expect(sceneNext.status).toBe(200);
|
||||
expect(rootNext.headers.get("ETag")).not.toBe(root.headers.get("ETag"));
|
||||
expect(sceneNext.headers.get("ETag")).not.toBe(scene.headers.get("ETag"));
|
||||
expect(bundle).toHaveBeenCalledTimes(2);
|
||||
const firstHtml = parseHTML(await root.text()).document;
|
||||
const nextHtml = parseHTML(await rootNext.text()).document;
|
||||
expect(nextHtml.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content")).toBe(
|
||||
secondBase,
|
||||
);
|
||||
expect(nextHtml.querySelector('meta[name="hf-scene-parts"]')?.getAttribute("content")).not.toBe(
|
||||
firstHtml.querySelector('meta[name="hf-scene-parts"]')?.getAttribute("content"),
|
||||
);
|
||||
const normalized = appForMirror(projectDir, ` ${secondBase}/// `, overrides);
|
||||
expect(
|
||||
(
|
||||
await normalized.request(previewUrl, {
|
||||
headers: { "If-None-Match": rootNext.headers.get("ETag")! },
|
||||
})
|
||||
).status,
|
||||
).toBe(304);
|
||||
vi.stubEnv("HYPERFRAMES_GSAP_BASE_URL", mirror);
|
||||
expect((await second.request(previewUrl)).headers.get("ETag")).toBe(
|
||||
rootNext.headers.get("ETag"),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries an authored GSAP failure only on the selected mirror and reports mirror failure", async () => {
|
||||
const app = appForMirror(createProjectDir(), mirror);
|
||||
const html = await (await app.request(previewUrl)).text();
|
||||
const fallback = /<script data-hf-gsap-fallback>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? "";
|
||||
let onError: (event: { target: unknown }) => void = () => undefined;
|
||||
const scripts: { tagName: string; src: string; onerror?: (event: Event) => void }[] = [];
|
||||
const doc = {
|
||||
addEventListener: (_type: string, listener: typeof onError) => {
|
||||
onError = listener;
|
||||
},
|
||||
createElement: () => ({ tagName: "SCRIPT", src: "" }),
|
||||
head: { appendChild: (script: (typeof scripts)[number]) => scripts.push(script) },
|
||||
};
|
||||
const reported = vi.fn();
|
||||
new Function("document", "reportError", fallback)(doc, reported);
|
||||
onError({
|
||||
target: { tagName: "SCRIPT", src: "https://authored.example/gsap@3.14.2/dist/gsap.min.js" },
|
||||
});
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(scripts[0]?.src).toBe(`${mirror}/gsap@3.15.0/dist/gsap.min.js`);
|
||||
onError({ target: scripts[0] });
|
||||
scripts[0]?.onerror?.(new Event("error"));
|
||||
await vi.waitFor(() => expect(reported).toHaveBeenCalledTimes(1));
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(html).not.toContain("cdn.jsdelivr.net");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
STUDIO_PREVIEW_ERRORS,
|
||||
} from "@hyperframes/core/studio-preview-mark";
|
||||
import { PREVIEW_PROXY_BOX_PARAM, parsePreviewProxyBox } from "@hyperframes/core";
|
||||
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
||||
import {
|
||||
GSAP_BASE_URL_META,
|
||||
gsapCdnDist,
|
||||
motionPathPluginUrl,
|
||||
normalizeGsapBaseUrl,
|
||||
} from "@hyperframes/core/gsap-cdn";
|
||||
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
|
||||
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
||||
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
|
||||
@@ -76,9 +81,13 @@ import { requestSubPath } from "../helpers/requestSubPath.js";
|
||||
import { lazyPreviewImages } from "../helpers/lazyPreviewImages.js";
|
||||
|
||||
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
||||
const GSAP_CDN_SCRIPT = `<script src="${gsapCdnDist()}gsap.min.js"></script>`;
|
||||
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="${gsapCdnDist()}CustomEase.min.js"></script>`;
|
||||
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="${motionPathPluginUrl()}"></script>`;
|
||||
function escapeGsapUrl(url: string): string {
|
||||
return url.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function gsapScriptTag(file: string, baseUrl: string): string {
|
||||
return `<script src="${escapeGsapUrl(gsapCdnDist(undefined, baseUrl) + file)}"></script>`;
|
||||
}
|
||||
|
||||
function injectProjectSignature(html: string, signature: string): string {
|
||||
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
|
||||
@@ -166,7 +175,7 @@ function htmlHasMotionPathPlugin(html: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function injectMotionPathPluginIfNeeded(html: string): string {
|
||||
function injectMotionPathPluginIfNeeded(html: string, baseUrl: string): string {
|
||||
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
|
||||
// The plugin registers onto an already-loaded gsap, so it must come AFTER the
|
||||
// core gsap script — which often lives at body-end, not <head>. Insert it
|
||||
@@ -176,10 +185,10 @@ function injectMotionPathPluginIfNeeded(html: string): string {
|
||||
if (gsap) {
|
||||
const version = gsap.src.match(/gsap@([\d.]+)/)?.[1];
|
||||
const ordering = `${gsap.type ? ` type="${gsap.type}"` : ""}${gsap.defer ? " defer" : ""}`;
|
||||
const pluginTag = `<script${ordering} src="${motionPathPluginUrl(version)}"></script>`;
|
||||
const pluginTag = `<script${ordering} src="${escapeGsapUrl(motionPathPluginUrl(version, baseUrl))}"></script>`;
|
||||
return html.slice(0, gsap.end) + "\n" + pluginTag + html.slice(gsap.end);
|
||||
}
|
||||
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
|
||||
return injectScriptTagIntoHead(html, gsapScriptTag("MotionPathPlugin.min.js", baseUrl));
|
||||
}
|
||||
|
||||
function isGsapCoreUrl(src: string): boolean {
|
||||
@@ -221,13 +230,18 @@ function scriptCloseEnd(html: string, start: number): number {
|
||||
return closeAt === undefined ? 0 : html.indexOf(">", closeAt) + 1;
|
||||
}
|
||||
|
||||
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
|
||||
function injectStudioMotionDependencies(
|
||||
html: string,
|
||||
manifestContent: string,
|
||||
baseUrl: string,
|
||||
): string {
|
||||
const manifest = parseStudioMotionManifestContent(manifestContent);
|
||||
if (!manifest.hasMotion) return html;
|
||||
let next = html;
|
||||
if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT);
|
||||
if (!htmlHasGsap(next))
|
||||
next = injectScriptTagIntoHead(next, gsapScriptTag("gsap.min.js", baseUrl));
|
||||
if (manifest.hasCustomEase && !htmlHasCustomEase(next)) {
|
||||
next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT);
|
||||
next = injectScriptTagIntoHead(next, gsapScriptTag("CustomEase.min.js", baseUrl));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -236,21 +250,23 @@ function injectStudioMotionScript(
|
||||
html: string,
|
||||
projectDir: string,
|
||||
activeCompositionPath: string,
|
||||
baseUrl: string,
|
||||
): string {
|
||||
const manifestContent = readStudioMotionManifestContent(projectDir);
|
||||
const script = createStudioMotionRenderBodyScript(manifestContent, {
|
||||
activeCompositionPath,
|
||||
});
|
||||
if (!script) return html;
|
||||
const withDependencies = injectStudioMotionDependencies(html, manifestContent);
|
||||
const withDependencies = injectStudioMotionDependencies(html, manifestContent, baseUrl);
|
||||
const tag = `<script type="${AFTER_FONTS_SCRIPT_TYPE}">${script.replace(/<\/script/gi, "<\\/script")}</script>`;
|
||||
return insertBeforeCloseTag(withDependencies, "body", tag) ?? `${withDependencies}${tag}`;
|
||||
}
|
||||
|
||||
const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
|
||||
const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
|
||||
function gsapCdnFallbackScript(baseUrl: string): string {
|
||||
return `<script ${GSAP_FALLBACK_ATTR}>
|
||||
(function(){
|
||||
var cdnBase="${gsapCdnDist()}";
|
||||
var cdnBase=${JSON.stringify(gsapCdnDist(undefined, baseUrl)).replace(/</g, "\\u003c")};
|
||||
var loaded={};
|
||||
function loadFallback(file){
|
||||
if(loaded[file])return loaded[file];
|
||||
@@ -270,11 +286,13 @@ const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
|
||||
},true);
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function injectGsapCdnFallback(html: string): string {
|
||||
function injectGsapCdnFallback(html: string, baseUrl: string): string {
|
||||
if (html.includes(GSAP_FALLBACK_ATTR)) return html;
|
||||
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
|
||||
return GSAP_CDN_FALLBACK_SCRIPT + html;
|
||||
if (html.includes("<head>"))
|
||||
return html.replace("<head>", "<head>" + gsapCdnFallbackScript(baseUrl));
|
||||
return gsapCdnFallbackScript(baseUrl) + html;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,9 +351,9 @@ function injectStudioPreviewAugmentations(
|
||||
adapter: StudioApiAdapter,
|
||||
projectDir: string,
|
||||
activeCompositionPath: string,
|
||||
capture: boolean,
|
||||
options: { capture: boolean; gsapBaseUrl: string },
|
||||
): string {
|
||||
const marked = capture
|
||||
const marked = options.capture
|
||||
? injectTagsAtHeadStart(
|
||||
html,
|
||||
`<meta name="${STUDIO_PREVIEW_MARK_META}" content="${STUDIO_PREVIEW_CAPTURE_MODE}">`,
|
||||
@@ -347,11 +365,20 @@ function injectStudioPreviewAugmentations(
|
||||
return injectStudioMotionScript(
|
||||
injectMotionPathPluginIfNeeded(
|
||||
injectGsapCdnFallback(
|
||||
injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir)),
|
||||
injectProjectSignature(
|
||||
injectTagsAtHeadStart(
|
||||
marked,
|
||||
`<meta name="${GSAP_BASE_URL_META}" content="${escapeGsapUrl(options.gsapBaseUrl)}">`,
|
||||
),
|
||||
resolveProjectSignature(adapter, projectDir),
|
||||
),
|
||||
options.gsapBaseUrl,
|
||||
),
|
||||
options.gsapBaseUrl,
|
||||
),
|
||||
projectDir,
|
||||
activeCompositionPath,
|
||||
options.gsapBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,6 +443,16 @@ const withPreviewBase = (html: string, projectId: string) =>
|
||||
: injectTagsAtHeadStart(html, `<base href="${previewBaseHref(projectId)}">`);
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
|
||||
let gsapBaseUrl: string;
|
||||
try {
|
||||
gsapBaseUrl = normalizeGsapBaseUrl(process.env.HYPERFRAMES_GSAP_BASE_URL);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"Invalid HYPERFRAMES_GSAP_BASE_URL: use an absolute HTTP(S) npm mirror root without credentials, query, or fragment",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const gsapEtagSalt = `:gsap:${createHash("sha1").update(gsapBaseUrl).digest("hex").slice(0, 12)}`;
|
||||
const previewCacheHeaders = (etag: string) => ({
|
||||
"Cache-Control": "private, no-cache",
|
||||
ETag: etag,
|
||||
@@ -472,7 +509,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
adapter,
|
||||
project.dir,
|
||||
mainCompositionPath,
|
||||
capture,
|
||||
{ capture, gsapBaseUrl },
|
||||
);
|
||||
if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
|
||||
bundled = await injectMediaCodecMap(
|
||||
@@ -502,7 +539,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
adapter,
|
||||
project.dir,
|
||||
fallback.compositionPath,
|
||||
capture,
|
||||
{ capture, gsapBaseUrl },
|
||||
);
|
||||
if (previewVariables) {
|
||||
fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
|
||||
@@ -533,7 +570,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
const previewVariables = vars.values;
|
||||
const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== undefined;
|
||||
|
||||
const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
||||
const etag = `"preview:${signature}${gsapEtagSalt}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
||||
const ifNoneMatch = c.req.header("If-None-Match");
|
||||
if (ifNoneMatch === etag) {
|
||||
return new Response(null, {
|
||||
@@ -595,7 +632,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
// not revalidate to a 304 that skips the pin.
|
||||
const compPathHash = createHash("sha1").update(compPath).digest("hex");
|
||||
const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== undefined;
|
||||
const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
||||
const etag = `"comp:v2:${compPathHash}:${signature}${gsapEtagSalt}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
||||
const ifNoneMatch = c.req.header("If-None-Match");
|
||||
if (ifNoneMatch === etag) {
|
||||
return new Response(null, {
|
||||
@@ -614,11 +651,15 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
adapter.runtimeUrl,
|
||||
baseHref,
|
||||
stamped,
|
||||
gsapBaseUrl,
|
||||
);
|
||||
if (!html) return c.text("not found", 404);
|
||||
recordPreviewReferences(project.dir, html);
|
||||
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
|
||||
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
|
||||
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, {
|
||||
capture,
|
||||
gsapBaseUrl,
|
||||
});
|
||||
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
|
||||
html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
|
||||
return c.html(html, 200, previewCacheHeaders(etag));
|
||||
|
||||
@@ -318,6 +318,28 @@ window.__timelines["root"] = tl;`;
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a missing soft-reload plugin from the iframe mirror and reports its failure", () => {
|
||||
const { iframe, contentWindow } = buildMockIframe({
|
||||
gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
|
||||
});
|
||||
const head = iframe.contentDocument!.head;
|
||||
const meta = document.createElement("meta");
|
||||
meta.name = "hyperframes-gsap-base-url";
|
||||
meta.content = "http://localhost:8080/vendor/npm";
|
||||
head.appendChild(meta);
|
||||
const onAsyncFailure = vi.fn();
|
||||
expect(applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure })).toBe("applied");
|
||||
const plugin = head.querySelector("script");
|
||||
expect(plugin?.src).toBe(
|
||||
"http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
|
||||
);
|
||||
plugin?.onerror?.(new Event("error"));
|
||||
expect(onAsyncFailure).toHaveBeenCalledTimes(1);
|
||||
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
expect(head.querySelectorAll("script")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns "cannot-soft-reload" when multiple GSAP scripts exist (ambiguous)', () => {
|
||||
const script1 = document.createElement("script");
|
||||
script1.textContent = "const tl = gsap.timeline({ paused: true });";
|
||||
@@ -490,6 +512,26 @@ describe("ensureMotionPathPluginLoaded", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preloads from the iframe mirror once, retaining the runtime version", () => {
|
||||
const { iframe, appendedScripts, contentWindow, registerPlugin } = buildBootstrapIframe({
|
||||
gsap: { version: "3.14.2", registerPlugin: vi.fn() },
|
||||
});
|
||||
const meta = document.createElement("meta");
|
||||
meta.name = "hyperframes-gsap-base-url";
|
||||
meta.content = "http://localhost:8080/vendor/npm";
|
||||
iframe.contentDocument!.head.appendChild(meta);
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
expect(appendedScripts[0]!.src).toBe(
|
||||
"http://localhost:8080/vendor/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
|
||||
);
|
||||
appendedScripts[0]!.onerror?.(new Event("error"));
|
||||
expect(contentWindow.__hfMotionPathPluginLoading).toBe(false);
|
||||
expect(registerPlugin).not.toHaveBeenCalled();
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appends the plugin script once and registers it on load", () => {
|
||||
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
|
||||
ensureMotionPathPluginLoaded(iframe);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
||||
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
||||
import { GSAP_BASE_URL_META, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
||||
import { findAuthoredElement } from "./authoredSource";
|
||||
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
||||
import {
|
||||
@@ -50,6 +50,13 @@ type IframeWindow = Window & {
|
||||
// At most one preview waits for its runtime; a newer load replaces it, so a frame that never boots is not kept alive.
|
||||
let pendingReadyRetry: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
function previewMotionPathUrl(doc: Document, version?: string): string {
|
||||
const baseUrl = doc.head.querySelector<HTMLMetaElement>(
|
||||
`meta[name="${GSAP_BASE_URL_META}"]`,
|
||||
)?.content;
|
||||
return motionPathPluginUrl(version, baseUrl);
|
||||
}
|
||||
|
||||
export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null): void {
|
||||
if (pendingReadyRetry) window.removeEventListener("message", pendingReadyRetry);
|
||||
pendingReadyRetry = null;
|
||||
@@ -86,7 +93,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
|
||||
try {
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
||||
pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
|
||||
const finalize = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
try {
|
||||
@@ -480,7 +487,7 @@ export function applySoftReload(
|
||||
}
|
||||
win.__hfMotionPathPluginLoading = true;
|
||||
const pluginScript = doc.createElement("script");
|
||||
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
|
||||
pluginScript.src = previewMotionPathUrl(doc, win.gsap?.version);
|
||||
pluginScript.onload = () => {
|
||||
win.__hfMotionPathPluginLoading = false;
|
||||
executeScript();
|
||||
|
||||
Reference in New Issue
Block a user