fix(studio): one owner for gsap CDN URLs, so MotionPathPlugin matches the composition's gsap (#4976)

* fix(studio): load MotionPathPlugin at the composition's own gsap version

* fix(core): list the gsap-cdn subpath where core's exports are generated from

* fix(core): every default gsap URL comes from the one gsap-cdn owner

* fix(core): the gsap a new composition or first tween gets comes from the gsap-cdn owner
This commit is contained in:
Miguel Ángel
2026-10-04 00:44:50 +00:00
committed by GitHub
parent f7987bb1f2
commit cd1a34ceca
13 changed files with 92 additions and 25 deletions
+2 -1
View File
@@ -17,6 +17,7 @@ import {
parseCubeLut,
serializeHfColorGrading,
} from "@hyperframes/core";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
import { defineCommand } from "citty";
import sharp from "sharp";
import type { Example } from "./_examples.js";
@@ -344,7 +345,7 @@ export function buildGradeCompareHtml(options: GradeCompareHtmlOptions): string
<meta charset="UTF-8" />
<meta name="viewport" content="width=${metrics.width}, height=${metrics.height}" />
<title>HyperFrames Grade Compare</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="${gsapCdnDist()}gsap.min.js"></script>
<style>
html,
body {
+6
View File
@@ -80,6 +80,12 @@
"types": "./dist/studioPreviewMark.d.ts",
"environments": ["browser", "bun", "node"]
},
"./gsap-cdn": {
"source": "./src/gsapCdn.ts",
"runtime": "./dist/gsapCdn.js",
"types": "./dist/gsapCdn.d.ts",
"environments": ["browser", "bun", "node"]
},
"./media-timing": {
"source": "./src/mediaTiming.ts",
"runtime": "./dist/mediaTiming.js",
+10
View File
@@ -94,6 +94,12 @@
"import": "./src/studioPreviewMark.ts",
"types": "./src/studioPreviewMark.ts"
},
"./gsap-cdn": {
"bun": "./src/gsapCdn.ts",
"node": "./dist/gsapCdn.js",
"import": "./src/gsapCdn.ts",
"types": "./src/gsapCdn.ts"
},
"./media-timing": {
"bun": "./src/mediaTiming.ts",
"node": "./dist/mediaTiming.js",
@@ -540,6 +546,10 @@
"import": "./dist/studioPreviewMark.js",
"types": "./dist/studioPreviewMark.d.ts"
},
"./gsap-cdn": {
"import": "./dist/gsapCdn.js",
"types": "./dist/gsapCdn.d.ts"
},
"./media-timing": {
"import": "./dist/mediaTiming.js",
"types": "./dist/mediaTiming.d.ts"
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { gsapCdnDist, motionPathPluginUrl } from "./gsapCdn";
describe("gsapCdnDist", () => {
it("serves the composition's gsap version", () => {
expect(gsapCdnDist("3.14.2")).toBe("https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/");
expect(motionPathPluginUrl("3")).toBe(
"https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js",
);
});
it.each([undefined, "", "latest", "3.15.0/../x"])("falls back to the default for %j", (v) => {
expect(gsapCdnDist(v)).toBe(gsapCdnDist());
expect(gsapCdnDist()).toMatch(
/^https:\/\/cdn\.jsdelivr\.net\/npm\/gsap@\d+\.\d+\.\d+\/dist\/$/,
);
});
});
+11
View File
@@ -0,0 +1,11 @@
const GSAP_CDN_VERSION = "3.15.0";
export function gsapCdnDist(gsapVersion?: string): string {
const version = gsapVersion && /^\d+(\.\d+)*$/.test(gsapVersion) ? gsapVersion : GSAP_CDN_VERSION;
return `https://cdn.jsdelivr.net/npm/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`;
}
+5
View File
@@ -8,6 +8,7 @@ import {
TEXT_STYLES,
ZOOM_CONTAINER_STYLES,
} from "./constants.js";
import { gsapCdnDist } from "../gsapCdn.js";
describe("generateBaseHtml", () => {
it("generates valid HTML structure with DOCTYPE", () => {
@@ -76,6 +77,10 @@ describe("getStageStyles", () => {
});
describe("constants", () => {
it("GSAP_CDN is the default gsap from the one gsap-cdn owner", () => {
expect(GSAP_CDN).toBe(`${gsapCdnDist()}gsap.min.js`);
});
it("GSAP_CDN is a valid URL", () => {
expect(GSAP_CDN).toMatch(/^https:\/\//);
expect(GSAP_CDN).toContain("gsap");
+3 -1
View File
@@ -1,4 +1,6 @@
export const GSAP_CDN = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";
import { gsapCdnDist } from "../gsapCdn";
export const GSAP_CDN = `${gsapCdnDist()}gsap.min.js`;
export const BASE_STYLES = `* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; display: flex; align-items: center; justify-content: center; }`;
@@ -34,6 +34,7 @@ import {
type UnresolvedElement,
} from "@hyperframes/core";
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
import {
assignBundledRuntimeCompositionIds,
assignMediaRenderIds,
@@ -1884,7 +1885,7 @@ export interface CompileForRenderOptions {
variables?: Record<string, unknown>;
}
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
const GSAP_CDN_BASE = gsapCdnDist();
function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string {
return html.replace(
@@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
import { buildSubCompositionHtml, hasBaseElement } from "./subComposition";
function makeTempProject(files: Record<string, string>): string {
@@ -17,6 +18,15 @@ function makeTempProject(files: Record<string, string>): string {
}
describe("buildSubCompositionHtml", () => {
it("loads the default gsap version when the project head has no gsap", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head><title>Host</title></head><body></body></html>`,
"compositions/scene.html": `<div data-composition-id="scene" data-width="320" data-height="180"></div>`,
});
const html = buildSubCompositionHtml(dir, "compositions/scene.html", "/api/runtime.js");
expect(html).toContain(`<script src="${gsapCdnDist()}gsap.min.js"></script>`);
});
it("adds the preview base even when the project head's script mentions a <base>", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head><script>if (0) document.write('<base href="../">');</script></head><body></body></html>`,
@@ -8,6 +8,7 @@ import {
} from "@hyperframes/core";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
/**
* Rewrite relative asset paths in a parsed DOM tree. Shared across all
@@ -349,7 +350,7 @@ export function buildSubCompositionHtml(
// Fallback: if no index.html head was found, add minimal deps
if (!headContent.includes("gsap")) {
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
headContent += `\n<script src="${gsapCdnDist()}gsap.min.js"></script>`;
}
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
+7 -9
View File
@@ -11,6 +11,7 @@ import {
type BundleOptions,
} from "@hyperframes/core/compiler";
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
@@ -63,10 +64,9 @@ import { requestSubPath } from "../helpers/requestSubPath.js";
import { lazyPreviewImages } from "../helpers/lazyPreviewImages.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
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 injectProjectSignature(html: string, signature: string): string {
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
@@ -157,10 +157,8 @@ function injectMotionPathPluginIfNeeded(html: string): string {
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
const match = html.match(gsapScript);
if (match) {
// Match the plugin version to the composition's own gsap so the plugin
// registers cleanly (a minor-version skew triggers a GSAP compatibility warning).
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
const version = match[0].match(/gsap@([\d.]+)/)?.[1];
const pluginTag = `<script src="${motionPathPluginUrl(version)}"></script>`;
const end = html.indexOf(match[0]) + match[0].length;
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
}
@@ -199,7 +197,7 @@ function injectStudioMotionScript(
const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
const GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
(function(){
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
var cdnBase="${gsapCdnDist()}";
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
@@ -249,7 +249,7 @@ describe("applySoftReload", () => {
// gsap present but MotionPathPlugin unset → async load path.
const { iframe, contentWindow } = buildMockIframe({
MotionPathPlugin: undefined,
gsap: { timeline: vi.fn(), registerPlugin: vi.fn() },
gsap: { timeline: vi.fn(), registerPlugin: vi.fn(), version: "3.14.2" },
});
(iframe.contentDocument as unknown as { head: unknown }).head = head;
@@ -260,7 +260,7 @@ describe("applySoftReload", () => {
// script has NOT executed yet, so the timeline isn't rebound synchronously.
expect(result).toBe("applied");
expect(appendedScripts).toHaveLength(1);
expect(appendedScripts[0]!.src).toContain("MotionPathPlugin");
expect(appendedScripts[0]!.src).toContain("gsap@3.14.2/dist/MotionPathPlugin");
expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
// onerror must NOT run the script (that would reference a missing plugin) —
@@ -384,6 +384,16 @@ describe("ensureMotionPathPluginLoaded", () => {
expect(appendedScripts).toHaveLength(0);
});
it("loads the plugin at the composition's own gsap version", () => {
const { iframe, appendedScripts } = buildBootstrapIframe({
gsap: { version: "3.14.2", registerPlugin: vi.fn() },
});
ensureMotionPathPluginLoaded(iframe);
expect(appendedScripts[0]!.src).toBe(
"https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js",
);
});
it("appends the plugin script once and registers it on load", () => {
const { iframe, contentWindow, appendedScripts, registerPlugin } = buildBootstrapIframe();
ensureMotionPathPluginLoaded(iframe);
+4 -10
View File
@@ -1,4 +1,5 @@
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
import { findAuthoredElement } from "./authoredSource";
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
@@ -14,6 +15,7 @@ type IframeWindow = Window & {
// re-flash the iframe. Cleared once the plugin loads or errors.
__hfMotionPathPluginLoading?: boolean;
gsap?: {
version?: string;
timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
@@ -22,14 +24,6 @@ type IframeWindow = Window & {
MotionPathPlugin?: unknown;
};
/**
* CDN URL for the GSAP MotionPathPlugin. Shared between the one-time preview
* bootstrap (ensureMotionPathPluginLoaded) and the soft-reload fallback so the
* version is pinned in a single place.
*/
const MOTION_PATH_PLUGIN_CDN =
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
/**
* Pre-load + register MotionPathPlugin ONCE in the preview iframe so
* `win.MotionPathPlugin` is reliably set before any studio edit. Called from the
@@ -66,7 +60,7 @@ export function ensureMotionPathPluginLoaded(iframe: HTMLIFrameElement | null):
try {
win.__hfMotionPathPluginLoading = true;
const pluginScript = doc.createElement("script");
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
const finalize = () => {
win.__hfMotionPathPluginLoading = false;
try {
@@ -445,7 +439,7 @@ export function applySoftReload(
}
win.__hfMotionPathPluginLoading = true;
const pluginScript = doc.createElement("script");
pluginScript.src = MOTION_PATH_PLUGIN_CDN;
pluginScript.src = motionPathPluginUrl(win.gsap?.version);
pluginScript.onload = () => {
win.__hfMotionPathPluginLoading = false;
executeScript();