mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(parsers): preserve duration-authored keyframe timing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user