Skip to content

Commit 041614d

Browse files
Merge pull request #2689 from heygen-com/codex/studio-timeline-b-interaction-hardening-v2
fix(studio): harden keyframe editing semantics
2 parents b946560 + 50b6822 commit 041614d

42 files changed

Lines changed: 1327 additions & 390 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/studio-server/src/routes/files.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
18301830
expect(result.after).not.toContain("data-hf-studio-rotation");
18311831
});
18321832

1833+
it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => {
1834+
const projectDir = createProjectDir();
1835+
const PATH_COMP = `<!DOCTYPE html><html><body data-duration="32">
1836+
<div id="box"></div>
1837+
<script data-hyperframes-gsap>
1838+
const tl = gsap.timeline();
1839+
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, duration: 16.055, ease: "power1.inOut" }, 12.17);
1840+
</script>
1841+
</body></html>`;
1842+
writeHtml(projectDir, "path.html", PATH_COMP);
1843+
const app = new Hono();
1844+
registerFileRoutes(app, createAdapter(projectDir));
1845+
1846+
const anim = await getFirstAnimation(app, "path.html");
1847+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", {
1848+
method: "POST",
1849+
headers: { "Content-Type": "application/json" },
1850+
body: JSON.stringify({
1851+
type: "replace-with-keyframes",
1852+
animationId: anim.id,
1853+
targetSelector: "#box",
1854+
position: 12.17,
1855+
duration: 16.055,
1856+
keyframes: [
1857+
{ percentage: 0, properties: { x: 0, y: 0 } },
1858+
{ percentage: 23.2, properties: { x: 25, y: 30 } },
1859+
{ percentage: 100, properties: { x: 100, y: 100 } },
1860+
],
1861+
ease: "none",
1862+
}),
1863+
});
1864+
const result = (await res.json()) as { ok: boolean; after: string };
1865+
1866+
expect(res.status).toBe(200);
1867+
expect(result.ok).toBe(true);
1868+
expect(result.after).toContain('"23.2%"');
1869+
expect(result.after).toContain('easeEach: "power1.inOut"');
1870+
expect(result.after).toContain('ease: "none"');
1871+
expect(result.after).not.toContain("motionPath");
1872+
});
1873+
18331874
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
18341875
const projectDir = createProjectDir();
18351876
writeComp(projectDir, "scene.html", TEMPLATE_COMP);

packages/studio-server/src/routes/files.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,7 @@ export type GsapMutationRequest =
997997
auto?: boolean;
998998
}>;
999999
ease?: string;
1000+
easeEach?: string;
10001001
}
10011002
| {
10021003
type: "split-animations";
@@ -1052,6 +1053,18 @@ export type GsapMutationRequest =
10521053

10531054
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
10541055

1056+
function resolveReplacementEaseEach(
1057+
scriptText: string,
1058+
request: { animationId: string; easeEach?: string },
1059+
): string | undefined {
1060+
if (request.easeEach !== undefined) return request.easeEach;
1061+
const original = parseGsapScriptAcorn(scriptText).animations.find(
1062+
(animation) => animation.id === request.animationId,
1063+
);
1064+
if (!original?.arcPath?.enabled) return undefined;
1065+
return original?.keyframes?.easeEach ?? original?.ease;
1066+
}
1067+
10551068
// Mutations that can change a position tween's first keyframe (value/existence/timing)
10561069
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
10571070
// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
@@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn(
15071520
body.duration,
15081521
body.keyframes,
15091522
body.ease,
1523+
resolveReplacementEaseEach(block.scriptText, body),
15101524
);
15111525
return added.script;
15121526
}
@@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast(
18771891
body.duration,
18781892
body.keyframes,
18791893
body.ease,
1894+
resolveReplacementEaseEach(block.scriptText, body),
18801895
);
18811896
return added.script;
18821897
}

packages/studio/src/components/TimelineToolbar.test.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import React, { act } from "react";
44
import { createRoot } from "react-dom/client";
5-
import { afterEach, describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
67
import { usePlayerStore } from "../player/store/playerStore";
8+
import { makeSelection } from "../hooks/domSelectionTestHarness";
79
import { TimelineToolbar } from "./TimelineToolbar";
810

911
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -13,12 +15,14 @@ afterEach(() => {
1315
usePlayerStore.setState({ autoKeyframeEnabled: true });
1416
});
1517

16-
function renderToolbar() {
18+
function renderToolbar(
19+
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
20+
) {
1721
const host = document.createElement("div");
1822
document.body.append(host);
1923
const root = createRoot(host);
2024
act(() => {
21-
root.render(<TimelineToolbar />);
25+
root.render(<TimelineToolbar domEditSession={domEditSession} />);
2226
});
2327
return { host, root };
2428
}
@@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
5458
act(() => root.unmount());
5559
});
5660
});
61+
describe("TimelineToolbar — motion path endpoints", () => {
62+
it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
63+
usePlayerStore.setState({ currentTime: 10 });
64+
const animation: GsapAnimation = {
65+
id: "#el-to-0-position",
66+
targetSelector: "#el",
67+
method: "to",
68+
position: 0,
69+
duration: 10,
70+
properties: {},
71+
keyframes: {
72+
format: "object-array",
73+
keyframes: [
74+
{ percentage: 0, properties: { x: 0, y: 0 } },
75+
{ percentage: 100, properties: { x: 100, y: 0 } },
76+
],
77+
},
78+
arcPath: {
79+
enabled: true,
80+
autoRotate: false,
81+
segments: [{ curviness: 1 }],
82+
},
83+
};
84+
const element = document.createElement("div");
85+
element.id = "el";
86+
const session = {
87+
domEditSelection: makeSelection("Element", element),
88+
selectedGsapAnimations: [animation],
89+
handleGsapAddAnimation: vi.fn(),
90+
handleGsapConvertToKeyframes: vi.fn(),
91+
handleGsapRemoveKeyframe: vi.fn(),
92+
} satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
93+
94+
const { host, root } = renderToolbar(session);
95+
const button = host.querySelector<HTMLButtonElement>(
96+
'button[aria-label="Motion path endpoint"]',
97+
);
98+
expect(button?.disabled).toBe(true);
99+
act(() => root.unmount());
100+
});
101+
});

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,60 @@ interface TimelineToolbarProps {
3636
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
3737
}
3838

39+
interface KeyframeToggleState {
40+
state: "active" | "inactive" | "none";
41+
isMotionPath: boolean;
42+
pathEndpoint: boolean;
43+
willExtend: boolean;
44+
}
45+
46+
const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
47+
state: "none",
48+
isMotionPath: false,
49+
pathEndpoint: false,
50+
willExtend: false,
51+
};
52+
53+
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
54+
if (!animation?.keyframes) return false;
55+
const keyframes = animation.keyframes.keyframes;
56+
return (
57+
Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= 1 ||
58+
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= 1
59+
);
60+
}
61+
62+
function resolveKeyframeToggleState(
63+
session: DomEditSessionSlice | undefined,
64+
currentTime: number,
65+
): KeyframeToggleState {
66+
if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
67+
const arcAnimation = session.selectedGsapAnimations.find(
68+
(animation) => animation.arcPath && animation.keyframes,
69+
);
70+
const animation =
71+
arcAnimation ??
72+
session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
73+
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
74+
75+
const isMotionPath = Boolean(arcAnimation);
76+
if (!isPlayheadWithinTween(animation, currentTime)) {
77+
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
78+
}
79+
80+
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
81+
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
82+
const active = animation.keyframes.keyframes.some(
83+
(keyframe) => Math.abs(keyframe.percentage - percentage) <= 1,
84+
);
85+
return {
86+
state: pathEndpoint ? "none" : active ? "active" : "inactive",
87+
isMotionPath,
88+
pathEndpoint,
89+
willExtend: false,
90+
};
91+
}
92+
3993
function useKeyframeToggle(session?: DomEditSessionSlice) {
4094
const currentTime = usePlayerStore((s) => s.currentTime);
4195
const sessionRef = useRef(session);
@@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
4599
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
46100
);
47101

48-
if (!session) return { state: "none" as const, onToggle: undefined };
49-
50-
const sel = session.domEditSelection;
51-
const anims = session.selectedGsapAnimations;
52-
const kfAnim = anims.find((a) => a.keyframes);
53-
54-
let state: "active" | "inactive" | "none" = "none";
55-
// Outside the tween, clicking extends the animation to the playhead rather than
56-
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
57-
let willExtend = false;
58-
if (kfAnim?.keyframes && sel) {
59-
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
60-
state = "inactive";
61-
willExtend = true;
62-
} else {
63-
// Tween-relative percentage (not the clip range) so the button state matches
64-
// where the keyframe would actually land.
65-
const pct = computeElementPercentage(currentTime, sel, kfAnim);
66-
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
67-
? "active"
68-
: "inactive";
69-
}
70-
}
102+
const toggleState = resolveKeyframeToggleState(session, currentTime);
71103

72-
return { state, willExtend, onToggle: sel ? onToggle : undefined };
104+
return {
105+
...toggleState,
106+
onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined,
107+
};
73108
}
74109

75110
// fallow-ignore-next-line complexity
@@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
91126
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
92127
const {
93128
state: keyframeState,
129+
isMotionPath: keyframeIsMotionPath,
130+
pathEndpoint: keyframePathEndpoint,
94131
willExtend: keyframeWillExtend,
95132
onToggle: onToggleKeyframe,
96133
} = useKeyframeToggle(domEditSession);
@@ -180,25 +217,41 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
180217
// toolbar layout never shifts.
181218
<Tooltip
182219
label={
183-
!onToggleKeyframe
184-
? "Select an animated element to add keyframes"
185-
: keyframeState === "active"
186-
? "Remove keyframe at playhead (K)"
187-
: keyframeState === "inactive"
220+
keyframePathEndpoint
221+
? "Motion path endpoints cannot be removed"
222+
: !onToggleKeyframe
223+
? "Select an animated element to add keyframes"
224+
: keyframeIsMotionPath
188225
? keyframeWillExtend
189-
? "Add keyframe at playhead, extends animation (K)"
190-
: "Add keyframe at playhead (K)"
191-
: "Add keyframe (K)"
226+
? "Extend motion path to playhead (K)"
227+
: keyframeState === "active"
228+
? "Remove waypoint from motion path (K)"
229+
: "Add waypoint to motion path (K)"
230+
: keyframeState === "active"
231+
? "Remove keyframe at playhead (K)"
232+
: keyframeState === "inactive"
233+
? keyframeWillExtend
234+
? "Add keyframe at playhead, extends animation (K)"
235+
: "Add keyframe at playhead (K)"
236+
: "Add keyframe (K)"
192237
}
193238
>
194239
<button
195240
type="button"
196241
disabled={!onToggleKeyframe}
197242
onClick={onToggleKeyframe}
198243
aria-label={
199-
keyframeState === "active"
200-
? "Remove keyframe at playhead"
201-
: "Add keyframe at playhead"
244+
keyframePathEndpoint
245+
? "Motion path endpoint"
246+
: keyframeIsMotionPath
247+
? keyframeState === "active"
248+
? "Remove motion path waypoint"
249+
: keyframeWillExtend
250+
? "Extend motion path to playhead"
251+
: "Add motion path waypoint"
252+
: keyframeState === "active"
253+
? "Remove keyframe at playhead"
254+
: "Add keyframe at playhead"
202255
}
203256
className={
204257
!onToggleKeyframe

packages/studio/src/components/editor/MotionPathOverlay.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
8888
// The keyframe % selected by clicking its node — highlighted, and the next drag
8989
// modifies it rather than adding a keyframe.
9090
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
91+
const timelineElement = usePlayerStore((state) => {
92+
if (!selection) return undefined;
93+
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
94+
return state.elements.find(
95+
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
96+
);
97+
});
9198
// Set-destination mode is armed from the preview toolbar (replaces the old
9299
// double-click-on-canvas UX). See createMode effects below.
93100
const armed = usePlayerStore((s) => s.motionPathArmed);
@@ -418,12 +425,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
418425
// Right-click a keyframe node → the timeline's keyframe context menu (delete
419426
// this keyframe / delete all), so motion-path keyframes are removable in place.
420427
const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
421-
if (ref.type !== "keyframe" || !animId || !elementId) return;
428+
if (ref.type !== "keyframe" || !animId || !elementId || !timelineElement) return;
422429
e.preventDefault();
423430
e.stopPropagation();
424431
setKfMenu({
425432
x: e.clientX,
426433
y: e.clientY,
434+
element: timelineElement,
427435
elementId,
428436
percentage: ref.pct,
429437
tweenPercentage: ref.pct,

0 commit comments

Comments
 (0)