feat(core): GSAP-aware split engine for timeline clip splitting (#1330)

* refactor(studio): extract shared timeline components and deduplicate code

Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs

* feat(core): GSAP-aware split engine for timeline clip splitting

Add splitAnimationsInScript to the GSAP parser — correctly re-times
animations when a timeline clip is split at an arbitrary position:

- Animations before split: kept on original, properties inherited via
  tl.set inserted before other tweens for correct GSAP state recording
- Animations after split: retargeted via AST selector update
- Spanning animations: trimmed on original, continuation added for
  new element with correct position and duration
- Keyframes: classified by total per-keyframe duration
- Reverse iteration prevents stale animation ID collisions

Enhance splitElementInHtml:
- CSS rule duplication via PostCSS for ID-based styles
- Server-side ID deduplication for repeated splits
- Media playback-start adjustment for video/audio

Add split-animations route to gsap-mutations endpoint.
This commit is contained in:
Miguel Ángel
2026-06-10 23:48:16 -04:00
committed by GitHub
parent ab08260201
commit 45d4a71ed0
7 changed files with 671 additions and 145 deletions
+15 -10
View File
@@ -22,7 +22,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -54,7 +54,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.6.80",
"version": "0.6.88",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -101,11 +101,12 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2",
"recast": "^0.23.11",
},
"devDependencies": {
@@ -130,7 +131,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -148,7 +149,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.6.79",
"version": "0.6.88",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -168,7 +169,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.6.80",
"version": "0.6.88",
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -180,7 +181,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -221,7 +222,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -233,7 +234,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.6.80",
"version": "0.6.88",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -1738,7 +1739,7 @@
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
@@ -2132,6 +2133,8 @@
"path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -2148,6 +2151,8 @@
"tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
+1
View File
@@ -193,6 +193,7 @@
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2",
"recast": "^0.23.11"
},
"devDependencies": {
+246 -95
View File
@@ -17,9 +17,18 @@ import {
convertToKeyframesInScript,
removeAllKeyframesFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
import {
parseAndSerialize,
parseSingleAnimation,
expectKeyframe,
expectKeyframesFormat,
convertAndReparse,
parseSplitAndAssert,
} from "./gsapParser.test-helpers.js";
describe("parseGsapScript", () => {
it("parses a basic timeline with .to()", () => {
@@ -299,11 +308,7 @@ describe("stagger/yoyo/repeat round-trip", () => {
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
expect(serialized).toContain("stagger: {");
expect(serialized).toContain("each: 0.15");
@@ -315,11 +320,7 @@ describe("stagger/yoyo/repeat round-trip", () => {
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
expect(serialized).toContain("yoyo: true");
expect(serialized).toContain("repeat: 3");
@@ -349,11 +350,7 @@ describe("unresolvable value round-trip", () => {
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
// The raw expression should survive — emitted without quotes
expect(serialized).toContain("opacity: someFn()");
@@ -1101,9 +1098,8 @@ describe("gsap.utils.toArray targets", () => {
const tl = gsap.timeline({ paused: true });
tl.to(gsap.utils.toArray(".item"), { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".item");
const anim = parseSingleAnimation(script);
expect(anim.targetSelector).toBe(".item");
});
it("resolves a toArray result stored in a variable", () => {
@@ -1112,8 +1108,8 @@ describe("gsap.utils.toArray targets", () => {
const tl = gsap.timeline({ paused: true });
tl.to(items, { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].targetSelector).toBe(".item");
const anim = parseSingleAnimation(script);
expect(anim.targetSelector).toBe(".item");
});
});
@@ -1147,9 +1143,8 @@ describe("forEach / map callback targets", () => {
tl.to(el, { opacity: 1, duration: 0.4 }, 0);
});
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".item");
const anim = parseSingleAnimation(script);
expect(anim.targetSelector).toBe(".item");
});
it("resolves an inline querySelectorAll().forEach callback param", () => {
@@ -1159,8 +1154,8 @@ describe("forEach / map callback targets", () => {
tl.to(dot, { scale: 1, duration: 0.3 }, 0);
});
`;
const result = parseGsapScript(script);
expect(result.animations[0].targetSelector).toBe(".dot");
const anim = parseSingleAnimation(script);
expect(anim.targetSelector).toBe(".dot");
});
});
@@ -1205,23 +1200,12 @@ describe("native GSAP keyframes parsing", () => {
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("percentage");
expect(anim.keyframes!.keyframes).toHaveLength(3);
const anim = parseSingleAnimation(script);
const kfs = expectKeyframesFormat(anim, "percentage", 3);
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[1].percentage).toBe(50);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
expect(anim.keyframes!.keyframes[2].percentage).toBe(100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 });
expectKeyframe(kfs[1], 50, { x: 100 }, "power2.out");
expectKeyframe(kfs[2], 100, { x: 200 });
});
it("parses object array keyframes format", () => {
@@ -1235,26 +1219,15 @@ describe("native GSAP keyframes parsing", () => {
]
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("object-array");
expect(anim.keyframes!.keyframes).toHaveLength(3);
const anim = parseSingleAnimation(script);
const kfs = expectKeyframesFormat(anim, "object-array", 3);
// Total duration = 0.5 + 1 + 0.8 = 2.3
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 });
// Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
expect(anim.keyframes!.keyframes[1].percentage).toBe(22);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out");
// Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
expect(anim.keyframes!.keyframes[2].percentage).toBe(65);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
expectKeyframe(kfs[2], 65, { x: 200 });
});
it("parses simple array keyframes format", () => {
@@ -1265,30 +1238,17 @@ describe("native GSAP keyframes parsing", () => {
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
const anim = parseSingleAnimation(script);
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("simple-array");
expect(anim.keyframes!.easeEach).toBe("power2.inOut");
expect(anim.keyframes!.keyframes).toHaveLength(4);
// Evenly spaced: 0%, 33%, 67%, 100%
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0);
expect(anim.keyframes!.keyframes[1].percentage).toBe(33);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[2].percentage).toBe(67);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[3].percentage).toBe(100);
expect(anim.keyframes!.keyframes[3].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0);
expectKeyframe(anim.keyframes!.keyframes[0], 0, { x: 0, opacity: 0 });
expectKeyframe(anim.keyframes!.keyframes[1], 33, { x: 100, opacity: 1 });
expectKeyframe(anim.keyframes!.keyframes[2], 67, { x: 200, opacity: 1 });
expectKeyframe(anim.keyframes!.keyframes[3], 100, { x: 0, opacity: 0 });
});
it("parses three-level easing", () => {
@@ -1539,18 +1499,11 @@ describe("keyframe mutations", () => {
const tl = gsap.timeline({ paused: true });
tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3);
`;
const id = getAnimId(script);
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 });
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
const anim = convertAndReparse(script, { x: 0, opacity: 1 });
expect(anim.method).toBe("to");
expect(anim.keyframes).toBeDefined();
const kfs = anim.keyframes!.keyframes;
expect(kfs[0].properties.x).toBe(-200);
expect(kfs[0].properties.opacity).toBe(0);
expect(kfs[1].properties.x).toBe(0);
expect(kfs[1].properties.opacity).toBe(1);
const kfs = expectKeyframesFormat(anim, "percentage", 2);
expectKeyframe(kfs[0]!, 0, { x: -200, opacity: 0 });
expectKeyframe(kfs[1]!, 100, { x: 0, opacity: 1 });
});
it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => {
@@ -1558,16 +1511,11 @@ describe("keyframe mutations", () => {
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0);
`;
const id = getAnimId(script);
const updated = convertToKeyframesInScript(script, id);
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
const anim = convertAndReparse(script);
expect(anim.method).toBe("to");
expect(anim.keyframes).toBeDefined();
const kfs = anim.keyframes!.keyframes;
expect(kfs[0].properties.x).toBe(-100);
expect(kfs[1].properties.x).toBe(100);
const kfs = expectKeyframesFormat(anim, "percentage", 2);
expect(kfs[0]!.properties.x).toBe(-100);
expect(kfs[1]!.properties.x).toBe(100);
});
it("convertToKeyframesInScript — skips if already has keyframes", () => {
@@ -1793,3 +1741,206 @@ tl.to("#title", { x: 100, duration: 0.5 }, 0);
expect(id).toBe("");
});
});
describe("splitAnimationsInScript", () => {
const baseScript = `const tl = gsap.timeline({ paused: true });`;
const opts = {
originalId: "el1",
newId: "el1-split",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
};
const split = (script: string, o = opts) => splitAnimationsInScript(script, o).script;
it("keeps animation entirely in first half and adds set for inherited state", () => {
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 0);`;
const result = split(script);
const parsed = parseGsapScript(result);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal).toHaveLength(1);
expect(forNew).toHaveLength(1);
expect(forNew[0]!.method).toBe("set");
expect(forNew[0]!.properties.x).toBe(100);
expect(forNew[0]!.position).toBe(opts.splitTime);
});
it("retargets animation entirely in second half to new element", () => {
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`;
const selectors = parseSplitAndAssert(script, (s) => split(s), 1);
expect(selectors[0]).toBe("#el1-split");
});
it("splits spanning tween with linear interpolation and fromTo on clone", () => {
const script = `${baseScript}\ntl.to("#el1", { opacity: 1, duration: 4 }, 0);`;
const setupOpts = { ...opts, splitTime: 2, elementDuration: 4 };
const result = split(script, setupOpts);
const parsed = parseGsapScript(result);
const first = parsed.animations.find((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
const continuation = forNew.find((a) => a.method === "fromTo");
expect(first).toBeDefined();
expect(first!.duration).toBe(2);
expect(first!.properties.opacity).toBe(0.5);
expect(continuation).toBeDefined();
expect(continuation!.duration).toBe(2);
expect(continuation!.fromProperties?.opacity).toBe(0.5);
expect(continuation!.properties.opacity).toBe(1);
});
it("retargets multiple animations at the same position both after split", () => {
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);\ntl.to("#el1", { y: 200, duration: 1 }, 3);`;
const result = split(script);
const parsed = parseGsapScript(result);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal.length).toBe(0);
expect(forNew.length).toBe(2);
});
it("returns script unchanged when no matching animations", () => {
const script = `${baseScript}\ntl.to("#other", { x: 100, duration: 1 }, 0);`;
const result = split(script);
expect(result).toBe(script);
});
it("handles multiple animations independently", () => {
const script = `${baseScript}
tl.to("#el1", { x: 100, duration: 1 }, 0);
tl.to("#el1", { y: 200, duration: 1 }, 3);`;
const result = split(script);
const parsed = parseGsapScript(result);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal).toHaveLength(1);
expect(forOriginal[0]!.properties.x).toBe(100);
expect(forNew).toHaveLength(2);
const retargeted = forNew.find((a) => a.method === "to");
const inherited = forNew.find((a) => a.method === "set");
expect(retargeted!.properties.y).toBe(200);
expect(inherited!.properties.x).toBe(100);
});
it("interpolates fromTo properties at split point on both halves", () => {
const script = `${baseScript}\ntl.fromTo("#el1", { opacity: 0 }, { opacity: 1, duration: 4 }, 0);`;
const result = split(script);
const parsed = parseGsapScript(result);
const first = parsed.animations.find((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
const continuation = forNew.find((a) => a.method === "fromTo");
expect(first!.properties.opacity).toBe(0.5);
expect(continuation).toBeDefined();
expect(continuation!.fromProperties?.opacity).toBe(0.5);
expect(continuation!.properties.opacity).toBe(1);
});
it("round-trips correctly through parseGsapScript", () => {
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 4 }, 0);`;
const result = split(script);
const parsed = parseGsapScript(result);
expect(parsed.animations.length).toBeGreaterThanOrEqual(2);
for (const anim of parsed.animations) {
expect(typeof anim.position).toBe("number");
if (anim.method !== "set") expect(anim.duration).toBeGreaterThan(0);
}
});
it("leaves spanning keyframes on original and warns via skippedSelectors", () => {
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 1 }, { scale: 1.2, duration: 1 }, { x: 50, duration: 1 }] }, 1);`;
const splitOpts = {
originalId: "el1",
newId: "el1-split",
splitTime: 2.5,
elementStart: 0,
elementDuration: 5,
};
const fullResult = splitAnimationsInScript(script, splitOpts);
const parsed = parseGsapScript(fullResult.script);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal.length).toBe(1);
expect(forOriginal[0]!.keyframes).toBeDefined();
expect(forNew.length).toBe(1);
expect(forNew[0]!.method).toBe("set");
expect(forNew[0]!.properties.opacity).toBe(1);
expect(fullResult.skippedSelectors).toContain("#el1 (keyframes spanning split)");
});
it("retargets keyframes animation entirely after split", () => {
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 4);`;
const splitOpts = {
originalId: "el1",
newId: "el1-split",
splitTime: 3,
elementStart: 0,
elementDuration: 5,
};
const result = split(script, splitOpts);
const parsed = parseGsapScript(result);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal.length).toBe(0);
expect(forNew.length).toBe(1);
expect(forNew[0]!.keyframes).toBeDefined();
});
it("keeps keyframes animation entirely before split and inherits final keyframe state", () => {
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 0);\ntl.to("#el1", { y: 100, duration: 1 }, 4);`;
const splitOpts = {
originalId: "el1",
newId: "el1-split",
splitTime: 3,
elementStart: 0,
elementDuration: 5,
};
const result = split(script, splitOpts);
const parsed = parseGsapScript(result);
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
const setState = forNew.find((a) => a.method === "set");
expect(setState).toBeDefined();
expect(setState!.properties.scale).toBe(1.2);
});
it("retargets set tween entirely after split", () => {
const script = `${baseScript}\ntl.set("#el1", { opacity: 0 }, 3);`;
const result = split(script);
const parsed = parseGsapScript(result);
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
expect(forOriginal.length).toBe(0);
expect(forNew.length).toBe(1);
expect(forNew[0]!.method).toBe("set");
expect(forNew[0]!.position).toBe(3);
});
it("inserts inherited state set before other tweens targeting new element", () => {
const script = `${baseScript}\ntl.to("#el1", { opacity: 1, x: 50, duration: 0.5 }, 0);\ntl.to("#el1", { opacity: 0, duration: 0.5 }, 5);`;
const splitOpts = {
originalId: "el1",
newId: "el1-split",
splitTime: 3,
elementStart: 0,
elementDuration: 6,
};
const result = split(script, splitOpts);
const parsed = parseGsapScript(result);
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
const setState = forNew.find((a) => a.method === "set");
const exitTween = forNew.find((a) => a.method === "to");
expect(setState).toBeDefined();
expect(exitTween).toBeDefined();
expect(setState!.properties.opacity).toBe(1);
expect(setState!.properties.x).toBe(50);
const setIdx = parsed.animations.indexOf(setState!);
const exitIdx = parsed.animations.indexOf(exitTween!);
expect(setIdx).toBeLessThan(exitIdx);
});
it("reports skipped selectors for non-ID-based animations referencing the element", () => {
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 0);\ntl.to(".el1", { opacity: 0, duration: 1 }, 1);`;
const result = splitAnimationsInScript(script, opts);
expect(result.skippedSelectors).toEqual([".el1"]);
});
});
+259 -34
View File
@@ -580,6 +580,20 @@ function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframe
};
}
function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): number | undefined {
const kfNode = (varsNode.properties ?? []).find(
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
)?.value;
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
let total = 0;
for (const el of kfNode.elements ?? []) {
if (!el || el.type !== "ObjectExpression") continue;
const r = objectExpressionToRecord(el, scope);
if (typeof r.duration === "number") total += r.duration;
}
return total > 0 ? total : undefined;
}
// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
const elements = node.elements ?? [];
@@ -861,9 +875,13 @@ function tweenCallToAnimation(
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
const duration = typeof vars.duration === "number" ? vars.duration : undefined;
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
const ease = typeof vars.ease === "string" ? vars.ease : undefined;
if (duration === undefined && keyframesData) {
duration = computeKeyframesTotalDuration(call.varsArg, scope);
}
const anim: Omit<GsapAnimation, "id"> = {
targetSelector: call.selector,
method: call.method,
@@ -1025,6 +1043,21 @@ function setVarsKey(varsArg: any, key: string, value: number | string): void {
}
}
/**
* Filter an ObjectExpression's properties, keeping non-editable keys
* and delegating the keep/drop decision for editable keys to `shouldKeep`.
*/
function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean): void {
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (!isEditablePropertyKey(key)) return true;
return shouldKeep(key);
});
}
/**
* Replace the editable-property keys on an ObjectExpression with `newProps`,
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
@@ -1034,15 +1067,7 @@ function reconcileEditableProperties(
varsArg: any,
newProps: Record<string, number | string>,
): void {
if (varsArg?.type !== "ObjectExpression") return;
// Drop editable props no longer present.
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (!isEditablePropertyKey(key)) return true;
return key in newProps;
});
filterEditableKeys(varsArg, (key) => key in newProps);
// Upsert each new prop, preserving the order keys first appeared.
for (const [key, value] of Object.entries(newProps)) {
setVarsKey(varsArg, key, value);
@@ -1125,6 +1150,24 @@ export function updateAnimationInScript(
return recast.print(parsed.ast).code;
}
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const selectorArg = target.call.path.node.arguments?.[0];
if (selectorArg?.type === "StringLiteral") {
selectorArg.value = newSelector;
} else if (selectorArg?.type === "Identifier") {
target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector };
}
return recast.print(parsed.ast).code;
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
@@ -1277,8 +1320,186 @@ export function removeAnimationFromScript(script: string, animationId: string):
return recast.print(parsed.ast).code;
}
function insertInheritedStateSet(
script: string,
selector: string,
position: number,
properties: Record<string, number | string>,
): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const tlVar = parsed.timelineVar;
const props = Object.entries(properties)
.map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
.join(", ");
const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
const newStatement = parseScript(code).program.body[0];
const anchor = findTimelineDeclarationPath(parsed.ast, tlVar);
if (anchor) {
anchor.insertAfter(newStatement);
} else if (parsed.located.length > 0) {
const firstTween = parsed.located[0]!.call;
const stmtPath = findStatementPath(firstTween.path);
if (stmtPath) stmtPath.insertBefore(newStatement);
else parsed.ast.program.body.unshift(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
return recast.print(parsed.ast).code;
}
// ── Split Animation Functions ─────────────────────────────────────────────
export interface SplitAnimationsOptions {
originalId: string;
newId: string;
splitTime: number;
elementStart: number;
elementDuration: number;
}
export interface SplitAnimationsResult {
script: string;
/** Non-ID-selector animations that the engine cannot safely retarget. */
skippedSelectors: string[];
}
// fallow-ignore-next-line complexity
export function splitAnimationsInScript(
script: string,
opts: SplitAnimationsOptions,
): SplitAnimationsResult {
const parsed = parseGsapScript(script);
const originalSelector = `#${opts.originalId}`;
const newSelector = `#${opts.newId}`;
const skippedSelectors: string[] = [];
for (const a of parsed.animations) {
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
skippedSelectors.push(a.targetSelector);
}
}
const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector);
if (matching.length === 0) return { script, skippedSelectors };
let result = script;
const newElementStart = opts.splitTime;
const inheritedProps: Record<string, number | string> = {};
// Reverse iteration: updateAnimationSelector mutates selectors in the source
// string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2")
// for later animations. Processing last-to-first prevents stale ID collisions.
for (let i = matching.length - 1; i >= 0; i--) {
const anim = matching[i]!;
const pos = typeof anim.position === "number" ? anim.position : 0;
const dur = anim.duration ?? 0;
const animEnd = pos + dur;
if (anim.keyframes) {
if (pos >= opts.splitTime) {
result = updateAnimationSelector(result, anim.id, newSelector);
} else if (animEnd > opts.splitTime) {
// Spanning keyframes can't be correctly split without renormalizing
// percentages and durations — leave on original, warn the caller.
skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
const kfs = anim.keyframes.keyframes;
for (const kf of kfs) {
const kfTime = pos + (kf.percentage / 100) * dur;
if (kfTime <= opts.splitTime) {
for (const [k, v] of Object.entries(kf.properties)) {
inheritedProps[k] = v;
}
}
}
} else {
// Entirely before split — extract final keyframe properties
const kfs = anim.keyframes.keyframes;
if (kfs.length > 0) {
for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) {
inheritedProps[k] = v;
}
}
}
continue;
}
if (animEnd <= opts.splitTime) {
for (const [k, v] of Object.entries(anim.properties)) {
inheritedProps[k] = v;
}
continue;
}
if (pos >= opts.splitTime) {
result = updateAnimationSelector(result, anim.id, newSelector);
continue;
}
// Spans the split — use linear interpolation to compute mid-values,
// then .fromTo() on the clone so both halves play the correct range.
// For .fromTo() tweens we have explicit from-values; for .to() tweens
// we use accumulated state from prior animations, defaulting to 0 for
// unknown numeric properties (the standard GSAP transform initial state).
const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
const fromSource = anim.fromProperties ?? inheritedProps;
const midProps: Record<string, number | string> = {};
for (const [k, v] of Object.entries(anim.properties)) {
if (typeof v !== "number") {
midProps[k] = v;
continue;
}
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
midProps[k] = fromVal + (v - fromVal) * progress;
}
const firstHalfDuration = opts.splitTime - pos;
result = updateAnimationInScript(result, anim.id, {
duration: firstHalfDuration,
properties: midProps,
});
const secondHalfDuration = animEnd - opts.splitTime;
const addResult = addAnimationToScript(result, {
targetSelector: newSelector,
method: "fromTo",
position: newElementStart,
duration: secondHalfDuration,
properties: { ...anim.properties },
fromProperties: { ...midProps },
ease: anim.ease,
extras: anim.extras,
});
result = addResult.script;
for (const [k, v] of Object.entries(midProps)) {
inheritedProps[k] = v;
}
}
if (Object.keys(inheritedProps).length > 0) {
result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps);
}
return { script: result, skippedSelectors };
}
// ── Keyframe Mutation Functions ────────────────────────────────────────────
function sortedKeyframes(
kfs: Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }>,
) {
return kfs.slice().sort((a, b) => a.percentage - b.percentage);
}
function keyframePropsToCode(kf: { properties: Record<string, number | string> }): string[] {
return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
}
/** Remove a named property from an ObjectExpression's properties array. */
function removeVarsKey(varsArg: any, key: string): void {
if (varsArg?.type !== "ObjectExpression") return;
@@ -1344,6 +1565,19 @@ function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>):
removeVarsKey(varsArg, "easeEach");
}
/**
* Locate an animation's keyframes ObjectExpression and build the percentage key.
* Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and
* updateKeyframeInScript.
*/
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
const loc = locateAnimation(script, animationId);
if (!loc) return null;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return null;
return { loc, kfNode, pctKey: `${percentage}%` };
}
/**
* Insert a keyframe at the given percentage in an existing percentage-keyframes
* object. If the percentage already exists, its value is replaced.
@@ -1375,8 +1609,8 @@ export function addKeyframeToScript(
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
}
const pctKey = `${percentage}%`;
const newValueNode = buildKeyframeValueNode(properties, ease);
// Replace if this percentage already exists
@@ -1467,12 +1701,10 @@ export function removeKeyframeFromScript(
animationId: string,
percentage: number,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode, pctKey } = ctx;
const pctKey = `${percentage}%`;
const removeIdx = kfNode.properties.findIndex(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
@@ -1502,12 +1734,10 @@ export function updateKeyframeInScript(
properties: Record<string, number | string>,
ease?: string,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode, pctKey } = ctx;
const pctKey = `${percentage}%`;
const existing = kfNode.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
@@ -1560,14 +1790,15 @@ function resolveConversionProps(
/** Strip editable properties and ease/keyframes keys from a varsArg. */
function stripEditableAndEase(varsArg: any): void {
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
// drop it explicitly before filtering, along with keyframes.
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (key === "ease" || key === "keyframes") return false;
return !isEditablePropertyKey(key);
return key !== "ease" && key !== "keyframes";
});
filterEditableKeys(varsArg, () => false);
}
/** Build and prepend a keyframes property node onto varsArg. */
@@ -1674,11 +1905,8 @@ export function materializeKeyframesInScript(
}
const entries: string[] = [];
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
for (const kf of sorted) {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
for (const kf of sortedKeyframes(keyframes)) {
const propEntries = keyframePropsToCode(kf);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
@@ -1977,11 +2205,8 @@ export function unrollDynamicAnimations(
const calls: string[] = [];
for (const el of elements) {
const kfEntries: string[] = [];
const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
for (const kf of sorted) {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
for (const kf of sortedKeyframes(el.keyframes)) {
const propEntries = keyframePropsToCode(kf);
kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
if (el.easeEach) {
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
probeElementInSource,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -467,3 +467,53 @@ describe("splitElementInHtml — hfId clone isolation", () => {
expect(occurrences).toBe(1);
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
});
@@ -1,4 +1,6 @@
import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
export interface SourceMutationTarget {
id?: string | null;
@@ -18,6 +20,36 @@ function parseSourceDocument(source: string): { document: Document; wrappedFragm
};
}
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
const idToken = `#${originalId}`;
const transform = selectorParser((selectors) => {
selectors.walkIds((node) => {
if (node.value === originalId) node.value = newId;
});
});
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent ?? "";
let root: postcss.Root;
try {
root = postcss.parse(css);
} catch {
continue;
}
const clones: postcss.Rule[] = [];
root.walkRules((rule) => {
if (!rule.selector.includes(idToken)) return;
const newSelector = transform.processSync(rule.selector);
if (newSelector === rule.selector) return;
const clone = rule.clone({ selector: newSelector });
clones.push(clone);
});
if (clones.length > 0) {
for (const c of clones) root.append(c);
styleEl.textContent = root.toString();
}
}
}
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll(selector));
if (matches.length > 0) return matches;
@@ -287,6 +319,7 @@ function setElementDuration(
}
}
// fallow-ignore-next-line complexity
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
@@ -302,6 +335,14 @@ export function splitElementInHtml(
return { html: source, matched: false, newId: null };
}
if (document.getElementById(newId)) {
let suffix = 2;
const base = newId;
while (document.getElementById(newId)) {
newId = `${base}-${suffix++}`;
}
}
const firstDuration = splitTime - start;
const secondDuration = duration - firstDuration;
@@ -311,6 +352,9 @@ export function splitElementInHtml(
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Keep the "clip" class — the runtime uses it to control visibility
// based on data-start/data-duration timing.
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
? "data-playback-start"
@@ -327,6 +371,12 @@ export function splitElementInHtml(
);
}
// Duplicate CSS rules targeting the original ID so the clone inherits the same styles.
const originalId = el.getAttribute("id");
if (originalId) {
duplicateCssRulesForId(document, originalId, newId);
}
// Trim the original element's duration
setElementDuration(el, start, firstDuration, usesDataEnd);
+49 -5
View File
@@ -408,15 +408,25 @@ type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
}
| {
type: "split-animations";
originalId: string;
newId: string;
splitTime: number;
elementStart: number;
elementDuration: number;
};
// ── GSAP mutation executor ──────────────────────────────────────────────────
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
async function executeGsapMutation(
body: GsapMutationRequest,
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
respond: (data: unknown, status?: number) => Response,
): Promise<string | Response> {
): Promise<GsapMutationResult | Response> {
const parser = await loadGsapParser();
const {
parseGsapScript,
@@ -434,6 +444,7 @@ async function executeGsapMutation(
updateArcSegmentInScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
} = parser;
function requireAnimation(
@@ -606,6 +617,36 @@ async function executeGsapMutation(
);
return result.script;
}
case "split-animations": {
if (
typeof body.originalId !== "string" ||
!body.originalId ||
typeof body.newId !== "string" ||
!body.newId ||
typeof body.splitTime !== "number" ||
!Number.isFinite(body.splitTime) ||
typeof body.elementStart !== "number" ||
!Number.isFinite(body.elementStart) ||
typeof body.elementDuration !== "number" ||
!Number.isFinite(body.elementDuration) ||
body.elementDuration <= 0
) {
return respond(
{
error:
"split-animations requires originalId, newId (non-empty strings), splitTime, elementStart (finite numbers), and elementDuration (positive number)",
},
400,
);
}
return splitAnimationsInScript(block.scriptText, {
originalId: body.originalId,
newId: body.newId,
splitTime: body.splitTime,
elementStart: body.elementStart,
elementDuration: body.elementDuration,
});
}
default:
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}
@@ -1019,21 +1060,24 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const result = await executeGsapMutation(body, block, respond);
if (result instanceof Response) return result;
const newScript = result;
const newScript = typeof result === "string" ? result : result.script;
const newHtml = block.replaceScript(newScript);
if (newHtml !== html) {
writeFileSync(res.absPath, newHtml, "utf-8");
}
// Re-parse the mutated script so the UI gets fresh state
const { parseGsapScript } = await loadGsapParser();
const freshParsed = parseGsapScript(newScript);
return c.json({
const responsePayload: Record<string, unknown> = {
ok: true,
parsed: freshParsed,
before: html,
after: newHtml,
scriptText: newScript,
});
};
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
responsePayload.skippedSelectors = result.skippedSelectors;
}
return c.json(responsePayload);
});
}