Skip to content
Draft
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
15 changes: 15 additions & 0 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!DOCTYPE html><html><body data-duration="32">
<div id="box"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, duration: 16.055, ease: "power1.inOut" }, 12.17);
</script>
</body></html>`;
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);
Expand Down
16 changes: 16 additions & 0 deletions packages/studio-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
}
| {
type: "split-animations";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1396,6 +1409,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
Expand Down Expand Up @@ -1750,6 +1764,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
body.easeEach,
);
return result.script;
}
Expand All @@ -1765,6 +1780,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
resolveReplacementEaseEach(block.scriptText, body),
);
return added.script;
}
Expand Down
48 changes: 48 additions & 0 deletions packages/studio/src/hooks/gsapDragPositionCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,54 @@ export async function commitGsapPositionFromDrag(

const backfillDefaults: Record<string, number> = { 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;
Expand Down
100 changes: 100 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
48 changes: 48 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<CommitMutationOptions>) => 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 }),
);
});
});
Loading
Loading