fix(studio): harden keyframe editing semantics

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 00:13:46 +02:00
parent 9fc0011703
commit d57039882f
41 changed files with 1294 additions and 435 deletions
@@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
expect(result.after).not.toContain("data-hf-studio-rotation");
});
it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
const projectDir = createProjectDir();
const PATH_COMP = `<!DOCTYPE html><html><body data-duration="32">
<div id="box"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, duration: 16.055, ease: "power1.inOut" }, 12.17);
</script>
</body></html>`;
writeHtml(projectDir, "path.html", PATH_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "path.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "replace-with-keyframes",
animationId: anim.id,
targetSelector: "#box",
position: 12.17,
duration: 16.055,
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 23.2, properties: { x: 25, y: 30 } },
{ percentage: 100, properties: { x: 100, y: 100 } },
],
ease: "none",
}),
});
const result = (await res.json()) as { ok: boolean; after: string };
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.after).toContain('"23.2%"');
expect(result.after).toContain('easeEach: "power1.inOut"');
expect(result.after).toContain('ease: "none"');
expect(result.after).not.toContain("motionPath");
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
@@ -997,6 +997,7 @@ export type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
}
| {
type: "split-animations";
@@ -1052,6 +1053,18 @@ export type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
function resolveReplacementEaseEach(
scriptText: string,
request: { animationId: string; easeEach?: string },
): string | undefined {
if (request.easeEach !== undefined) return request.easeEach;
const original = parseGsapScriptAcorn(scriptText).animations.find(
(animation) => animation.id === request.animationId,
);
if (!original?.arcPath?.enabled) return undefined;
return original?.keyframes?.easeEach ?? original?.ease;
}
// Mutations that can change a position tween's first keyframe (value/existence/timing)
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
@@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
@@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}