diff --git a/packages/parsers/src/gsapObjectArrayTiming.test.ts b/packages/parsers/src/gsapObjectArrayTiming.test.ts new file mode 100644 index 0000000000..b1d554058f --- /dev/null +++ b/packages/parsers/src/gsapObjectArrayTiming.test.ts @@ -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(); + }); +}); diff --git a/packages/parsers/src/gsapObjectArrayTiming.ts b/packages/parsers/src/gsapObjectArrayTiming.ts new file mode 100644 index 0000000000..1b63e3202d --- /dev/null +++ b/packages/parsers/src/gsapObjectArrayTiming.ts @@ -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, +): 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((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, + 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, + 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; +} diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index eafbf09628..cdce510b53 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -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 }); }); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 24f42ec71e..9a3eea6af5 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -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; - 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 = {}; - 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(); @@ -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 = {}; + 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; if (kfNode) { diff --git a/packages/parsers/src/gsapParserAcorn.full.test.ts b/packages/parsers/src/gsapParserAcorn.full.test.ts index 77b63b4aff..e6348eff04 100644 --- a/packages/parsers/src/gsapParserAcorn.full.test.ts +++ b/packages/parsers/src/gsapParserAcorn.full.test.ts @@ -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 }); }); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index f578879cd6..f8f4ef962c 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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; - 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 = {}; - 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 }; } diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index e934585ffe..9e0add6c8d 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -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)", () => { diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 00b5da5c4e..f6d8e42151 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -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).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 = { - ...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).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) {