Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/parsers/src/gsapObjectArrayTiming.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
84 changes: 84 additions & 0 deletions packages/parsers/src/gsapObjectArrayTiming.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 3 additions & 4 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down
Loading
Loading