Skip to content

Commit 1ea09b1

Browse files
committed
fix(core): inject MotionPathPlugin into preview when a composition uses motionPath
A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it.
1 parent 17f5dd9 commit 1ea09b1

2 files changed

Lines changed: 92 additions & 2 deletions

File tree

packages/core/src/studio-api/routes/preview.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,57 @@ describe("registerPreviewRoutes", () => {
114114
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
115115
});
116116

117+
it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => {
118+
const projectDir = createProjectDir();
119+
writeFileSync(
120+
join(projectDir, "index.html"),
121+
`<!doctype html><html><head>
122+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
123+
</head><body><div id="card" class="clip"></div>
124+
<script>
125+
const tl = gsap.timeline({ paused: true });
126+
tl.to("#card", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] }, duration: 1 }, 0);
127+
window.__timelines = { index: tl };
128+
</script>
129+
</body></html>`,
130+
);
131+
const app = new Hono();
132+
registerPreviewRoutes(app, createAdapter(projectDir));
133+
134+
const response = await app.request("http://localhost/projects/demo/preview");
135+
const html = await response.text();
136+
137+
expect(response.status).toBe(200);
138+
// Plugin version is derived from the composition's own gsap (gsap@3 here).
139+
expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js");
140+
// Plugin must load AFTER the core gsap script so it can register onto it.
141+
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js"));
142+
});
143+
144+
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
145+
const projectDir = createProjectDir();
146+
writeFileSync(
147+
join(projectDir, "index.html"),
148+
`<!doctype html><html><head>
149+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
150+
</head><body><div id="card" class="clip"></div>
151+
<script>
152+
const tl = gsap.timeline({ paused: true });
153+
tl.to("#card", { x: 100, duration: 1 }, 0);
154+
window.__timelines = { index: tl };
155+
</script>
156+
</body></html>`,
157+
);
158+
const app = new Hono();
159+
registerPreviewRoutes(app, createAdapter(projectDir));
160+
161+
const response = await app.request("http://localhost/projects/demo/preview");
162+
const html = await response.text();
163+
164+
expect(response.status).toBe(200);
165+
expect(html).not.toContain("MotionPathPlugin.min.js");
166+
});
167+
117168
it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => {
118169
const projectDir = createProjectDir();
119170
mkdirSync(join(projectDir, "compositions"), { recursive: true });

packages/core/src/studio-api/routes/preview.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
1818
const GSAP_CDN_VERSION = "3.15.0";
1919
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
2020
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
21+
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
2122

2223
function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
2324
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
@@ -86,6 +87,42 @@ function htmlHasCustomEase(html: string): boolean {
8687
);
8788
}
8889

90+
// A composition that drives motion via GSAP's `motionPath` (e.g. a studio-created
91+
// motion path written into the single-source timeline) needs MotionPathPlugin
92+
// registered before the timeline first renders — otherwise the initial seek
93+
// throws "Invalid property motionPath ... Missing plugin?". Detect it anywhere in
94+
// the bundle (the plugin registers globally, so sub-composition usage counts too).
95+
function htmlUsesMotionPath(html: string): boolean {
96+
return /motionPath\s*[:{]/.test(html);
97+
}
98+
99+
function htmlHasMotionPathPlugin(html: string): boolean {
100+
return (
101+
/<script\b[^>]*src=["'][^"']*MotionPathPlugin/i.test(html) ||
102+
/\bwindow\.MotionPathPlugin\b/.test(html) ||
103+
/\bMotionPathPlugin\s*=\s*/.test(html)
104+
);
105+
}
106+
107+
function injectMotionPathPluginIfNeeded(html: string): string {
108+
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
109+
// The plugin registers onto an already-loaded gsap, so it must come AFTER the
110+
// core gsap script — which often lives at body-end, not <head>. Insert it
111+
// directly after the gsap script tag; only fall back to <head> if none is found
112+
// (e.g. gsap is inlined).
113+
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
114+
const match = html.match(gsapScript);
115+
if (match) {
116+
// Match the plugin version to the composition's own gsap so the plugin
117+
// registers cleanly (a minor-version skew triggers a GSAP compatibility warning).
118+
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
119+
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
120+
const end = html.indexOf(match[0]) + match[0].length;
121+
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
122+
}
123+
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
124+
}
125+
89126
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
90127
const manifest = parseStudioMotionManifestContent(manifestContent);
91128
if (!manifest.hasMotion) return html;
@@ -149,8 +186,10 @@ function injectStudioPreviewAugmentations(
149186
activeCompositionPath: string,
150187
): string {
151188
return injectStudioMotionScript(
152-
injectGsapCdnFallback(
153-
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
189+
injectMotionPathPluginIfNeeded(
190+
injectGsapCdnFallback(
191+
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
192+
),
154193
),
155194
projectDir,
156195
activeCompositionPath,

0 commit comments

Comments
 (0)