fix(parsers): preserve duration-authored keyframe timing

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 14:12:18 +02:00
parent c253dec23b
commit 4bfbd89d63
8 changed files with 385 additions and 178 deletions
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
findObjectArrayKeyframeIndex,
getCompatibleObjectArrayKeyframeTiming,
getObjectArrayKeyframeTiming,
} from "./gsapObjectArrayTiming.js";
describe("getObjectArrayKeyframeTiming", () => {
it("preserves tenth-percent precision for evenly distributed arrays", () => {
expect(getObjectArrayKeyframeTiming([undefined, undefined, undefined, undefined])).toEqual({
percentages: [0, 33.3, 66.7, 100],
});
});
it("maps positive authored durations to cumulative percentages", () => {
expect(getObjectArrayKeyframeTiming([1, 2, 1])).toEqual({
percentages: [25, 75, 100],
totalDuration: 4,
});
});
it.each([
[[0, 1, 1], "zero"],
[[1, -1, 1], "negative"],
[[1, Number.NaN, 1], "non-finite"],
[[1, "__raw:total * 0.5", 1], "expression"],
[[1, undefined, 1], "partial"],
])("rejects %s duration timing (%s)", (durations) => {
expect(getObjectArrayKeyframeTiming(durations)).toBeNull();
});
});
describe("findObjectArrayKeyframeIndex", () => {
it("falls back to the nearest array entry for an in-range percentage", () => {
expect(
findObjectArrayKeyframeIndex([undefined, undefined, undefined, undefined], 50, {
fallbackToNearest: true,
}),
).toBe(1);
});
it("rejects out-of-range and unresolved timing", () => {
expect(findObjectArrayKeyframeIndex([undefined, undefined], -1)).toBeNull();
expect(findObjectArrayKeyframeIndex([undefined, undefined], 101)).toBeNull();
expect(findObjectArrayKeyframeIndex([1, 0, 1], 50)).toBeNull();
});
});
describe("getCompatibleObjectArrayKeyframeTiming", () => {
it("accepts absent or matching outer durations and rejects conflicts", () => {
expect(getCompatibleObjectArrayKeyframeTiming([0.25, 0.75], undefined)).not.toBeNull();
expect(getCompatibleObjectArrayKeyframeTiming([0.25, 0.75], 1)).not.toBeNull();
expect(getCompatibleObjectArrayKeyframeTiming([0.25, 0.75], 2)).toBeNull();
expect(getCompatibleObjectArrayKeyframeTiming([0.25, 0.75], "1")).toBeNull();
});
});
@@ -0,0 +1,84 @@
export interface ObjectArrayKeyframeTiming {
percentages: number[];
totalDuration?: number;
}
const roundPercentage = (percentage: number): number => Math.round(percentage * 10) / 10;
const OBJECT_ARRAY_PERCENTAGE_TOLERANCE = 2;
/**
* Resolve GSAP object-array keyframe positions exactly once for parsers and writers.
* Authored per-step durations place each keyframe at its cumulative end; arrays
* without durations are distributed evenly. A partially-authored or invalid duration
* sequence is unresolved: callers must preserve the source rather than silently
* invent different timing.
*/
export function getObjectArrayKeyframeTiming(
durations: ReadonlyArray<unknown>,
): ObjectArrayKeyframeTiming | null {
const hasAuthoredDuration = durations.some((duration) => duration !== undefined);
if (hasAuthoredDuration) {
if (
!durations.every(
(duration): duration is number =>
typeof duration === "number" && Number.isFinite(duration) && duration > 0,
)
) {
return null;
}
const totalDuration = durations.reduce<number>((sum, duration) => sum + duration, 0);
let cumulative = 0;
return {
percentages: durations.map((duration) => {
cumulative += duration;
return roundPercentage((cumulative / totalDuration) * 100);
}),
totalDuration,
};
}
const lastIndex = durations.length - 1;
return {
percentages: durations.map((_, index) =>
lastIndex > 0 ? roundPercentage((index / lastIndex) * 100) : 0,
),
};
}
export function getCompatibleObjectArrayKeyframeTiming(
durations: ReadonlyArray<unknown>,
outerDuration: unknown,
): ObjectArrayKeyframeTiming | null {
const timing = getObjectArrayKeyframeTiming(durations);
if (!timing) return null;
if (timing.totalDuration === undefined || outerDuration === undefined) return timing;
if (
typeof outerDuration === "number" &&
Math.abs(outerDuration - timing.totalDuration) <= Number.EPSILON
) {
return timing;
}
return null;
}
export function findObjectArrayKeyframeIndex(
durations: ReadonlyArray<unknown>,
percentage: number,
options?: { fallbackToNearest?: boolean; tolerance?: number },
): number | null {
if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) return null;
const timing = getObjectArrayKeyframeTiming(durations);
if (!timing) return null;
const { percentages } = timing;
let match: number | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < percentages.length; index++) {
const distance = Math.abs(percentages[index]! - percentage);
if (distance < bestDistance) {
match = index;
bestDistance = distance;
}
}
const tolerance = options?.tolerance ?? OBJECT_ARRAY_PERCENTAGE_TOLERANCE;
return bestDistance <= tolerance || options?.fallbackToNearest ? match : null;
}
+3 -4
View File
@@ -1557,10 +1557,9 @@ describe("native GSAP keyframes parsing", () => {
const kfs = expectKeyframesFormat(anim, "object-array", 3);
// Total duration = 0.5 + 1 + 0.8 = 2.3
// First: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 });
// Second: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out");
// Preserve one decimal of authored timing precision.
expectKeyframe(kfs[0], 21.7, { x: 0, opacity: 1 });
expectKeyframe(kfs[1], 65.2, { x: 100 }, "power2.out");
// Third: cumulative = 2.3, pct = round(2.3/2.3 * 100) = 100
expectKeyframe(kfs[2], 100, { x: 200 });
});
+76 -86
View File
@@ -51,6 +51,11 @@ export {
} from "./gsapConstants";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import {
findObjectArrayKeyframeIndex,
getCompatibleObjectArrayKeyframeTiming,
getObjectArrayKeyframeTiming,
} from "./gsapObjectArrayTiming";
export { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
export type { SpringPreset } from "./springEase";
@@ -723,21 +728,24 @@ function computeKeyframesTotalDuration(
(p: AstNode) => (p.key?.name ?? p.key?.value) === "keyframes",
)?.value;
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
let total = 0;
const durations: unknown[] = [];
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;
durations.push(r.duration);
}
return total > 0 ? total : undefined;
return getObjectArrayKeyframeTiming(durations)?.totalDuration;
}
// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
function parseObjectArrayKeyframes(
node: AstNode,
scope: ScopeBindings,
): GsapKeyframesData | undefined {
const elements = node.elements ?? [];
const raw: Array<{
properties: Record<string, number | string>;
duration?: number;
duration?: unknown;
ease?: string;
}> = [];
@@ -748,10 +756,10 @@ function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKey
}
const record = objectExpressionToRecord(el, scope);
const properties: Record<string, number | string> = {};
let duration: number | undefined;
let duration: unknown;
let ease: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "duration" && typeof v === "number") {
if (k === "duration") {
duration = v;
} else if (k === "ease" && typeof v === "string") {
ease = v;
@@ -762,33 +770,16 @@ function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKey
raw.push({ properties, duration, ease });
}
// Convert durations to percentage positions. If durations are present, use
// cumulative ratios; otherwise distribute evenly.
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
const keyframes: GsapPercentageKeyframe[] = [];
if (totalDuration > 0) {
let cumulative = 0;
for (const entry of raw) {
cumulative += entry.duration ?? 0;
const percentage = Math.round((cumulative / totalDuration) * 100);
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
} else {
for (let i = 0; i < raw.length; i++) {
const entry = raw[i]!;
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
}
const timing = getObjectArrayKeyframeTiming(raw.map((entry) => entry.duration));
if (!timing) return undefined;
const { percentages } = timing;
const keyframes = raw.map(
(entry, index): GsapPercentageKeyframe => ({
percentage: percentages[index]!,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
}),
);
return { format: "object-array", keyframes };
}
@@ -2106,14 +2097,11 @@ function findKeyframesObjectNode(varsArg: AstNode): AstNode | null {
}
/**
* Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object
* form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node
* (or null if not array-form). GSAP distributes an array evenly, so this is
* runtime-identical — but it gives the percentage-keyed write ops something to
* target. Needed before INSERTING a keyframe at an arbitrary percentage, which an
* even array can't host.
* Convert array-form keyframes to percentage-object form in place. Per-step
* durations become cumulative positions and are removed from keyframe values;
* their sum becomes the outer duration when one was not authored.
*/
function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null {
function convertArrayKeyframesToObjectNode(varsArg: AstNode, scope: ScopeBindings): AstNode | null {
if (varsArg?.type !== "ObjectExpression") return null;
const prop = (varsArg.properties ?? []).find(
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes",
@@ -2122,11 +2110,22 @@ function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null {
const els: AstNode[] = (prop.value.elements ?? []).filter(
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
);
const n = els.length;
if (n === 0) return null;
if (els.length === 0) return null;
const records = els.map((element) => objectExpressionToRecord(element, scope));
const outerDuration = objectExpressionToRecord(varsArg, scope).duration;
const timing = getCompatibleObjectArrayKeyframeTiming(
records.map((record) => record.duration),
outerDuration,
);
if (!timing) return null;
if (timing.totalDuration !== undefined && findPropertyNode(varsArg, "duration") === undefined) {
setVarsKey(varsArg, "duration", timing.totalDuration);
}
const entries = els.map((el: AstNode, i: number) => {
const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0;
return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`;
el.properties = (el.properties ?? []).filter(
(property: AstNode) => !isObjectProperty(property) || propKeyName(property) !== "duration",
);
return `${JSON.stringify(`${timing.percentages[i]}%`)}: ${recast.print(el).code}`;
});
prop.value = parseExpr(`{ ${entries.join(", ")} }`);
return prop.value;
@@ -2187,7 +2186,9 @@ export function addKeyframeToScript(
// Array-form keyframes can't host an arbitrary new percentage — normalize to
// object form in place first. (convertToKeyframesInScript below only converts
// FLAT tweens; it early-returns when keyframes already exist.)
if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
if (!kfNode) {
kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg, loc.parsed.scope);
}
if (!kfNode) {
script = convertToKeyframesInScript(script, animationId);
@@ -2300,11 +2301,8 @@ export function removeKeyframeFromScript(
animationId: string,
percentage: number,
): string {
// Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages
// GSAP distributes them evenly. The object-form path below can't see them
// (findKeyframesObjectNode only matches ObjectExpression), so removing from an
// array-form tween silently no-op'd. Resolve the element by its implicit
// percentage and splice it; collapse to a flat tween when fewer than two remain.
// Array-form keyframes have no explicit percentages. Resolve their authored
// cumulative/even position before splicing the matching element.
const arrLoc = locateAnimationWithFallback(script, animationId);
// findPropertyNode here returns the property's VALUE node directly.
const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
@@ -2312,19 +2310,12 @@ export function removeKeyframeFromScript(
const elements: AstNode[] = (arrVal.elements ?? []).filter(
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
);
const n = elements.length;
if (n === 0) return script;
let matchIdx = -1;
let bestDist = Number.POSITIVE_INFINITY;
for (let i = 0; i < n; i++) {
const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
const dist = Math.abs(pct - percentage);
if (dist <= PCT_TOLERANCE && dist < bestDist) {
matchIdx = i;
bestDist = dist;
}
}
if (matchIdx === -1) return script;
if (elements.length === 0) return script;
const durations = elements.map(
(element) => objectExpressionToRecord(element, arrLoc.parsed.scope).duration,
);
const matchIdx = findObjectArrayKeyframeIndex(durations, percentage);
if (matchIdx === null) return script;
const remaining = elements.filter((_, i) => i !== matchIdx);
if (remaining.length < 2) {
const sole = remaining[0];
@@ -2378,7 +2369,7 @@ export function moveKeyframeInScript(
// normalize to object form in place first (mirrors addKeyframeToScript).
const kfNode =
findKeyframesObjectNode(loc.target.call.varsArg) ??
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
convertArrayKeyframesToObjectNode(loc.target.call.varsArg, loc.parsed.scope);
if (!kfNode) return script;
const match = findKeyframePropByPct(kfNode, fromPercentage);
@@ -2439,7 +2430,7 @@ export function resizeKeyframedTweenInScript(
// normalize to object form in place first (mirrors addKeyframeToScript).
const kfNode =
findKeyframesObjectNode(loc.target.call.varsArg) ??
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
convertArrayKeyframesToObjectNode(loc.target.call.varsArg, loc.parsed.scope);
if (!kfNode) return script;
const seen = new Set<AstNode>();
@@ -2472,37 +2463,36 @@ export function updateKeyframeInScript(
ease?: string,
): string {
// Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages —
// GSAP distributes them evenly. The percentage-keyed object path below can't
// match them (findKeyframesObjectNode only matches ObjectExpression), so dragging
// a motion-path node on an array-authored tween silently no-op'd. Resolve the
// element by its implicit percentage and replace it in place. Mirrors the array
// branch in removeKeyframeFromScript.
// Match array entries through the same cumulative/even timing rule used by
// parsing, then merge the edit without dropping per-step duration metadata.
const arrLoc = locateAnimationWithFallback(script, animationId);
const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
if (arrLoc && arrVal?.type === "ArrayExpression") {
const elements: AstNode[] = (arrVal.elements ?? []).filter(
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
);
const n = elements.length;
if (n === 0) return script;
let matchIdx = -1;
let bestDist = Number.POSITIVE_INFINITY;
for (let i = 0; i < n; i++) {
const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
const dist = Math.abs(pct - percentage);
if (dist <= PCT_TOLERANCE && dist < bestDist) {
matchIdx = i;
bestDist = dist;
}
}
if (matchIdx === -1) return script;
if (elements.length === 0) return script;
const records = elements.map((element) =>
objectExpressionToRecord(element, arrLoc.parsed.scope),
);
const matchIdx = findObjectArrayKeyframeIndex(
records.map((record) => record.duration),
percentage,
{ fallbackToNearest: true },
);
if (matchIdx === null) return script;
const matchEl = elements[matchIdx];
if (!matchEl) return script;
const realIdx = arrVal.elements.indexOf(matchEl);
if (Object.keys(properties).length === 0 && ease && setObjectExpressionEase(matchEl, ease)) {
return recast.print(arrLoc.parsed.ast).code;
}
arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease);
const merged: Record<string, number | string> = {};
for (const [key, value] of Object.entries(records[matchIdx] ?? {})) {
if (typeof value === "number" || typeof value === "string") merged[key] = value;
}
Object.assign(merged, properties);
arrVal.elements[realIdx] = buildKeyframeValueNode(merged, ease);
return recast.print(arrLoc.parsed.ast).code;
}
@@ -2674,7 +2664,7 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
// on every array-form tween.
const kfNode =
findKeyframesObjectNode(loc.target.call.varsArg) ??
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
convertArrayKeyframesToObjectNode(loc.target.call.varsArg, loc.parsed.scope);
const method = loc.target.call.method;
let record: Record<string, unknown>;
if (kfNode) {
@@ -882,8 +882,8 @@ describe("native GSAP keyframes parsing", () => {
const anim = parseSingleAnimation(script);
const kfs = expectKeyframesFormat(anim, "object-array", 3);
expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 });
expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out");
expectKeyframe(kfs[0], 21.7, { x: 0, opacity: 1 });
expectKeyframe(kfs[1], 65.2, { x: 100 }, "power2.out");
expectKeyframe(kfs[2], 100, { x: 200 });
});
+15 -33
View File
@@ -21,6 +21,7 @@ import type {
import { classifyTweenPropertyGroup } from "./gsapConstants.js";
import { buildArcPath } from "./gsapSerialize.js";
import { inlineComputedTimelines, readProvenance } from "./gsapInline.js";
import { getObjectArrayKeyframeTiming } from "./gsapObjectArrayTiming.js";
// Browser-safe re-exports so studio code can build arc config without importing
// the recast parser (this acorn module is the browser-safe gsap subpath).
@@ -1031,13 +1032,13 @@ function computeKeyframesTotalDuration(
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
)?.value;
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
let total = 0;
const durations: unknown[] = [];
for (const el of kfNode.elements ?? []) {
if (!el || el.type !== "ObjectExpression") continue;
const r = objectExpressionToRecord(el, scope, source);
if (typeof r.duration === "number") total += r.duration;
durations.push(r.duration);
}
return total > 0 ? total : undefined;
return getObjectArrayKeyframeTiming(durations)?.totalDuration;
}
// fallow-ignore-next-line complexity
@@ -1045,11 +1046,11 @@ function parseObjectArrayKeyframes(
node: any,
scope: ScopeBindings,
source: string,
): GsapKeyframesData {
): GsapKeyframesData | undefined {
const elements = node.elements ?? [];
const raw: Array<{
properties: Record<string, number | string>;
duration?: number;
duration?: unknown;
ease?: string;
}> = [];
@@ -1057,10 +1058,10 @@ function parseObjectArrayKeyframes(
if (!el || el.type !== "ObjectExpression") continue;
const record = objectExpressionToRecord(el, scope, source);
const properties: Record<string, number | string> = {};
let duration: number | undefined;
let duration: unknown;
let ease: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "duration" && typeof v === "number") {
if (k === "duration") {
duration = v;
} else if (k === "ease" && typeof v === "string") {
ease = v;
@@ -1071,32 +1072,13 @@ function parseObjectArrayKeyframes(
raw.push({ properties, duration, ease });
}
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
const keyframes: GsapPercentageKeyframe[] = [];
if (totalDuration > 0) {
let cumulative = 0;
for (const entry of raw) {
cumulative += entry.duration ?? 0;
const percentage = Math.round((cumulative / totalDuration) * 100);
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
} else {
for (let i = 0; i < raw.length; i++) {
const entry = raw[i];
if (!entry) continue;
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
}
const timing = getObjectArrayKeyframeTiming(raw.map((entry) => entry.duration));
if (!timing) return undefined;
const keyframes: GsapPercentageKeyframe[] = raw.map((entry, index) => ({
percentage: timing.percentages[index]!,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
}));
return { format: "object-array", keyframes };
}
+108 -14
View File
@@ -257,6 +257,23 @@ describe("removeKeyframeFromScript: array-form keyframes (recast + acorn parity)
expect(removeKeyframeAcorn(arrayScript, id, 12)).toBe(arrayScript);
expect(removeKeyframeRecast(arrayScript, id, 12)).toBe(arrayScript);
});
it("removes duration-authored entries at their cumulative percentage", () => {
const durationScript = `
const tl = gsap.timeline({ paused: true });
tl.to("#p", { keyframes: [
{ x: 0, duration: 1 },
{ x: 50, duration: 2 },
{ x: 100, duration: 1 }
] }, 0.2);
`;
const id = acornId(durationScript);
const acorn = removeKeyframeAcorn(durationScript, id, 75);
const recast = removeKeyframeRecast(durationScript, id, 75);
expect(modelOf(acorn)).toEqual(modelOf(recast));
expect(JSON.stringify(shapeOf(acorn).keyframes)).not.toContain('"x":50');
});
});
const CONVERT_FIXTURES: Array<{
@@ -1049,6 +1066,18 @@ const UPD_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2);
`;
const DURATION_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, duration: 1 }, { x: 50, duration: 2 }, { x: 100, duration: 1 }] }, 0.2);
`;
const EXPRESSION_DURATION_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, duration: getStep() }, { x: 100, duration: 1 }] }, 0.2);
`;
const MISMATCHED_DURATION_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, duration: 1 }, { x: 50, duration: 2 }, { x: 100, duration: 1 }], duration: 8 }, 0.2);
`;
const UPD_MOTION_PATH_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 40 }] }, duration: 1, ease: "power1.inOut" }, 0.2);
@@ -1089,6 +1118,46 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
expectParity(UPD_ARRAY_SCRIPT, 49, { x: 55, y: 85 });
});
it("targets duration-authored array entries at their cumulative percentage", () => {
const id = acornId(DURATION_ARRAY_SCRIPT);
const acorn = updateKeyframeAcorn(DURATION_ARRAY_SCRIPT, id, 75, {}, "spring(0.42)");
const recast = updateKeyframeRecast(DURATION_ARRAY_SCRIPT, id, 75, {}, "spring(0.42)");
expect(modelOf(acorn)).toEqual(modelOf(recast));
for (const output of [acorn, recast]) {
const keyframes = parseGsapScript(output).animations[0]!.keyframes!.keyframes;
expect(keyframes.map((keyframe) => keyframe.percentage)).toEqual([25, 75, 100]);
expect(keyframes[1]!.ease).toBe("spring(0.42)");
expect(keyframes[0]!.ease).toBeUndefined();
}
});
it("keeps browser and server parsers aligned on duration-authored percentages", () => {
const server = parseGsapScript(DURATION_ARRAY_SCRIPT).animations[0]?.keyframes?.keyframes;
const browser = parseGsapScriptAcorn(DURATION_ARRAY_SCRIPT).animations[0]?.keyframes?.keyframes;
expect(browser?.map(({ percentage }) => percentage)).toEqual(
server?.map(({ percentage }) => percentage),
);
});
it("preserves source instead of inventing timing for an expression duration", () => {
const id = acornId(EXPRESSION_DURATION_ARRAY_SCRIPT);
expect(updateKeyframeAcorn(EXPRESSION_DURATION_ARRAY_SCRIPT, id, 50, { x: 60 })).toBe(
EXPRESSION_DURATION_ARRAY_SCRIPT,
);
expect(updateKeyframeRecast(EXPRESSION_DURATION_ARRAY_SCRIPT, id, 50, { x: 60 })).toBe(
EXPRESSION_DURATION_ARRAY_SCRIPT,
);
expect(
parseGsapScript(EXPRESSION_DURATION_ARRAY_SCRIPT).animations[0]?.hasUnresolvedKeyframes,
).toBe(true);
expect(
parseGsapScriptAcorn(EXPRESSION_DURATION_ARRAY_SCRIPT).animations[0]?.hasUnresolvedKeyframes,
).toBe(true);
});
it("no-op when the object-form percentage is absent (both writers)", () => {
const id = acornId(UPD_OBJ_SCRIPT);
expect(updateKeyframeAcorn(UPD_OBJ_SCRIPT, id, 33, { opacity: 0.4 })).toBe(UPD_OBJ_SCRIPT);
@@ -1128,20 +1197,7 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
expect(animation?.keyframes?.keyframes.at(-1)?.properties).toMatchObject(properties);
});
// KNOWN DIVERGENCE (acorn-array bug, follow-up — NOT a test artifact):
// For PARTIAL props on ARRAY-form keyframes the two writers disagree. recast's
// array branch (gsapParser.updateKeyframeInScript) does a whole-value REPLACE
// — `arrVal.elements[i] = buildKeyframeValueNode(properties, ease)` — matching
// its own object-form branch and the documented replace contract. acorn's
// array branch (updateArrayKeyframeByPct in gsapWriterAcorn) MERGES instead —
// `{ ...valueNodeToRecord(el), ...properties }` — so updating `50%` with only
// `{ x: 60 }` leaves recast at { x: 60 } but acorn at { x: 60, y: 80 }. acorn's
// array path is inconsistent with both recast AND acorn's own object path.
// Real callers (Studio drag, SDK mutate.ts) always pass the COMPLETE keyframe
// value, so the bug is latent in production — but it's a genuine writer gap to
// fix in gsapWriterAcorn, out of scope for this test-only change. Skipped (not
// deleted) so the contract is documented and the fix has a ready assertion.
it.skip("array-form PARTIAL props: recast replaces, acorn merges (acorn bug)", () => {
it("array-form partial props preserve the remaining authored keyframe fields", () => {
const id = acornId(UPD_ARRAY_SCRIPT);
const acorn = updateKeyframeAcorn(UPD_ARRAY_SCRIPT, id, 50, { x: 60 });
const recast = updateKeyframeRecast(UPD_ARRAY_SCRIPT, id, 50, { x: 60 });
@@ -1271,6 +1327,28 @@ describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", (
expect(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
expect(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
});
it("normalizes duration-authored percentages before moving", () => {
const id = acornId(DURATION_ARRAY_SCRIPT);
const acorn = moveKeyframeAcorn(DURATION_ARRAY_SCRIPT, id, 75, 60);
const recast = moveKeyframeRecast(DURATION_ARRAY_SCRIPT, id, 75, 60);
expect(modelOf(acorn)).toEqual(modelOf(recast));
expect(shapeOf(acorn).keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual([
25, 60, 100,
]);
});
it("does not normalize conflicting inner and outer durations", () => {
const id = acornId(MISMATCHED_DURATION_ARRAY_SCRIPT);
expect(moveKeyframeAcorn(MISMATCHED_DURATION_ARRAY_SCRIPT, id, 75, 60)).toBe(
MISMATCHED_DURATION_ARRAY_SCRIPT,
);
expect(moveKeyframeRecast(MISMATCHED_DURATION_ARRAY_SCRIPT, id, 75, 60)).toBe(
MISMATCHED_DURATION_ARRAY_SCRIPT,
);
});
});
// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
@@ -1385,6 +1463,22 @@ describe("resizeKeyframedTweenInScript: array-form keyframes (recast + acorn par
modelOf(resizeKeyframedTweenRecast(KF_ADD_ARRAY_SCRIPT, id, 0.2, 2, RESIZE_ARRAY_REMAP)),
);
});
it("normalizes duration-authored percentages before resizing", () => {
const id = acornId(DURATION_ARRAY_SCRIPT);
const remap = [
{ from: 25, to: 20 },
{ from: 75, to: 60 },
{ from: 100, to: 100 },
];
const acorn = resizeKeyframedTweenAcorn(DURATION_ARRAY_SCRIPT, id, 0.2, 5, remap);
const recast = resizeKeyframedTweenRecast(DURATION_ARRAY_SCRIPT, id, 0.2, 5, remap);
expect(modelOf(acorn)).toEqual(modelOf(recast));
expect(shapeOf(acorn).keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual([
20, 60, 100,
]);
});
});
describe("removeAllKeyframesFromScript: array-form keyframes (recast + acorn parity)", () => {
+41 -39
View File
@@ -26,6 +26,10 @@ import {
} from "./gsapParserAcorn.js";
import { classifyPropertyGroup } from "./gsapConstants.js";
import type { PropertyGroupName } from "./gsapConstants.js";
import {
findObjectArrayKeyframeIndex,
getCompatibleObjectArrayKeyframeTiming,
} from "./gsapObjectArrayTiming.js";
import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js";
import * as acornWalk from "acorn-walk";
@@ -974,8 +978,6 @@ export function updateKeyframeInScript(
return updateObjectKeyframe(script, match.prop, properties, ease);
}
// ponytail: even-spacing index map; if array keyframes ever carry per-element
// `duration`, switch to matching the closest cumulative position.
function updateArrayKeyframeByPct(
script: string,
arrayNode: Node,
@@ -986,13 +988,18 @@ function updateArrayKeyframeByPct(
const elements = ((arrayNode.elements ?? []) as Array<Node | null>).filter(
(el): el is Node => !!el && el.type === "ObjectExpression",
);
const n = elements.length;
if (n === 0) return script;
const idx = n > 1 ? Math.round((percentage / 100) * (n - 1)) : 0;
const el = elements[Math.max(0, Math.min(n - 1, idx))];
if (elements.length === 0) return script;
const records = elements.map((element) => valueNodeToRecord(element, script));
const idx = findObjectArrayKeyframeIndex(
records.map((record) => record.duration),
percentage,
{ fallbackToNearest: true },
);
if (idx === null) return script;
const el = elements[idx];
if (!el) return script;
const merged: Record<string, number | string> = {
...valueNodeToRecord(el, script),
...records[idx],
...properties,
};
if (ease) merged.ease = ease;
@@ -1071,23 +1078,34 @@ function locateWithKeyframes(
}
/** Locate a tween's keyframes object, converting a flat tween first if absent. */
// Array-form keyframes (`keyframes: [{x,y}, …]`) → even-percentage object form
// (`{ "0%": {…}, "33.3%": {…}, … }`). Inserting a keyframe needs percentage keys,
// which an even array can't host. Runtime-identical; mirrors the recast path.
// Array-form keyframes percentage-object form. Duration-authored entries use
// cumulative positions; duration metadata moves to the outer tween.
function convertArrayKeyframesToObject(script: string, target: Node): string {
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ArrayExpression") return script;
const els = ((kfPropNode.value.elements ?? []) as Array<Node | null>).filter(
(el): el is Node => !!el && el.type === "ObjectExpression",
);
const n = els.length;
if (n === 0) return script;
if (els.length === 0) return script;
const records = els.map((element) => valueNodeToRecord(element, script));
const outerDuration = valueNodeToRecord(target.call.varsArg, script).duration;
const timing = getCompatibleObjectArrayKeyframeTiming(
records.map((record) => record.duration),
outerDuration,
);
if (!timing) return script;
const entries = els.map((el, i) => {
const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0;
return `${JSON.stringify(`${pct}%`)}: ${script.slice(el.start, el.end)}`;
const { duration: _duration, ...record } = records[i]!;
return `${JSON.stringify(`${timing.percentages[i]}%`)}: ${recordToCode(record)}`;
});
const ms = new MagicString(script);
ms.overwrite(kfPropNode.value.start, kfPropNode.value.end, `{ ${entries.join(", ")} }`);
if (
timing.totalDuration !== undefined &&
findPropertyNode(target.call.varsArg, "duration") === undefined
) {
upsertProp(ms, target.call.varsArg, "duration", timing.totalDuration);
}
return ms.toString();
}
@@ -1237,18 +1255,8 @@ function collapseKeyframesToFlat(
ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`);
}
/** Implicit tween-relative percentage of array-form keyframe index `i` of `n`
* (GSAP distributes array keyframes evenly: 0%, 1/(n-1), , 100%). */
function arrayKeyframePct(i: number, n: number): number {
return n > 1 ? (i / (n - 1)) * 100 : 0;
}
// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages —
// GSAP distributes them evenly. removeKeyframeFromScript only handled the
// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween
// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`).
// Resolve the element by its implicit percentage and splice it out; collapse to a
// flat tween when fewer than two remain (parity with the object-form path).
// Resolve an array entry through the parser's cumulative/even timing rule, then
// splice it out; collapse when fewer than two remain.
function removeArrayKeyframe(
ms: MagicString,
varsArg: Node,
@@ -1259,19 +1267,13 @@ function removeArrayKeyframe(
const elements: Node[] = (arrNode.elements ?? []).filter(
(e: Node | null): e is Node => !!e && e.type === "ObjectExpression",
);
const n = elements.length;
if (n === 0) return false;
let matchIdx = -1;
let bestDist = Number.POSITIVE_INFINITY;
for (let i = 0; i < n; i++) {
const dist = Math.abs(arrayKeyframePct(i, n) - percentage);
if (dist <= PCT_TOLERANCE && dist < bestDist) {
matchIdx = i;
bestDist = dist;
}
}
if (matchIdx === -1) return false;
if (elements.length === 0) return false;
const records = elements.map((element) => valueNodeToRecord(element, script));
const matchIdx = findObjectArrayKeyframeIndex(
records.map((record) => record.duration),
percentage,
);
if (matchIdx === null) return false;
const remaining = elements.filter((_, i) => i !== matchIdx);
if (remaining.length < 2) {