diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts
index 9d4a3c5244..fa06032dd5 100644
--- a/packages/parsers/src/gsapParser.ts
+++ b/packages/parsers/src/gsapParser.ts
@@ -2499,6 +2499,21 @@ export function updateKeyframeInScript(
return recast.print(arrLoc.parsed.ast).code;
}
+ // motionPath waypoints are exposed to Studio as synthetic keyframes, while
+ // their source representation owns one tween-level ease. Route an ease-only
+ // segment edit to that authored owner instead of silently no-oping because a
+ // literal keyframes block does not exist.
+ if (
+ arrLoc &&
+ !arrVal &&
+ ease !== undefined &&
+ Object.keys(properties).length === 0 &&
+ findPropertyNode(arrLoc.target.call.varsArg, "motionPath")
+ ) {
+ applyUpdatesToCall(arrLoc.target.call, { ease });
+ return recast.print(arrLoc.parsed.ast).code;
+ }
+
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode } = ctx;
diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts
index 856701a851..f9e3abaf10 100644
--- a/packages/parsers/src/gsapWriter.parity.test.ts
+++ b/packages/parsers/src/gsapWriter.parity.test.ts
@@ -1049,6 +1049,10 @@ 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 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);
+`;
describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
function expectParity(
@@ -1100,6 +1104,17 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
);
});
+ it("updates the authored tween ease for synthetic motion-path keyframes", () => {
+ const id = acornId(UPD_MOTION_PATH_SCRIPT);
+ const ease = "spring(0.42)";
+ const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
+ const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
+
+ expect(modelOf(acorn)).toEqual(modelOf(recast));
+ expect(parseGsapScript(acorn).animations[0]?.ease).toBe(ease);
+ expect(parseGsapScript(recast).animations[0]?.ease).toBe(ease);
+ });
+
// 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
diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts
index e41b8805f8..de9d6a676e 100644
--- a/packages/parsers/src/gsapWriterAcorn.ts
+++ b/packages/parsers/src/gsapWriterAcorn.ts
@@ -879,7 +879,21 @@ export function updateKeyframeInScript(
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
- if (!kfPropNode) return script;
+ if (!kfPropNode) {
+ // motionPath waypoints are exposed to Studio as synthetic keyframes, but
+ // GSAP authors one ease for the whole motionPath tween. An ease-only edit
+ // therefore belongs on the tween instead of a nonexistent keyframes block.
+ if (
+ ease !== undefined &&
+ Object.keys(properties).length === 0 &&
+ findPropertyNode(target.call.varsArg, "motionPath")
+ ) {
+ const ms = new MagicString(script);
+ upsertProp(ms, target.call.varsArg, "ease", ease);
+ return ms.toString();
+ }
+ return script;
+ }
// Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages
// — GSAP distributes them evenly, and the runtime read assigns even percentages
diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index d4f7f4d4ab..9f829a972c 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -1539,6 +1539,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
expect(result.after).not.toContain("data-hf-studio-rotation");
});
+ it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
+ const projectDir = createProjectDir();
+ const PATH_COMP = `
+
+
+`;
+ writeHtml(projectDir, "path.html", PATH_COMP);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const anim = await getFirstAnimation(app, "path.html");
+ const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ type: "replace-with-keyframes",
+ animationId: anim.id,
+ targetSelector: "#box",
+ position: 12.17,
+ duration: 16.055,
+ keyframes: [
+ { percentage: 0, properties: { x: 0, y: 0 } },
+ { percentage: 23.2, properties: { x: 25, y: 30 } },
+ { percentage: 100, properties: { x: 100, y: 100 } },
+ ],
+ ease: "none",
+ }),
+ });
+ const result = (await res.json()) as { ok: boolean; after: string };
+
+ expect(res.status).toBe(200);
+ expect(result.ok).toBe(true);
+ expect(result.after).toContain('"23.2%"');
+ expect(result.after).toContain('easeEach: "power1.inOut"');
+ expect(result.after).toContain('ease: "none"');
+ expect(result.after).not.toContain("motionPath");
+ });
+
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index fab9795378..1330588aa6 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -936,6 +936,7 @@ type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
+ easeEach?: string;
}
| {
type: "split-animations";
@@ -991,6 +992,18 @@ type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
+function resolveReplacementEaseEach(
+ scriptText: string,
+ request: { animationId: string; easeEach?: string },
+): string | undefined {
+ if (request.easeEach !== undefined) return request.easeEach;
+ const original = parseGsapScriptAcorn(scriptText).animations.find(
+ (animation) => animation.id === request.animationId,
+ );
+ if (!original?.arcPath?.enabled) return undefined;
+ return original?.keyframes?.easeEach ?? original?.ease;
+}
+
// Mutations that can change a position tween's first keyframe (value/existence/timing)
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
@@ -1396,6 +1409,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
+ resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
@@ -1750,6 +1764,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
+ body.easeEach,
);
return result.script;
}
@@ -1765,6 +1780,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
+ resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
diff --git a/packages/studio/src/hooks/gsapDragPositionCommit.ts b/packages/studio/src/hooks/gsapDragPositionCommit.ts
index ac1d760126..b941eabdaa 100644
--- a/packages/studio/src/hooks/gsapDragPositionCommit.ts
+++ b/packages/studio/src/hooks/gsapDragPositionCommit.ts
@@ -259,6 +259,54 @@ export async function commitGsapPositionFromDrag(
const backfillDefaults: Record = { x: baseGsapX, y: baseGsapY };
const ct = usePlayerStore.getState().currentTime;
+ if (anim.arcPath?.enabled) {
+ const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
+ const keyframes = anim.keyframes?.keyframes ?? [];
+ const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05);
+ if (pointIndex >= 0) {
+ await callbacks.commitMutation(
+ selection,
+ {
+ type: "update-motion-path-point",
+ animationId: anim.id,
+ pointIndex,
+ x: newX,
+ y: newY,
+ },
+ { label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
+ );
+ setActiveKeyframePct(null);
+ parkPlayheadOnKeyframe(anim, pct);
+ return;
+ }
+
+ const tweenStart = resolveTweenStart(anim);
+ const tweenDuration = resolveTweenDuration(anim);
+ if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
+ const temporalKeyframes = [
+ ...keyframes.map((kf) => ({
+ percentage: kf.percentage,
+ properties: { ...kf.properties },
+ ...(kf.ease ? { ease: kf.ease } : {}),
+ })),
+ { percentage: pct, properties: { x: newX, y: newY } },
+ ].sort((a, b) => a.percentage - b.percentage);
+ await callbacks.commitMutation(
+ selection,
+ {
+ type: "replace-with-keyframes",
+ animationId: anim.id,
+ targetSelector: anim.targetSelector,
+ position: roundTo3(tweenStart),
+ duration: roundTo3(tweenDuration),
+ keyframes: temporalKeyframes,
+ ease: "none",
+ },
+ { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
+ );
+ return;
+ }
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
index 7c78c05872..078f184c6c 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
expect(types).not.toContain("replace-with-keyframes");
});
});
+
+describe("tryGsapDragIntercept — motion paths", () => {
+ const motionPathAnim = {
+ id: "#puck-b-to-12170-position",
+ targetSelector: "#puck-b",
+ propertyGroup: "position",
+ method: "to",
+ position: 12.17,
+ resolvedStart: 12.17,
+ duration: 16.055,
+ ease: "power1.inOut",
+ properties: {},
+ keyframes: {
+ keyframes: [
+ { percentage: 0, properties: { x: -184, y: 326 } },
+ { percentage: 50, properties: { x: 416, y: 804 } },
+ { percentage: 100, properties: { x: 796, y: 237 } },
+ ],
+ },
+ arcPath: {
+ enabled: true,
+ autoRotate: false,
+ segments: [{ curviness: 1 }, { curviness: 1 }],
+ },
+ } as unknown as GsapAnimation;
+ const liveTween = {
+ targets: () => [{ id: "puck-b" }],
+ vars: { motionPath: { path: [] }, duration: 16.055 },
+ duration: () => 16.055,
+ startTime: () => 12.17,
+ };
+
+ async function dragMotionPath(activeKeyframePct: number | null) {
+ usePlayerStore.setState({
+ autoKeyframeEnabled: true,
+ activeKeyframePct,
+ currentTime: 15.9,
+ });
+ const commitMutation = vi.fn();
+ const handled = await tryGsapDragIntercept(
+ selection,
+ { x: -50, y: 30 },
+ [motionPathAnim],
+ fakeIframe("puck-b", [liveTween]),
+ commitMutation,
+ );
+ return { commitMutation, handled };
+ }
+
+ afterEach(() => {
+ usePlayerStore.setState({ activeKeyframePct: null });
+ });
+
+ it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
+ const { commitMutation, handled } = await dragMotionPath(null);
+
+ expect(handled).toBe(true);
+ expect(commitMutation).toHaveBeenCalledWith(
+ selection,
+ {
+ type: "replace-with-keyframes",
+ animationId: motionPathAnim.id,
+ targetSelector: "#puck-b",
+ position: 12.17,
+ duration: 16.055,
+ keyframes: [
+ { percentage: 0, properties: { x: -184, y: 326 } },
+ { percentage: 23.2, properties: { x: -50, y: 30 } },
+ { percentage: 50, properties: { x: 416, y: 804 } },
+ { percentage: 100, properties: { x: 796, y: 237 } },
+ ],
+ ease: "none",
+ },
+ expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
+ );
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
+ "add-motion-path-point",
+ );
+ });
+
+ it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
+ const { commitMutation, handled } = await dragMotionPath(50);
+
+ expect(handled).toBe(true);
+ expect(commitMutation).toHaveBeenCalledWith(
+ selection,
+ {
+ type: "update-motion-path-point",
+ animationId: motionPathAnim.id,
+ pointIndex: 1,
+ x: -50,
+ y: 30,
+ },
+ expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
+ );
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
+ "replace-with-keyframes",
+ );
+ });
+});
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
index 98c263f2ad..459ab37d6d 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
@@ -12,6 +12,7 @@ import {
import type { TimelineElement } from "../player/store/playerStore";
import { usePlayerStore } from "../player/store/playerStore";
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
+import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
afterEach(() => {
usePlayerStore.getState().reset();
@@ -316,4 +317,51 @@ describe("deleteSelectedKeyframes", () => {
expect(options[1]).not.toHaveProperty("softReload");
expect(options[2]).not.toHaveProperty("skipReload");
});
+
+ it("deletes two expanded lanes through their own animation and tween percentages", () => {
+ usePlayerStore.setState({
+ selectedElementId: "card",
+ selectedKeyframes: new Set([
+ timelineKeyframeSelectionKey("card", {
+ percentage: 30,
+ tweenPercentage: 20,
+ propertyGroup: "position",
+ animationId: "card-position",
+ }),
+ timelineKeyframeSelectionKey("card", {
+ percentage: 70,
+ tweenPercentage: 80,
+ propertyGroup: "visual",
+ animationId: "card-visual",
+ }),
+ ]),
+ });
+ const handleGsapRemoveKeyframe =
+ vi.fn<(animId: string, pct: number, options?: Partial) => void>();
+
+ deleteSelectedKeyframes({
+ selectedGsapAnimations: [
+ { id: "card-position", keyframes: {} },
+ { id: "card-visual", keyframes: {} },
+ ],
+ handleGsapRemoveKeyframe,
+ });
+
+ expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
+ expect(
+ handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
+ animationId,
+ percentage,
+ ]),
+ ).toEqual([
+ ["card-position", 20],
+ ["card-visual", 80],
+ ]);
+ expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
+ expect.objectContaining({ skipReload: true }),
+ );
+ expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
+ expect.objectContaining({ softReload: true }),
+ );
+ });
});
diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx
index fda24c9592..ab84bf915c 100644
--- a/packages/studio/src/hooks/useDomEditSession.test.tsx
+++ b/packages/studio/src/hooks/useDomEditSession.test.tsx
@@ -314,7 +314,7 @@ describe("bulk segment ease commits", () => {
gsapCommitMutation.mockClear();
gsapCommitMutation.batch.mockClear();
let updateSegmentEase:
- | ((animationIds: string[], tweenPct: number, ease: string) => void)
+ | ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void)
| undefined;
function Probe() {
@@ -362,17 +362,22 @@ describe("bulk segment ease commits", () => {
expect(updateSegmentEase).toBeTypeOf("function");
if (!updateSegmentEase) return;
const options = { label: "Update segment ease", softReload: true };
- updateSegmentEase(["move-x", "move-y", "fade"], 100, "power2.inOut");
+ const targets = [
+ { animationId: "move-x", tweenPercentage: 20 },
+ { animationId: "move-y", tweenPercentage: 50 },
+ { animationId: "fade", tweenPercentage: 80 },
+ ];
+ updateSegmentEase(targets, "power2.inOut");
expect(gsapCommitMutation).not.toHaveBeenCalled();
expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1);
expect(gsapCommitMutation.batch).toHaveBeenCalledWith(
- ["move-x", "move-y", "fade"].map((animationId) => ({
+ targets.map(({ animationId, tweenPercentage }) => ({
selection,
mutation: {
type: "update-keyframe",
animationId,
- percentage: 100,
+ percentage: tweenPercentage,
properties: {},
ease: "power2.inOut",
},
@@ -383,7 +388,7 @@ describe("bulk segment ease commits", () => {
gsapCommitMutation.mockClear();
gsapCommitMutation.batch.mockClear();
- updateSegmentEase(["fade"], 25, "none");
+ updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none");
expect(gsapCommitMutation).toHaveBeenCalledTimes(1);
expect(gsapCommitMutation).toHaveBeenCalledWith(
selection,
@@ -399,7 +404,7 @@ describe("bulk segment ease commits", () => {
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
gsapCommitMutation.mockClear();
- updateSegmentEase([], 50, "linear");
+ updateSegmentEase([], "linear");
expect(gsapCommitMutation).not.toHaveBeenCalled();
expect(gsapCommitMutation.batch).not.toHaveBeenCalled();
} finally {
diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts
new file mode 100644
index 0000000000..5b774715ae
--- /dev/null
+++ b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import {
+ timelineKeyframeSelectionKey,
+ timelineKeyframeTargetFromSelectionKey,
+} from "./timelineKeyframeIdentity";
+
+describe("timeline keyframe selection identity", () => {
+ it("round-trips an expanded lane with colon-bearing identities", () => {
+ const key = timelineKeyframeSelectionKey("comp#a:child", {
+ percentage: 75,
+ tweenPercentage: 40,
+ propertyGroup: "position",
+ animationId: "child:position",
+ });
+
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({
+ percentage: 75,
+ tweenPercentage: 40,
+ propertyGroup: "position",
+ animationId: "child:position",
+ });
+ });
+
+ it("does not confuse an expanded lane whose element id extends the active id", () => {
+ const key = timelineKeyframeSelectionKey("comp#a:child", {
+ percentage: 75,
+ tweenPercentage: 40,
+ propertyGroup: "position",
+ animationId: "child-position",
+ });
+
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull();
+ });
+
+ it("retains the collapsed key fallback and rejects malformed percentages", () => {
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({
+ percentage: 30,
+ });
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull();
+ expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull();
+ });
+});