mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): make GSAP tween editing work on real compositions (#1115)
The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.
Three coordinated fixes make it work end to end:
- Parser read: resolve querySelector / querySelectorAll / getElementById
variable targets (and inline lookup calls) back to their CSS selector,
so variable-targeted tweens are recognized.
- Parser write: replace the full re-serialize (preamble + tweens +
postamble) with in-place recast AST mutation. Edits now touch only the
targeted tween's vars/position node and reprint, preserving every
surrounding statement — gsap.set calls, element declarations, the IIFE
wrapper, comments and formatting. Previously the first edit would
discard all of that.
- Linter: build overlap/clip windows directly from the parser's
structured animations instead of a regex walk paired positionally with
the parsed list. The old pairing skipped variable targets and would
drift once the parser started returning them. Removes the now-dead
regex meta helpers.
- studio-api: extractGsapScriptBlock now searches inside <template>
content (sub-compositions wrap markup + the GSAP script in a template,
which linkedom's querySelectorAll doesn't descend into), and the
frontend matches tweens to the selected element by id OR selector
rather than id only (class-targeted elements have no id).
Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerFileRoutes } from "./files";
|
||||
@@ -63,4 +63,89 @@ describe("registerFileRoutes", () => {
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
|
||||
// targeting element variables resolved from querySelector, with interleaved
|
||||
// gsap.set() calls. This is the shape every scaffolded composition uses.
|
||||
const TEMPLATE_COMP = `<template id="scene-template">
|
||||
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="3">
|
||||
<div class="kicker">HELLO</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const root = document.querySelector('#scene');
|
||||
const kicker = root.querySelector(".kicker");
|
||||
gsap.set(kicker, { y: 16, opacity: 0 });
|
||||
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
|
||||
window.__timelines["scene"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</template>`;
|
||||
|
||||
function writeComp(projectDir: string, name: string, html: string): void {
|
||||
const dir = join(projectDir, "compositions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, name), html);
|
||||
}
|
||||
|
||||
it("parses GSAP tweens from a <template>-wrapped sub-composition with variable targets", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request(
|
||||
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
animations: Array<{ id: string; targetSelector: string; properties: Record<string, number> }>;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.animations).toHaveLength(1);
|
||||
expect(payload.animations[0].targetSelector).toBe(".kicker");
|
||||
});
|
||||
|
||||
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const parseRes = await app.request(
|
||||
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
|
||||
);
|
||||
const { animations } = (await parseRes.json()) as { animations: Array<{ id: string }> };
|
||||
const animationId = animations[0].id;
|
||||
|
||||
const mutateRes = await app.request(
|
||||
"http://localhost/projects/demo/gsap-mutations/compositions/scene.html",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "update-property",
|
||||
animationId,
|
||||
property: "opacity",
|
||||
value: 0.5,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = (await mutateRes.json()) as { ok: boolean; after: string };
|
||||
|
||||
expect(mutateRes.status).toBe(200);
|
||||
expect(result.ok).toBe(true);
|
||||
// Edit landed
|
||||
expect(result.after).toContain("opacity: 0.5");
|
||||
// Surrounding code preserved verbatim — the in-place AST edit didn't rewrite the block
|
||||
expect(result.after).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
||||
expect(result.after).toContain('const kicker = root.querySelector(".kicker")');
|
||||
expect(result.after).toContain('window.__timelines["scene"] = tl;');
|
||||
expect(result.after).toContain("(function () {");
|
||||
// The variable target was not flattened to a string-literal selector
|
||||
expect(result.after).toContain("tl.to(kicker,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,7 +194,17 @@ function extractGsapScriptBlock(
|
||||
html: string,
|
||||
): { scriptText: string; replaceScript: (newText: string) => string } | null {
|
||||
const { document } = parseHTML(html);
|
||||
const scripts = document.querySelectorAll("script:not([src])");
|
||||
// linkedom's querySelectorAll doesn't descend into <template> content, but
|
||||
// sub-compositions wrap their markup (and the GSAP <script>) in a <template>.
|
||||
// Search top-level scripts first, then each template's own scripts. Operate
|
||||
// on the template element directly (NOT .content) so textContent writes are
|
||||
// reflected in document.toString().
|
||||
const scripts = [
|
||||
...document.querySelectorAll("script:not([src])"),
|
||||
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
|
||||
Array.from(tmpl.querySelectorAll("script:not([src])")),
|
||||
),
|
||||
];
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent || "";
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user