@hyperframes/studio 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,9 +45,9 @@
45
45
  "dompurify": "^3.2.4",
46
46
  "marked": "^14.1.4",
47
47
  "mediabunny": "^1.45.3",
48
- "@hyperframes/player": "0.7.5",
49
- "@hyperframes/sdk": "0.7.5",
50
- "@hyperframes/core": "0.7.5"
48
+ "@hyperframes/core": "0.7.7",
49
+ "@hyperframes/sdk": "0.7.7",
50
+ "@hyperframes/player": "0.7.7"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/react": "19",
@@ -62,7 +62,7 @@
62
62
  "vite": "^6.4.2",
63
63
  "vitest": "^3.2.4",
64
64
  "zustand": "^5.0.0",
65
- "@hyperframes/producer": "0.7.5"
65
+ "@hyperframes/producer": "0.7.7"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "react": "19",
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { trackEvent } from "../../telemetry/client";
4
5
  import type { CaptionStyle } from "../types";
5
6
 
6
7
  interface CaptionOverrideEntry {
@@ -78,7 +79,11 @@ export function useCaptionSync(projectId: string | null) {
78
79
  method: "PUT",
79
80
  headers: { "Content-Type": "text/plain" },
80
81
  body: JSON.stringify(overrides, null, 2),
81
- }).catch((err) => console.warn("[captions] auto-save failed:", err));
82
+ }).catch((error: unknown) => {
83
+ // Caption auto-save is a data-loss path; surface failures via telemetry
84
+ // so a silently-dropped edit isn't invisible (no console in studio).
85
+ trackEvent("studio_caption_autosave_failed", { error: String(error) });
86
+ });
82
87
  }, []);
83
88
 
84
89
  // Auto-save on model changes with 800ms debounce
@@ -17,7 +17,7 @@ import {
17
17
  STUDIO_PREVIEW_SELECTION_ENABLED,
18
18
  } from "./editor/manualEditingAvailability";
19
19
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
20
- import { useDomEditContext } from "../contexts/DomEditContext";
20
+ import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
21
21
  import { TimelineEditProvider } from "../contexts/TimelineEditContext";
22
22
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
23
23
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
@@ -117,6 +117,9 @@ export function StudioPreviewArea({
117
117
  domEditHoverSelection,
118
118
  domEditSelection,
119
119
  domEditGroupSelections,
120
+ selectedGsapAnimations,
121
+ } = useDomEditSelectionContext();
122
+ const {
120
123
  handleTimelineElementSelect,
121
124
  handlePreviewCanvasMouseDown,
122
125
  handlePreviewCanvasPointerMove,
@@ -128,15 +131,16 @@ export function StudioPreviewArea({
128
131
  handleDomGroupPathOffsetCommit,
129
132
  handleDomBoxSizeCommit,
130
133
  handleDomRotationCommit,
131
- selectedGsapAnimations,
132
134
  handleGsapRemoveKeyframe,
133
135
  handleGsapUpdateMeta,
134
136
  handleGsapAddKeyframe,
135
137
  handleGsapConvertToKeyframes,
136
138
  handleGsapDeleteAllForElement,
137
139
  buildDomSelectionForTimelineElement,
138
- } = useDomEditContext();
140
+ applyMarqueeSelection,
141
+ } = useDomEditActionsContext();
139
142
 
143
+ // fallow-ignore-next-line complexity
140
144
  const [snapPrefs, setSnapPrefs] = useState(() => {
141
145
  const p = readStudioUiPreferences();
142
146
  return {
@@ -160,6 +164,7 @@ export function StudioPreviewArea({
160
164
  const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
161
165
  handleGsapDeleteAllForElement(`#${rawId}`);
162
166
  },
167
+ // fallow-ignore-next-line complexity
163
168
  onDeleteKeyframe: (_elId: string, pct: number) => {
164
169
  const cacheKey = domEditSelection?.id ?? "";
165
170
  const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
@@ -215,6 +220,7 @@ export function StudioPreviewArea({
215
220
  }
216
221
  }
217
222
  },
223
+ // fallow-ignore-next-line complexity
218
224
  onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
219
225
  const currentTime = usePlayerStore.getState().currentTime;
220
226
  const pct =
@@ -339,6 +345,7 @@ export function StudioPreviewArea({
339
345
  gridSpacing={snapPrefs.gridSpacing}
340
346
  recordingState={recordingState}
341
347
  onToggleRecording={onToggleRecording}
348
+ onMarqueeSelect={applyMarqueeSelection}
342
349
  />
343
350
  <SnapToolbar onSnapChange={setSnapPrefs} />
344
351
  {STUDIO_KEYFRAMES_ENABLED && (
@@ -121,6 +121,8 @@ export function StudioRightPanel({
121
121
  handleSetArcPath,
122
122
  handleUpdateArcSegment,
123
123
  handleUnroll,
124
+ handleUpdateKeyframeEase,
125
+ handleSetAllKeyframeEases,
124
126
  handleGsapAddKeyframe,
125
127
  handleGsapRemoveKeyframe,
126
128
  handleGsapConvertToKeyframes,
@@ -274,6 +276,8 @@ export function StudioRightPanel({
274
276
  onSetArcPath={handleSetArcPath}
275
277
  onUpdateArcSegment={handleUpdateArcSegment}
276
278
  onUnroll={handleUnroll}
279
+ onUpdateKeyframeEase={handleUpdateKeyframeEase}
280
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
277
281
  recordingState={recordingState}
278
282
  recordingDuration={recordingDuration}
279
283
  onToggleRecording={onToggleRecording}
@@ -4,237 +4,19 @@ import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constan
4
4
  import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
5
5
  import { MetricField, SelectField } from "./propertyPanelPrimitives";
6
6
  import { controlPointsForGsapEase } from "./studioMotion";
7
- import {
8
- EASE_LABELS,
9
- METHOD_LABELS,
10
- METHOD_TOOLTIPS,
11
- PERCENT_PROPS,
12
- PROP_CONSTRAINTS,
13
- PROP_LABELS,
14
- PROP_TOOLTIPS,
15
- PROP_UNITS,
16
- clampPropertyValue,
17
- } from "./gsapAnimationConstants";
7
+ import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants";
18
8
  import { buildTweenSummary } from "./gsapAnimationHelpers";
19
9
  import { EaseCurveSection } from "./EaseCurveSection";
20
10
  import { ArcPathControls } from "./ArcPathControls";
21
11
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
22
12
  import { ComputedTweenNotice } from "./ComputedTweenNotice";
23
- import { P } from "./panelTokens";
24
- const BOOLEAN_PROPS = new Set(["visibility"]);
25
- const STRING_PROPS = new Set(["filter", "clipPath"]);
26
-
27
- const FILTER_PRESETS = [
28
- { label: "Blur", value: "blur(4px)" },
29
- { label: "Bright", value: "brightness(1.5)" },
30
- { label: "Gray", value: "grayscale(1)" },
31
- { label: "None", value: "none" },
32
- ];
33
-
34
- const CLIP_PATH_PRESETS = [
35
- { label: "Circle", value: "circle(50% at 50% 50%)" },
36
- { label: "Inset", value: "inset(10%)" },
37
- { label: "None", value: "none" },
38
- ];
39
-
40
- function isPercentProp(prop: string): boolean {
41
- return PERCENT_PROPS.has(prop);
42
- }
43
-
44
- function displayValue(prop: string, val: number | string): string {
45
- if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
46
- return String(val);
47
- }
48
-
49
- function adjustedValue(prop: string, raw: string): string {
50
- if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
51
- const num = Number(raw);
52
- if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
53
- return String(clampPropertyValue(prop, num));
54
- }
55
- return raw;
56
- }
57
-
58
- function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
59
- return (
60
- <button
61
- type="button"
62
- onClick={onClick}
63
- className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
64
- title={title}
65
- >
66
- <svg
67
- width="12"
68
- height="12"
69
- viewBox="0 0 12 12"
70
- fill="none"
71
- stroke="currentColor"
72
- strokeWidth="1.5"
73
- >
74
- <path d="M3 3l6 6M9 3l-6 6" />
75
- </svg>
76
- </button>
77
- );
78
- }
79
-
80
- function PropertyRow({
81
- prop,
82
- val,
83
- onCommit,
84
- onRemove,
85
- removeTitle,
86
- }: {
87
- prop: string;
88
- val: number | string;
89
- onCommit: (adjusted: string) => void;
90
- onRemove: () => void;
91
- removeTitle: string;
92
- }) {
93
- if (BOOLEAN_PROPS.has(prop)) {
94
- const isVisible = val === "visible" || val === 1;
95
- return (
96
- <div className="flex items-center gap-1">
97
- <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
98
- <span className="flex-1 text-[11px] font-medium text-neutral-500">
99
- {PROP_LABELS[prop] ?? prop}
100
- </span>
101
- <button
102
- type="button"
103
- onClick={() => onCommit(isVisible ? "hidden" : "visible")}
104
- className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
105
- style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
106
- title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
107
- >
108
- <span
109
- className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
110
- style={{
111
- width: 12,
112
- height: 12,
113
- background: isVisible ? P.white : P.textMuted,
114
- transform: isVisible ? "translateX(14px)" : "translateX(2px)",
115
- }}
116
- />
117
- </button>
118
- </div>
119
- <RemoveButton onClick={onRemove} title={removeTitle} />
120
- </div>
121
- );
122
- }
123
-
124
- if (STRING_PROPS.has(prop)) {
125
- const presets =
126
- prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
127
- return (
128
- <div className="flex flex-col gap-1">
129
- <div className="flex items-center gap-1">
130
- <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
131
- <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
132
- {PROP_LABELS[prop] ?? prop}
133
- </span>
134
- <input
135
- type="text"
136
- defaultValue={String(val)}
137
- className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
138
- onBlur={(e) => onCommit(e.currentTarget.value)}
139
- onKeyDown={(e) => {
140
- if (e.key === "Enter") {
141
- e.currentTarget.blur();
142
- }
143
- }}
144
- />
145
- </div>
146
- <RemoveButton onClick={onRemove} title={removeTitle} />
147
- </div>
148
- {presets.length > 0 && (
149
- <div className="flex gap-1 pl-1">
150
- {presets.map((p) => (
151
- <button
152
- key={p.value}
153
- type="button"
154
- onClick={() => onCommit(p.value)}
155
- className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
156
- >
157
- {p.label}
158
- </button>
159
- ))}
160
- </div>
161
- )}
162
- </div>
163
- );
164
- }
165
-
166
- return (
167
- <div className="flex items-center gap-1">
168
- <div className="min-w-0 flex-1">
169
- <MetricField
170
- label={PROP_LABELS[prop] ?? prop}
171
- value={displayValue(prop, val)}
172
- suffix={PROP_UNITS[prop]}
173
- tooltip={PROP_TOOLTIPS[prop]}
174
- scrub
175
- liveCommit
176
- onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
177
- />
178
- </div>
179
- <RemoveButton onClick={onRemove} title={removeTitle} />
180
- </div>
181
- );
182
- }
183
-
184
- function AddPropertyTrigger({
185
- adding,
186
- available,
187
- addLabel,
188
- addTitle,
189
- onAdd,
190
- onOpen,
191
- onClose,
192
- buttonClassName,
193
- }: {
194
- adding: boolean;
195
- available: string[];
196
- addLabel: string;
197
- addTitle: string;
198
- onAdd: (prop: string) => void;
199
- onOpen: () => void;
200
- onClose: () => void;
201
- buttonClassName: string;
202
- }) {
203
- if (adding && available.length > 0) {
204
- return (
205
- <select
206
- autoFocus
207
- className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
208
- defaultValue=""
209
- onChange={(e) => {
210
- if (e.target.value) onAdd(e.target.value);
211
- onClose();
212
- }}
213
- onBlur={onClose}
214
- >
215
- <option value="" disabled>
216
- Choose property…
217
- </option>
218
- {available.map((p) => (
219
- <option key={p} value={p}>
220
- {PROP_LABELS[p] ?? p}
221
- </option>
222
- ))}
223
- </select>
224
- );
225
- }
226
- if (available.length === 0) return null;
227
- return (
228
- <button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
229
- {addLabel}
230
- </button>
231
- );
232
- }
233
-
234
- function parseNumericOrString(raw: string): number | string {
235
- const num = Number(raw);
236
- return Number.isFinite(num) ? num : raw;
237
- }
13
+ import { KeyframeEaseList } from "./KeyframeEaseList";
14
+ import {
15
+ PropertyRow,
16
+ AddPropertyTrigger,
17
+ parseNumericOrString,
18
+ BOOLEAN_PROPS,
19
+ } from "./AnimationCardParts";
238
20
 
239
21
  interface AnimationCardProps extends GsapAnimationEditCallbacks {
240
22
  animation: GsapAnimation;
@@ -257,11 +39,14 @@ export const AnimationCard = memo(function AnimationCard({
257
39
  onLivePreviewEnd,
258
40
  onSetArcPath,
259
41
  onUpdateArcSegment,
42
+ onUpdateKeyframeEase,
43
+ onSetAllKeyframeEases,
260
44
  onUnroll,
261
45
  }: AnimationCardProps) {
262
46
  const [expanded, setExpanded] = useState(defaultExpanded);
263
47
  const [addingProp, setAddingProp] = useState(false);
264
48
  const [addingFromProp, setAddingFromProp] = useState(false);
49
+ const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
265
50
 
266
51
  const usedProps = useMemo(
267
52
  () => new Set(Object.keys(animation.properties)),
@@ -330,7 +115,8 @@ export const AnimationCard = memo(function AnimationCard({
330
115
  const [copied, setCopied] = useState(false);
331
116
 
332
117
  const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
333
- const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
118
+ const easeName =
119
+ (animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
334
120
  const easeLabel = easeName.startsWith("custom(")
335
121
  ? "Custom curve"
336
122
  : (EASE_LABELS[easeName] ?? easeName);
@@ -340,6 +126,28 @@ export const AnimationCard = memo(function AnimationCard({
340
126
  : animation.position;
341
127
 
342
128
  const summary = useMemo(() => buildTweenSummary(animation), [animation]);
129
+ const setKeys = Object.keys(animation.properties);
130
+ if (
131
+ animation.method === "set" &&
132
+ // `every` is vacuously true on an empty bag — require at least one key so a
133
+ // property-less set doesn't masquerade as a position row.
134
+ (setKeys.includes("x") || setKeys.includes("y")) &&
135
+ setKeys.every((k) => k === "x" || k === "y" || k === "immediateRender")
136
+ )
137
+ return (
138
+ <div className="border-b border-neutral-800 pb-2">
139
+ <div className="flex items-center gap-2 py-1.5">
140
+ <span className="rounded bg-neutral-800 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400">
141
+ Position
142
+ </span>
143
+ <span className="text-[11px] text-neutral-500">
144
+ x: {Math.round(Number(animation.properties.x ?? 0))}, y:{" "}
145
+ {Math.round(Number(animation.properties.y ?? 0))}
146
+ </span>
147
+ <span className="ml-auto text-[9px] text-neutral-600">drag to move</span>
148
+ </div>
149
+ </div>
150
+ );
343
151
 
344
152
  return (
345
153
  <div className="border-b border-neutral-800 pb-3">
@@ -393,7 +201,7 @@ export const AnimationCard = memo(function AnimationCard({
393
201
  clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
394
202
  }}
395
203
  />
396
- Keyframed — edit values in the Layout panel above
204
+ Keyframed — click a segment below to edit its curve
397
205
  </p>
398
206
  )}
399
207
  </div>
@@ -435,29 +243,48 @@ export const AnimationCard = memo(function AnimationCard({
435
243
 
436
244
  {animation.method !== "set" && (
437
245
  <>
438
- <SelectField
439
- label="Speed"
440
- value={easeName.startsWith("custom(") ? "custom" : easeName}
441
- options={[...SUPPORTED_EASES, "custom"]}
442
- onChange={(next) => {
443
- if (next === "custom") {
444
- const points = controlPointsForGsapEase(
445
- easeName !== "none" ? easeName : "power2.out",
446
- );
447
- const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
448
- onUpdateMeta(animation.id, { ease: `custom(${path})` });
449
- } else {
450
- onUpdateMeta(animation.id, { ease: next });
246
+ {animation.keyframes && onUpdateKeyframeEase ? (
247
+ <KeyframeEaseList
248
+ keyframes={animation.keyframes.keyframes}
249
+ globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
250
+ expandedPct={expandedKfPct}
251
+ onToggle={setExpandedKfPct}
252
+ onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
253
+ onApplyAll={
254
+ onSetAllKeyframeEases
255
+ ? (ease) => onSetAllKeyframeEases(animation.id, ease)
256
+ : undefined
451
257
  }
452
- }}
453
- />
454
- <EaseCurveSection
455
- ease={easeName}
456
- duration={animation.duration}
457
- onCustomEaseCommit={(customEase) =>
458
- onUpdateMeta(animation.id, { ease: customEase })
459
- }
460
- />
258
+ />
259
+ ) : (
260
+ <>
261
+ <SelectField
262
+ label="Speed"
263
+ value={easeName.startsWith("custom(") ? "custom" : easeName}
264
+ options={[...SUPPORTED_EASES, "custom"]}
265
+ onChange={(next) => {
266
+ const easeKey = animation.keyframes ? "easeEach" : "ease";
267
+ if (next === "custom") {
268
+ const points = controlPointsForGsapEase(
269
+ easeName !== "none" ? easeName : "power2.out",
270
+ );
271
+ const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
272
+ onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
273
+ } else {
274
+ onUpdateMeta(animation.id, { [easeKey]: next });
275
+ }
276
+ }}
277
+ />
278
+ <EaseCurveSection
279
+ ease={easeName}
280
+ duration={animation.duration}
281
+ onCustomEaseCommit={(customEase) => {
282
+ const easeKey = animation.keyframes ? "easeEach" : "ease";
283
+ onUpdateMeta(animation.id, { [easeKey]: customEase });
284
+ }}
285
+ />
286
+ </>
287
+ )}
461
288
  </>
462
289
  )}
463
290