Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
414d299
fix(studio): place GSAP step lists and duration-less tweens where GSA…
miguel-heygen Oct 4, 2026
13599b0
refactor(studio): drop narrated comments and unnest the extend clamp
miguel-heygen Oct 4, 2026
6ac754e
fix(studio): match keyframes in time, resolve partial step timing, st…
miguel-heygen Oct 4, 2026
9ef36da
fix(studio): time step-list keyframes from the parts' last end and fo…
miguel-heygen Oct 4, 2026
fc69ab3
refactor(studio): trim comments to the package's comment share
miguel-heygen Oct 4, 2026
b63130a
fix(parsers): apply timeline defaults as GSAP does to step lists and …
miguel-heygen Oct 4, 2026
76b9076
fix(studio): keep GSAP's ease for motion paths with keyframes and rea…
miguel-heygen Oct 4, 2026
6d4a0b0
refactor(studio): read a live motion path in its own step
miguel-heygen Oct 4, 2026
8a7ad4e
refactor(parsers): leave step-list timing to its one owner and skip t…
miguel-heygen Oct 4, 2026
bb496e6
refactor(studio): move the live motion-path read into its own module
miguel-heygen Oct 4, 2026
8661f4f
test(parsers): pin an unresolved length for a step list stretched by …
miguel-heygen Oct 4, 2026
6d49cf1
chore(studio): merge main into the step-list branch
miguel-heygen Oct 4, 2026
5853e89
fix(studio): time keyframes linearly under an outer ease that oversho…
miguel-heygen Oct 5, 2026
cf24952
chore(studio): merge main into the step-list branch
miguel-heygen Oct 5, 2026
186f6c7
fix(studio): time a converted expression-duration tween from its live…
miguel-heygen Oct 5, 2026
1f26459
chore(studio): merge main into the step-list branch
miguel-heygen Oct 5, 2026
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
12 changes: 6 additions & 6 deletions packages/parsers/src/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import type { GsapAnimation } from "./gsapSerialize.js";

export const GSAP_DEFAULT_DURATION = 0.5;

export const SUPPORTED_PROPS = [
// 2D Transforms
"x",
Expand Down Expand Up @@ -138,7 +140,7 @@ function knownStart(animation: GsapAnimation): number | undefined {
}

/**
* What a Studio hold pins from t=0 before a later keyframed tween: its first keyframe's position props,
* What a Studio hold pins from t=0 before a later keyframed tween: its 0% keyframe's position props,
* minus those an earlier timeline tween on the target writes (a global `gsap.set` is a base value).
*/
export function positionHoldForAnimation(
Expand All @@ -148,10 +150,8 @@ export function positionHoldForAnimation(
if (!animation.keyframes) return null;
const start = knownStart(animation) ?? 0;
if (!(start > 0.001)) return null;
const first = [...animation.keyframes.keyframes].sort(
(left, right) => left.percentage - right.percentage,
)[0];
if (!first) return null;
const atStart = animation.keyframes.keyframes.find((keyframe) => keyframe.percentage === 0);
if (!atStart) return null;
// A tween whose start the parser could not resolve (a label, say) is not known to come first.
const earlier = animations.filter((other) => {
const otherStart = knownStart(other);
Expand All @@ -164,7 +164,7 @@ export function positionHoldForAnimation(
);
});
const position: Record<string, number> = {};
for (const [property, value] of Object.entries(first.properties)) {
for (const [property, value] of Object.entries(atStart.properties)) {
if (classifyPropertyGroup(property) !== "position" || typeof value !== "number") continue;
if (earlier.some((other) => writesProperty(other, property))) continue;
position[property] = value;
Expand Down
19 changes: 15 additions & 4 deletions packages/parsers/src/gsapObjectArrayTiming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@ import {
} 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("ends each equal step at its share of the tween, to a tenth of a percent", () => {
expect(getObjectArrayKeyframeTiming([undefined, undefined, undefined])).toEqual({
percentages: [33.3, 66.7, 100],
totalDuration: 1.5,
});
expect(getObjectArrayKeyframeTiming([undefined])).toEqual({
percentages: [100],
totalDuration: 0.5,
});
});

it("gives a step without a duration GSAP's 0.5 s beside authored ones", () => {
expect(getObjectArrayKeyframeTiming([1, undefined, 1])).toEqual({
percentages: [40, 60, 100],
totalDuration: 2.5,
});
});

Expand All @@ -24,7 +36,6 @@ describe("getObjectArrayKeyframeTiming", () => {
[[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();
});
Expand Down
53 changes: 23 additions & 30 deletions packages/parsers/src/gsapObjectArrayTiming.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
import { GSAP_DEFAULT_DURATION } from "./gsapConstants.js";

export interface ObjectArrayKeyframeTiming {
percentages: number[];
totalDuration?: 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.
* Each step's keyframe sits at its cumulative end; a step without a duration plays
* GSAP's default. A non-positive or unresolved duration leaves the timing 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 steps = durations.map((duration) => duration ?? GSAP_DEFAULT_DURATION);
if (
!steps.every(
(duration): duration is number =>
typeof duration === "number" && Number.isFinite(duration) && duration > 0,
)
) {
return null;
}

const lastIndex = durations.length - 1;
const totalDuration = steps.reduce((sum, duration) => sum + duration, 0);
let cumulative = 0;
return {
percentages: durations.map((_, index) =>
lastIndex > 0 ? roundPercentage((index / lastIndex) * 100) : 0,
),
percentages: steps.map((duration) => {
cumulative += duration;
return roundPercentage((cumulative / totalDuration) * 100);
}),
totalDuration,
};
}

Expand All @@ -51,7 +43,8 @@ export function getCompatibleObjectArrayKeyframeTiming(
): ObjectArrayKeyframeTiming | null {
const timing = getObjectArrayKeyframeTiming(durations);
if (!timing) return null;
if (timing.totalDuration === undefined || outerDuration === undefined) return timing;
const stepsAuthorDuration = durations.some((duration) => duration !== undefined);
if (!stepsAuthorDuration || outerDuration === undefined) return timing;
if (
typeof outerDuration === "number" &&
Math.abs(outerDuration - timing.totalDuration) <= Number.EPSILON
Expand Down
78 changes: 66 additions & 12 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import type { GsapAnimation } from "./gsapParser.js";
import { syncPositionHoldsBeforeKeyframes as syncPositionHoldsBeforeKeyframesAcorn } from "./gsapWriterAcorn.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import type { Keyframe } from "./types.js";
import {
parseAndSerialize,
Expand Down Expand Up @@ -1840,6 +1841,60 @@ describe("keyframe mutations", () => {
expect(hold!.properties).toEqual({ x: -50, data: "hf-hold" });
});

// A step list starts from the element's value before the tween; its first step is an end value.
it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])("%s: holds nothing for a delayed step list that has no start keyframe", (_, sync) => {
for (const tween of [
`tl.to("#x", { keyframes: [{ x: 60 }, { x: 120 }] }, 1);`,
`tl.to("#x", { keyframes: { "50%": { x: 60 }, "100%": { x: 120 } }, duration: 1 }, 1);`,
`tl.fromTo("#x", { x: 10 }, { keyframes: [{ x: 60 }, { x: 120 }] }, 1);`,
]) {
expect(sync(`const tl = gsap.timeline({ paused: true });\n${tween}`)).not.toContain(
"hf-hold",
);
}
const partial =
`const tl = gsap.timeline({ paused: true });\n` +
`tl.to("#x", { keyframes: { "0%": { x: -50 }, "50%": { y: 30 }, "100%": { x: 60, y: 0 } }, duration: 1 }, 2);`;
const hold = parseGsapScript(sync(partial)).animations.find((a) => a.method === "set");
expect(hold!.properties).toEqual({ x: -50, data: "hf-hold" });
});

it.each([
["recast", parseGsapScript],
["acorn", parseGsapScriptAcorn],
])(
"%s: takes timeline defaults as GSAP 3.15 does, never onto a step list or a keyframed ease",
(_, parse) => {
const tweens = [
`tl.to("#a", { keyframes: [{ x: 1 }, { x: 2 }, { x: 3 }] }, 0);`,
`tl.to("#b", { keyframes: { "0%": { x: 0 }, "100%": { x: 1 } } }, 0);`,
`tl.to("#c", { motionPath: { path: [{ x: 0, y: 0 }, { x: 9, y: 9 }] } }, 0);`,
`tl.to("#d", { keyframes: { "0%": { x: 0 }, "100%": { x: 9 } }, motionPath: { path: [{ x: 0, y: 0 }, { x: 9, y: 9 }] } }, 0);`,
].join("\n");
const defaults = `{ defaults: { duration: 2, ease: "power2.in" } }`;
const [steps, percentages, motionPath, both] = parse(
`const tl = gsap.timeline(${defaults});\n${tweens}`,
).animations;
expect([steps!.duration, percentages!.duration, motionPath!.duration]).toEqual([1.5, 2, 2]);
expect([steps!.ease, percentages!.ease, motionPath!.ease, both!.ease]).toEqual([
undefined,
undefined,
"power2.in",
undefined,
]);
expect(parse(`const tl = gsap.timeline();\n${tweens}`).animations[0]!.duration).toBe(1.5);
},
);

it("acorn: a step list whose tween duration is a variable leaves its length unresolved", () => {
const script = `const tl = gsap.timeline();\ntl.to("#a", { keyframes: [{ x: 1, duration: 1 }, { x: 2, duration: 1 }], duration: D }, 0);`;
const anim = parseGsapScriptAcorn(script).animations[0]!;
expect([anim.duration, anim.durationUnresolved]).toEqual([undefined, true]);
});

it("acorn: a re-sync of a one-line script is byte-stable", () => {
const once = syncPositionHoldsBeforeKeyframesAcorn(
`const tl = gsap.timeline({ paused: true });tl.to("#t", { keyframes: { "0%": { x: -50 }, "100%": { x: 60 } }, duration: 1 }, 2);`,
Expand Down Expand Up @@ -1999,15 +2054,14 @@ describe("keyframe mutations", () => {
});

// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no percentages — GSAP
// distributes them evenly. The motion-path overlay drags/adds by percentage,
// which used to no-op on array-authored tweens (#puck-b / #shuttle).
// ends step i of n at (i+1)/n. The motion-path overlay drags/adds by percentage.
const ARRAY_KF_SCRIPT =
"const tl = gsap.timeline();\n" +
'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);';

it("updateKeyframeInScript — array-form: drags node 2 (pct 33.3) by index", () => {
it("updateKeyframeInScript — array-form: drags node 2 (pct 50) by index", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, { x: 503, y: 642 });
const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 50, { x: 503, y: 642 });
expect(updated).not.toBe(ARRAY_KF_SCRIPT);
const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes;
expect([kf[1]!.properties.x, kf[1]!.properties.y]).toEqual([503, 642]);
Expand All @@ -2017,26 +2071,26 @@ describe("keyframe mutations", () => {

it("updateKeyframeInScript — array-form ease-only update preserves existing properties", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, {}, "power2.inOut");
const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 50, {}, "power2.inOut");
const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes;
expect(kf[1]!.properties.x).toBe(520);
expect(kf[1]!.properties.y).toBe(120);
expect(kf[1]!.ease).toBe("power2.inOut");
});

it("addKeyframeToScript — array-form: normalizes to object form + inserts 50%", () => {
it("addKeyframeToScript — array-form: normalizes to object form + inserts 62.5%", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 50, { x: 780, y: 60 });
const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 62.5, { x: 780, y: 60 });
expect(updated).not.toBe(ARRAY_KF_SCRIPT);
const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes;
expect(kf.length).toBe(5);
const at50 = kf.find((k) => Math.abs(k.percentage - 50) < 1)!;
expect([at50.properties.x, at50.properties.y]).toEqual([780, 60]);
expect(kf.map((k) => k.percentage)).toEqual([25, 50, 62.5, 75, 100]);
const added = kf.find((k) => k.percentage === 62.5)!;
expect([added.properties.x, added.properties.y]).toEqual([780, 60]);
});

it("removeKeyframeFromScript — array-form: drops node 3 (pct 66.7)", () => {
it("removeKeyframeFromScript — array-form: drops node 3 (pct 75)", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = removeKeyframeFromScript(ARRAY_KF_SCRIPT, id, 66.7);
const updated = removeKeyframeFromScript(ARRAY_KF_SCRIPT, id, 75);
expect(updated).not.toBe(ARRAY_KF_SCRIPT);
const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes;
expect(kf.length).toBe(3);
Expand Down
15 changes: 9 additions & 6 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export {
import {
classifyPropertyGroup,
classifyTweenPropertyGroup,
GSAP_DEFAULT_DURATION,
isXYPositionWrite,
positionHoldForAnimation,
} from "./gsapConstants";
Expand Down Expand Up @@ -1085,7 +1086,7 @@ function tweenCallToAnimation(
percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
properties: { x: wp.x, y: wp.y },
}));
keyframesData = { format: "percentage", keyframes: kf };
keyframesData = { format: "percentage", keyframes: kf, fromMotionPath: true };
} else {
// Merge waypoint positions into existing keyframes at matching percentages.
// If keyframe count matches waypoint count, assign positionally.
Expand Down Expand Up @@ -1153,8 +1154,6 @@ function tweenCallToAnimation(

// ── Timeline Position Resolution ──────────────────────────────────────────

const GSAP_DEFAULT_DURATION = 0.5;

// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved —
// they fall through to parseFloat which returns null for non-numeric strings.
function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null {
Expand Down Expand Up @@ -1192,7 +1191,11 @@ function applyTimelineDefaults(
if (anim.duration === undefined && defaults.duration !== undefined) {
anim.duration = defaults.duration;
}
if (anim.ease === undefined && defaults.ease !== undefined) {
if (
anim.ease === undefined &&
defaults.ease !== undefined &&
(!anim.keyframes || anim.keyframes.fromMotionPath)
) {
anim.ease = defaults.ease;
}
}
Expand Down Expand Up @@ -2224,7 +2227,7 @@ function convertArrayKeyframesToObjectNode(varsArg: AstNode, scope: ScopeBinding
outerDuration,
);
if (!timing) return null;
if (timing.totalDuration !== undefined && findPropertyNode(varsArg, "duration") === undefined) {
if (findPropertyNode(varsArg, "duration") === undefined) {
setVarsKey(varsArg, "duration", timing.totalDuration);
}
const entries = els.map((el: AstNode, i: number) => {
Expand All @@ -2233,7 +2236,7 @@ function convertArrayKeyframesToObjectNode(varsArg: AstNode, scope: ScopeBinding
);
return `${JSON.stringify(`${timing.percentages[i]}%`)}: ${recast.print(el).code}`;
});
prop.value = parseExpr(`{ ${entries.join(", ")} }`);
prop.value = parseExpr(`{ ${entries.join(", ")}, easeEach: "none" }`);
return prop.value;
}

Expand Down
17 changes: 10 additions & 7 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
classifyTweenPropertyGroup,
DROPPED_VAR_KEYS,
EXTRAS_KEYS,
GSAP_DEFAULT_DURATION,
} from "./gsapConstants.js";
import { buildArcPath } from "./gsapSerialize.js";
import { inlineComputedTimelines, readProvenance } from "./gsapInline.js";
Expand Down Expand Up @@ -1320,7 +1321,7 @@ function tweenCallToAnimation(
percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
properties: { x: wp.x, y: wp.y },
}));
keyframesData = { format: "percentage", keyframes: kf };
keyframesData = { format: "percentage", keyframes: kf, fromMotionPath: true };
} else {
const kfs = keyframesData.keyframes;
if (kfs.length === waypoints.length) {
Expand Down Expand Up @@ -1360,11 +1361,11 @@ function tweenCallToAnimation(
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
const ease = typeof vars.ease === "string" ? vars.ease : undefined;

if (duration === undefined && keyframesData) {
duration = computeKeyframesTotalDuration(call.varsArg, scope, source);
}
const durationUnresolved =
call.method !== "set" && duration === undefined && hasUnknownDuration(call.varsArg, scope);
if (duration === undefined && keyframesData && !durationUnresolved) {
duration = computeKeyframesTotalDuration(call.varsArg, scope, source);
}

// Relabel object-proxy / empty-target tweens so they don't read as bare
// __unresolved__: a dwell/hold spacer or an onUpdate-driven DOM channel (#5/#11).
Expand Down Expand Up @@ -1509,8 +1510,6 @@ function annotateStaggeredCollections(anims: Omit<GsapAnimation, "id">[]): void

// ── Timeline position resolution ─────────────────────────────────────────────

const GSAP_DEFAULT_DURATION = 0.5;

// fallow-ignore-next-line complexity
function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null {
const trimmed = pos.trim();
Expand Down Expand Up @@ -1646,7 +1645,11 @@ function applyTimelineDefaults(
if (defaults.duration !== undefined) anim.duration = defaults.duration;
else if (defaults.durationUnresolved) anim.durationUnresolved = true;
}
if (anim.ease === undefined && defaults.ease !== undefined && !anim.keyframes) {
if (
anim.ease === undefined &&
defaults.ease !== undefined &&
(!anim.keyframes || anim.keyframes.fromMotionPath)
) {
anim.ease = defaults.ease;
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/parsers/src/gsapParserExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ export {
export { isStudioHoldSet } from "./gsapParser.js";
export type { PropertyGroupName } from "./gsapConstants.js";
export {
GSAP_DEFAULT_DURATION,
PROPERTY_GROUPS,
classifyPropertyGroup,
classifyTweenPropertyGroup,
} from "./gsapConstants.js";
export { getObjectArrayKeyframeTiming } from "./gsapObjectArrayTiming.js";
export { generateSpringEaseData, SPRING_PRESETS } from "./springEase.js";
export type { SpringPreset } from "./springEase.js";
export { parseGsapScriptAcorn as parseGsapScript } from "./gsapParserAcorn.js";
Expand Down
Loading
Loading