feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op (#1501)

* feat(sdk,core): ws-3 — unrollDynamicAnimations acorn port + SDK op

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* test(core): recast-vs-acorn parity + acorn fixes for arc/unroll/keyframe-add/%-removeKeyframe/add-with-keyframes (WS-3.F gate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): port shiftPositions/scalePositions to acorn writer (WS-3.F)

shiftPositionsInScript + scalePositionsInScript were recast-only GSAP-script
writers reachable from executeGsapMutation (shift-positions/scale-positions),
called by Studio timeline clip move/resize — the last write ops blocking recast
retirement. Ported to gsapWriterAcorn.ts mirroring recast's arithmetic
(shift: max(0,pos+delta); scale: remap pos by duration ratio + scale duration),
reusing a shared overwritePosition helper (also adopted by updateAnimationInScript).
Adds 10 recast-vs-acorn parity tests. Closes the WS-3.F op-coverage gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:54:04 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Miguel Ángel
parent 1612d18fdf
commit 09cefc1bb7
4 changed files with 709 additions and 9 deletions
@@ -15,9 +15,22 @@ import {
materializeKeyframesInScript as materializeRecast,
splitIntoPropertyGroups as splitGroupsRecast,
splitAnimationsInScript as splitAnimsRecast,
setArcPathInScript as setArcRecast,
updateArcSegmentInScript as updateArcSegmentRecast,
removeArcPathFromScript as removeArcRecast,
unrollDynamicAnimations as unrollRecast,
addKeyframeToScript as addKeyframeRecast,
removeKeyframeFromScript as removeKeyframeRecast,
addAnimationWithKeyframesToScript as addWithKfRecast,
shiftPositionsInScript as shiftRecast,
scalePositionsInScript as scaleRecast,
type SplitAnimationsOptions,
} from "./gsapParser.js";
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
import {
parseGsapScriptAcorn,
parseGsapScriptAcornForWrite,
type ParsedGsapAcornForWrite,
} from "./gsapParserAcorn.js";
import {
removeAllKeyframesFromScript as removeAllAcorn,
convertToKeyframesFromScript as convertAcorn,
@@ -27,12 +40,39 @@ import {
setArcPathInScript as setArcAcorn,
updateArcSegmentInScript as updateArcSegmentAcorn,
removeArcPathFromScript as removeArcAcorn,
unrollDynamicAnimations as unrollAcorn,
addKeyframeToScript as addKeyframeAcorn,
removeKeyframeFromScript as removeKeyframeAcorn,
addAnimationWithKeyframesToScript as addWithKfAcorn,
shiftPositionsInScript as shiftAcorn,
scalePositionsInScript as scaleAcorn,
} from "./gsapWriterAcorn.js";
function acornId(script: string): string {
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
return parsed.located[0]!.id;
}
/**
* True recast-vs-acorn differential: parse a written script with the acorn
* parser and strip per-parse metadata, leaving only the AUTHORED animation
* shape. Both writers must produce scripts that reparse to the same model
* (raw text differs — recast pretty-prints, acorn splices in place).
*/
function modelOf(script: string) {
return parseGsapScriptAcorn(script).animations.map((a) => {
// Drop per-parse metadata; compare AUTHORED shape only.
const {
id: _id,
resolvedStart: _resolvedStart,
implicitPosition: _implicitPosition,
propertyGroup: _propertyGroup,
provenance: _provenance,
...rest
} = a;
return rest;
});
}
function arcShapeOf(script: string) {
const anim = parseGsapScript(script).animations[0]!;
return { arcPath: anim.arcPath, properties: anim.properties };
@@ -421,6 +461,21 @@ function arcFixture() {
return { id, enabled };
}
// Multi-waypoint fixture: keyframes drive >2 path waypoints and >1 segment, and
// autoRotate is on — exercises the multi-segment branch of buildMotionPathObjectCode.
const ARC_KEYFRAME_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 50, y: 100 }, "100%": { x: 200, y: 0 } },
duration: 2
}, 0);
`;
const ARC_KEYFRAME_CFG = {
enabled: true as const,
autoRotate: true as const,
segments: [{ curviness: 1.5 }, { curviness: 0.5 }],
};
describe("setArcPathInScript: acorn output correctness", () => {
it("enable: arcPath.enabled=true, segments preserved", () => {
const id = acornId(ARC_FLAT_SCRIPT);
@@ -460,3 +515,477 @@ describe("removeArcPathFromScript: acorn output correctness", () => {
expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined();
});
});
// ─── unrollDynamicAnimations correctness ──────────────────────────────────────
const UNROLL_LOOP_SCRIPT = `
const tl = gsap.timeline({ paused: true });
const items = ["#a", "#b"];
for (let i = 0; i < items.length; i++) {
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
}
`;
const UNROLL_FOREACH_SCRIPT = `
const tl = gsap.timeline({ paused: true });
["#a", "#b"].forEach(function(sel) {
tl.to(sel, { opacity: 1, duration: 2 }, 1);
});
`;
const UNROLL_ELEMENTS = [
{
selector: "#hero",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
},
{
selector: "#sub",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 200 } },
],
},
];
function unrollId(script: string): string {
const p = acornId(script);
return p;
}
describe("unrollDynamicAnimations: acorn output correctness", () => {
it("for-loop: loop replaced with individual tl.to() calls", () => {
const id = unrollId(UNROLL_LOOP_SCRIPT);
const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
expect(out).not.toBe(UNROLL_LOOP_SCRIPT);
expect(out).toContain('tl.to("#hero"');
expect(out).toContain('tl.to("#sub"');
expect(out).not.toContain("for (");
});
it("forEach: loop replaced with individual tl.to() calls", () => {
const id = unrollId(UNROLL_FOREACH_SCRIPT);
const out = unrollAcorn(UNROLL_FOREACH_SCRIPT, id, UNROLL_ELEMENTS);
expect(out).toContain('tl.to("#hero"');
expect(out).not.toContain("forEach");
});
it("preserves duration and position from original tween", () => {
const id = unrollId(UNROLL_LOOP_SCRIPT);
const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS);
expect(out).toContain("duration: 1");
expect(out).toContain("}, 0)");
});
it("no-op when animationId not found", () => {
expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
});
});
// ── True recast-vs-acorn differential for the arc trio ──────────────────────
// For each representative input, apply the op via BOTH the recast writer
// (gsapParser.ts) and the acorn writer (gsapWriterAcorn.ts), then assert the
// reparsed authored model is identical. This is the WS-3.F parity safety net:
// acorn cannot drop or mis-serialize a path/segment/restored-xy that recast keeps.
describe("parity: arc path trio (recast vs acorn)", () => {
for (const { name, script, cfg } of [
{ name: "flat x/y — single segment", script: ARC_FLAT_SCRIPT, cfg: ARC_CFG },
{
name: "keyframes — multi-segment + autoRotate",
script: ARC_KEYFRAME_SCRIPT,
cfg: ARC_KEYFRAME_CFG,
},
]) {
describe(name, () => {
it("setArcPath enable: models match", () => {
const id = acornId(script);
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
expect(modelOf(setArcAcorn(script, id, cfg))).toEqual(
modelOf(setArcRecast(script, id, cfg)),
);
});
it("updateArcSegment: models match", () => {
const recastEnabled = setArcRecast(script, acornId(script), cfg);
const acornEnabled = setArcAcorn(script, acornId(script), cfg);
const idx = cfg.segments.length - 1;
expect(
modelOf(
updateArcSegmentAcorn(acornEnabled, acornId(acornEnabled), idx, { curviness: 3 }),
),
).toEqual(
modelOf(
updateArcSegmentRecast(recastEnabled, acornId(recastEnabled), idx, { curviness: 3 }),
),
);
});
it("removeArcPath: models match (x/y restored, motionPath gone)", () => {
const recastEnabled = setArcRecast(script, acornId(script), cfg);
const acornEnabled = setArcAcorn(script, acornId(script), cfg);
expect(modelOf(removeArcAcorn(acornEnabled, acornId(acornEnabled)))).toEqual(
modelOf(removeArcRecast(recastEnabled, acornId(recastEnabled))),
);
});
});
}
});
// ── forEach with explicit ease + nonzero position — the acorn writer reads
// duration/ease/position from the parsed animation model, recast reads them
// straight from the original tween's AST var/position args. ────────────────────
const UNROLL_FOREACH_EASE_SCRIPT = `
const tl = gsap.timeline({ paused: true });
["#a", "#b"].forEach(function(sel) {
tl.to(sel, { opacity: 1, duration: 2, ease: "power2.out" }, 1);
});
`;
// Dynamic tween NOT inside a loop — both writers fall back to replacing the
// enclosing expression statement. String (label) position, no duration/ease,
// so the default duration: 8 / ease: "none" path is exercised on both sides.
const UNROLL_FALLBACK_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dyn", { opacity: 1 }, "intro");
`;
const UNROLL_ELEMENTS_EASE = [
{
selector: "#hero",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
easeEach: "power1.in",
},
{
selector: "#sub",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 200 } },
],
},
];
// True recast-vs-acorn differential: apply unroll via BOTH writers, then assert
// the reparsed (authored-shape) models are identical. Covers a for-loop, a
// forEach with explicit ease + nonzero position, and a non-loop (fallback)
// tween with a label position + defaulted duration/ease.
const UNROLL_PARITY_CASES: Array<{
name: string;
script: string;
elements: typeof UNROLL_ELEMENTS;
}> = [
{ name: "for-loop", script: UNROLL_LOOP_SCRIPT, elements: UNROLL_ELEMENTS },
{ name: "forEach", script: UNROLL_FOREACH_SCRIPT, elements: UNROLL_ELEMENTS },
{
name: "forEach with ease + nonzero position",
script: UNROLL_FOREACH_EASE_SCRIPT,
elements: UNROLL_ELEMENTS_EASE,
},
{
name: "non-loop fallback — label position, defaulted duration/ease",
script: UNROLL_FALLBACK_SCRIPT,
elements: UNROLL_ELEMENTS_EASE,
},
];
describe("parity: unrollDynamicAnimations (recast vs acorn)", () => {
for (const { name, script, elements } of UNROLL_PARITY_CASES) {
it(name, () => {
const id = unrollId(script);
// Sanity: recast and acorn agree on the id for the dynamic tween.
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
const recastOut = unrollRecast(script, id, elements);
const acornOut = unrollAcorn(script, id, elements);
// Both writers must actually unroll (not no-op).
expect(recastOut).not.toBe(script);
expect(acornOut).not.toBe(script);
// Reparsed authored models must be identical.
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
});
}
it("no-op parity when animationId not found", () => {
expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
expect(unrollRecast(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT);
});
});
// ── addKeyframeToScript parity (recast vs acorn) ────────────────────────────
// PR #1470 routes Studio's GSAP keyframe-add through the acorn writer. Each
// case applies the op via BOTH writers and asserts the parsed authored models
// are equal — closing the parity gap the keyframe-add fix enforces (the acorn
// whole-value overwrite branch must emit recordToCode, not a stale valueCode).
// Two distinct percentages so adding/merging exercises the insert + merge paths.
const KF_ADD_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;
// `_auto`-marked endpoints exercise the adjacent-endpoint sync branch.
const KF_ADD_AUTO_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "100%": { opacity: 1, _auto: 1 } }, duration: 0.5 }, 0.2);
`;
// Flat tween — adding a keyframe runs the convert-to-keyframes path first.
const KF_ADD_FLAT_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
`;
// A percentage entry whose value is NOT an object literal — exercises the
// whole-value overwrite branch (the acorn path here once referenced an
// undefined `valueCode`; recast emits the new value node).
const KF_ADD_NON_OBJECT_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": 0.7, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;
describe("parity: addKeyframeToScript (recast vs acorn)", () => {
function expectParity(
script: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
backfillDefaults?: Record<string, number | string>,
) {
const id = acornId(script);
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
const acorn = addKeyframeAcorn(script, id, percentage, properties, ease, backfillDefaults);
const recast = addKeyframeRecast(script, id, percentage, properties, ease, backfillDefaults);
expect(modelOf(acorn)).toEqual(modelOf(recast));
}
it("inserts a new percentage in sorted order", () => {
expectParity(KF_ADD_SCRIPT, 25, { opacity: 0.3 });
});
it("replaces the value when the percentage already exists", () => {
expectParity(KF_ADD_SCRIPT, 100, { opacity: 0.99 });
});
it("merges a new property into an existing keyframe, preserving siblings", () => {
expectParity(KF_ADD_SCRIPT, 100, { x: 100 });
});
it("carries an ease onto the new keyframe", () => {
expectParity(KF_ADD_SCRIPT, 30, { opacity: 0.4 }, "power2.out");
});
it("backfills a new property across sibling keyframes", () => {
expectParity(KF_ADD_SCRIPT, 25, { x: 50 }, undefined, { x: 0 });
});
it("syncs an adjacent _auto 0% endpoint", () => {
expectParity(KF_ADD_AUTO_SCRIPT, 10, { opacity: 0.2 });
});
it("syncs an adjacent _auto 100% endpoint", () => {
expectParity(KF_ADD_AUTO_SCRIPT, 90, { opacity: 0.8 });
});
it("converts a flat tween to keyframes before inserting", () => {
expectParity(KF_ADD_FLAT_SCRIPT, 50, { opacity: 0.5 });
});
it("overwrites a non-object keyframe value with the new properties", () => {
expectParity(KF_ADD_NON_OBJECT_SCRIPT, 50, { opacity: 0.5 });
});
it("no-op on unknown id agrees between writers", () => {
expect(addKeyframeAcorn(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
expect(addKeyframeRecast(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT);
});
});
// ── removeKeyframeFromScript parity (recast vs acorn) ───────────────────────
// When removal drops a keyframes block below two stops it must collapse back to
// a flat tween (recast via collapseKeyframesToFlat). The acorn writer must
// mirror this — folding the survivor (incl. `_auto`), dropping per-keyframe
// `ease` and the sibling `easeEach` — or the SDK/server paths diverge.
// Three plain keyframes — removing the interior one stays a keyframes block.
const RM_PLAIN_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;
// Two plain keyframes — removing one drops below 2 → collapse to flat tween.
const RM_TWO_KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;
// Two _auto endpoints — collapse must carry the surviving `_auto` marker.
const RM_TWO_KF_AUTO_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
`;
// Survivor carries a per-keyframe `ease`, plus a sibling `easeEach`. Collapse
// must drop both `ease` (per-keyframe) and `easeEach` (keyframes-only).
const RM_TWO_KF_EASE_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1, ease: "power2.in" }, easeEach: "none" }, duration: 0.5 }, 0.2);
`;
// Survivor is empty — collapse yields a tween with NO authored props.
const RM_TWO_KF_EMPTY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": {}, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
`;
describe("parity: removeKeyframeFromScript (recast vs acorn)", () => {
function expectParity(script: string, percentage: number) {
const id = acornId(script);
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
expect(modelOf(removeKeyframeAcorn(script, id, percentage))).toEqual(
modelOf(removeKeyframeRecast(script, id, percentage)),
);
}
it("removes an interior keyframe and stays a keyframes block (3 → 2)", () => {
expectParity(RM_PLAIN_SCRIPT, 50);
});
it("targets a near-coincident percentage (51 → the 50% keyframe) at parity", () => {
expectParity(RM_PLAIN_SCRIPT, 51);
});
it("collapses to a flat tween when only one keyframe would remain (2 → 1)", () => {
expectParity(RM_TWO_KF_SCRIPT, 0);
});
it("collapses an _auto endpoint pair, carrying the surviving _auto marker", () => {
expectParity(RM_TWO_KF_AUTO_SCRIPT, 0);
});
it("collapses and drops per-keyframe ease + easeEach", () => {
expectParity(RM_TWO_KF_EASE_SCRIPT, 0);
});
it("collapses to a propless flat tween when the surviving keyframe is empty", () => {
expectParity(RM_TWO_KF_EMPTY_SCRIPT, 100);
});
it("no-op on unknown id agrees between writers", () => {
expect(removeKeyframeAcorn(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
expect(removeKeyframeRecast(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT);
});
});
// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
// inserted statement's authored model (selector, keyframes, duration, ease,
// position) must match — comparing the LAST animation each writer produced.
const ADD_WITH_KF_BASE = `
const tl = gsap.timeline({ paused: true });
tl.to("#existing", { opacity: 1, duration: 1 }, 0);
`;
function lastModelOf(script: string) {
const arr = modelOf(script);
return arr[arr.length - 1];
}
describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => {
it("minimal: two-keyframe insert, no ease", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 200 } },
];
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
it("moderate: three keyframes, per-keyframe ease, easeEach, nonzero position", () => {
const kfs = [
{ percentage: 0, properties: { x: 0, opacity: 0 } },
{ percentage: 50, properties: { x: 100, opacity: 0.5 }, ease: "power2.out" },
{ percentage: 100, properties: { x: 300, opacity: 1 } },
];
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
});
});
// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ──
const POSITIONS_MULTI = `const tl = gsap.timeline({ paused: true });
tl.from("#hero", { opacity: 0, duration: 1 }, 0);
tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5);
tl.from("#bg", { scale: 0, duration: 1 }, 1);`;
describe("parity: shiftPositionsInScript (recast vs acorn)", () => {
it("shifts only the target selector's numeric positions", () => {
const a = shiftAcorn(POSITIONS_MULTI, "#hero", 3);
const r = shiftRecast(POSITIONS_MULTI, "#hero", 3);
expect(modelOf(a)).toEqual(modelOf(r));
});
it("clamps negative-going positions to zero", () => {
const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 0.3);
tl.to("#el", { y: 50, duration: 1 }, 1.5);`;
expect(modelOf(shiftAcorn(s, "#el", -1))).toEqual(modelOf(shiftRecast(s, "#el", -1)));
});
it("skips string (relative) positions", () => {
const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
expect(modelOf(shiftAcorn(s, "#el", 1))).toEqual(modelOf(shiftRecast(s, "#el", 1)));
});
it("adjacent positions do not collide", () => {
const s = `const tl = gsap.timeline({ paused: true });
tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0);
tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`;
expect(modelOf(shiftAcorn(s, "#burst", 0.5))).toEqual(modelOf(shiftRecast(s, "#burst", 0.5)));
});
it("implicit-position tween gains an explicit shifted position", () => {
const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 1, duration: 1 });`;
expect(modelOf(shiftAcorn(s, "#el", 2))).toEqual(modelOf(shiftRecast(s, "#el", 2)));
});
it("no matching selector is a no-op", () => {
expect(shiftAcorn(POSITIONS_MULTI, "#nope", 3)).toBe(POSITIONS_MULTI);
});
});
describe("parity: scalePositionsInScript (recast vs acorn)", () => {
it("scales positions and durations proportionally for the target", () => {
const a = scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
const r = scaleRecast(POSITIONS_MULTI, "#hero", 0, 1, 2, 2);
expect(modelOf(a)).toEqual(modelOf(r));
});
it("skips string (relative) positions", () => {
const s = `const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 2);
tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
expect(modelOf(scaleAcorn(s, "#el", 0, 1, 1, 2))).toEqual(
modelOf(scaleRecast(s, "#el", 0, 1, 1, 2)),
);
});
it("no-op when oldDuration <= 0", () => {
expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 0, 2, 2)).toBe(POSITIONS_MULTI);
});
it("no-op when newDuration <= 0", () => {
expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI);
});
});
+161 -7
View File
@@ -331,18 +331,87 @@ export function updateAnimationInScript(
}
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
const posArgNode = call.node.arguments?.[posIdx];
if (posArgNode) {
ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position));
} else {
ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`);
}
overwritePosition(ms, call, updates.position);
}
return ms.toString();
}
/**
* Overwrite a tween call's numeric position argument (the positionArg the parser
* located: 3rd arg for fromTo, else 2nd), or append one when the call has no
* explicit position. Shared by updateAnimationInScript and the
* shift/scalePositionsInScript timeline ops.
*/
function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void {
if (call.positionArg) {
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
} else {
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
}
}
/**
* Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0),
* rewriting each call's position argument. Mirrors recast's shiftPositionsInScript
* (used by timeline clip-move to keep GSAP positions in sync with the clip start).
*/
export function shiftPositionsInScript(
script: string,
targetSelector: string,
delta: number,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const ms = new MagicString(script);
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== targetSelector) continue;
if (typeof entry.animation.position !== "number") continue;
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
overwritePosition(ms, entry.call, newPos);
changed = true;
}
return changed ? ms.toString() : script;
}
/**
* Linearly remap every tween targeting `targetSelector` from the old clip
* [oldStart, oldDuration] onto the new [newStart, newDuration] (position and,
* when present, duration scaled by the duration ratio). Mirrors recast's
* scalePositionsInScript (used by timeline clip-resize).
*/
export function scalePositionsInScript(
script: string,
targetSelector: string,
oldStart: number,
oldDuration: number,
newStart: number,
newDuration: number,
): string {
if (oldDuration <= 0 || newDuration <= 0) return script;
const ratio = newDuration / oldDuration;
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const ms = new MagicString(script);
let changed = false;
for (const entry of parsed.located) {
if (entry.animation.targetSelector !== targetSelector) continue;
if (typeof entry.animation.position !== "number") continue;
const newPos = Math.max(
0,
Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000,
);
overwritePosition(ms, entry.call, newPos);
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000);
upsertProp(ms, entry.call.varsArg, "duration", newDur);
}
changed = true;
}
return changed ? ms.toString() : script;
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
@@ -1632,3 +1701,88 @@ export function splitAnimationsInScript(
return { script: result, skippedSelectors };
}
// ── Unroll dynamic animations ────────────────────────────────────────────────
function isLoopNode(node: any): boolean {
const t = node?.type;
return (
t === "ForStatement" ||
t === "ForInStatement" ||
t === "ForOfStatement" ||
t === "WhileStatement"
);
}
function isForEachStatement(node: any): boolean {
return (
node?.type === "ExpressionStatement" &&
node.expression?.type === "CallExpression" &&
node.expression.callee?.property?.name === "forEach"
);
}
function findEnclosingLoop(ancestors: any[]): { start: number; end: number } | null {
for (let i = ancestors.length - 2; i >= 0; i--) {
const node = ancestors[i];
if (isLoopNode(node) || isForEachStatement(node)) {
return { start: node.start as number, end: node.end as number };
}
}
return null;
}
function buildUnrollReplacement(
timelineVar: string,
animation: GsapAnimation,
elements: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>,
): string {
const duration = typeof animation.duration === "number" ? animation.duration : 8;
const ease = typeof animation.ease === "string" ? animation.ease : "none";
const pos = animation.position ?? 0;
const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos);
const calls = elements.map((el) => {
const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage);
const kfCode = buildKeyframeObjectCode(sorted, el.easeEach);
return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`;
});
return calls.join("\n ");
}
export type UnrollElement = {
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
};
/**
* Replace a dynamic loop that generates multiple tween calls with individual
* static `tl.to()` calls — one per element. Finds the loop containing the
* animation and replaces the entire loop body with unrolled static calls.
*/
export function unrollDynamicAnimations(
script: string,
animationId: string,
elements: UnrollElement[],
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements);
const ms = new MagicString(script);
const loop = findEnclosingLoop(target.call.ancestors);
if (loop) {
ms.overwrite(loop.start, loop.end, replacement);
} else {
const stmt = findEnclosingExpressionStatement(target.call.ancestors);
if (!stmt) return script;
ms.overwrite(stmt.start as number, stmt.end as number, replacement);
}
return ms.toString();
}
+8
View File
@@ -61,6 +61,7 @@ import {
setArcPathInScript,
updateArcSegmentInScript,
removeArcPathFromScript,
unrollDynamicAnimations,
} from "@hyperframes/core/gsap-writer-acorn";
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
@@ -208,6 +209,12 @@ function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | un
);
case "removeArcPath":
return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId));
case "unrollDynamicAnimations":
return handleArcPathScript(
parsed,
s,
unrollDynamicAnimations(s, op.animationId, op.elements),
);
default:
return undefined;
}
@@ -1107,6 +1114,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "setArcPath":
case "updateArcSegment":
case "removeArcPath":
case "unrollDynamicAnimations":
case "deleteAllForSelector":
case "removeLabel":
if (getGsapScript(parsed.document) === null)
+10 -1
View File
@@ -157,7 +157,16 @@ export type EditOp =
cp2?: { x: number; y: number };
};
}
| { type: "removeArcPath"; animationId: string };
| { type: "removeArcPath"; animationId: string }
| {
type: "unrollDynamicAnimations";
animationId: string;
elements: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
};
export interface ElasticHold {
start: number;