@hyperframes/studio 0.7.70 → 0.7.72

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 (62) hide show
  1. package/dist/assets/{hyperframes-player-DpKeYLEo.js → hyperframes-player-Bjf2HPzR.js} +1 -1
  2. package/dist/assets/index-BgZMle9I.css +1 -0
  3. package/dist/assets/{index-DXjAllII.js → index-Ch1hbJ3e.js} +1 -1
  4. package/dist/assets/{index-CbX-xoud.js → index-Ct_pxETK.js} +1 -1
  5. package/dist/assets/index-DCyWLHnx.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5383 -3804
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +74 -88
  22. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  23. package/src/components/editor/easeCurveSvg.tsx +65 -0
  24. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  25. package/src/components/editor/easePresetLibrary.ts +49 -0
  26. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  27. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +73 -40
  28. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +85 -66
  29. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  30. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  31. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  32. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  33. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  34. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  35. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +5 -5
  36. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  37. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  38. package/src/components/editor/propertyPanelTypes.ts +13 -0
  39. package/src/components/editor/useColorGradingController.test.ts +235 -30
  40. package/src/components/editor/useColorGradingController.ts +46 -88
  41. package/src/components/editor/useColorGradingPreviews.ts +307 -0
  42. package/src/components/nle/NLEContext.tsx +1 -1
  43. package/src/contexts/PanelLayoutContext.tsx +3 -6
  44. package/src/hooks/useBlockCatalog.ts +31 -13
  45. package/src/hooks/useBlockHandlers.ts +22 -0
  46. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  47. package/src/hooks/useGsapScriptCommits.ts +14 -1
  48. package/src/hooks/usePanelLayout.test.ts +68 -1
  49. package/src/hooks/usePanelLayout.ts +72 -34
  50. package/src/player/components/PlayerControls.tsx +68 -229
  51. package/src/player/components/ShortcutsPanel.tsx +2 -4
  52. package/src/player/components/SpeedMenu.tsx +1 -2
  53. package/src/telemetry/events.test.ts +9 -0
  54. package/src/telemetry/events.ts +5 -0
  55. package/src/utils/blockInstaller.test.ts +67 -0
  56. package/src/utils/blockInstaller.ts +162 -109
  57. package/src/utils/studioUiPreferences.test.ts +5 -0
  58. package/src/utils/studioUiPreferences.ts +8 -0
  59. package/dist/assets/index-CAeU317U.js +0 -428
  60. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  61. package/src/player/components/PlayerControls.test.ts +0 -20
  62. package/src/player/components/useSeekBarDrag.ts +0 -169
@@ -23,6 +23,15 @@ import {
23
23
  FlatColorGradingAccessory,
24
24
  FlatColorGradingSection,
25
25
  } from "./propertyPanelFlatColorGradingSection";
26
+ import {
27
+ activeColorGradingEffectCount,
28
+ FlatEffectsAccessory,
29
+ FlatEffectsSection,
30
+ } from "./propertyPanelFlatEffectsSection";
31
+ import {
32
+ deriveMediaOverlayPlacement,
33
+ FlatOverlaysSection,
34
+ } from "./propertyPanelFlatOverlaysSection";
26
35
 
27
36
  type EditingSections = ReturnType<typeof resolveEditingSections>;
28
37
 
@@ -34,11 +43,7 @@ type FlatGroupDescriptor = {
34
43
  content: ReactNode;
35
44
  };
36
45
 
37
- // Type-only fallback for the Motion effect-card callbacks. Used solely to
38
- // satisfy FlatMotionSection's required-callback shape when the effect list is
39
- // gated off (showEffects === false, so none of these are ever invoked). Keeps
40
- // the gated-off path free of `!` non-null assertions — the real, narrowed
41
- // handlers flow through only when the double-gate below passes.
46
+ // Required callback shape for the gated-off Motion effect list.
42
47
  const EMPTY_GSAP_EFFECT_HANDLERS = {
43
48
  onAddAnimation: () => {},
44
49
  onUpdateProperty: () => {},
@@ -48,15 +53,7 @@ const EMPTY_GSAP_EFFECT_HANDLERS = {
48
53
  onRemoveProperty: () => {},
49
54
  };
50
55
 
51
- /**
52
- * The flat "Ledger" inspector shell (design_handoff_studio_inspector).
53
- *
54
- * Extracted from PropertyPanel so that file stays under the 600-LOC gate
55
- * (same one-directional-import precedent as FlatTextSection). Rendered only
56
- * when STUDIO_FLAT_INSPECTOR_ENABLED is on; owns the one-open group state.
57
- *
58
- * The Text/Style/Layout/Motion/Media/Grade groups share the one-open accordion.
59
- */
56
+ /** The flat inspector shell with one shared open-group state. */
60
57
  // fallow-ignore-next-line complexity
61
58
  export function PropertyPanelFlat({
62
59
  element,
@@ -93,6 +90,7 @@ export function PropertyPanelFlat({
93
90
  onAskAgent,
94
91
  onToggleElementHidden,
95
92
  onImportAssets,
93
+ onAddMediaOverlay,
96
94
  onImportFonts,
97
95
  recordingState,
98
96
  recordingDuration,
@@ -113,10 +111,7 @@ export function PropertyPanelFlat({
113
111
  currentTime,
114
112
  animIdForProp,
115
113
  gsapRuntimeValues,
116
- // Renamed: PropertyPanel.tsx still computes/passes these for its own legacy
117
- // (non-flat) panel, but the flat path recomputes its own basis below via
118
- // deriveElementTiming so it agrees with Motion's Timing row — ignore the
119
- // parent's naive `elDuration ?? 1` fallback.
114
+ // The flat path derives timing consistently with its Motion section.
120
115
  elStart: _elStart,
121
116
  elDuration: _elDuration,
122
117
  onCommitAnimatedProperty,
@@ -164,6 +159,7 @@ export function PropertyPanelFlat({
164
159
  | "onAskAgent"
165
160
  | "onToggleElementHidden"
166
161
  | "onImportAssets"
162
+ | "onAddMediaOverlay"
167
163
  | "onImportFonts"
168
164
  | "fontAssets"
169
165
  | "gsapAnimations"
@@ -187,9 +183,6 @@ export function PropertyPanelFlat({
187
183
  | "recordingDuration"
188
184
  | "onToggleRecording"
189
185
  > &
190
- // Layout-group values (Plan 3a Task 5). All are derived locals or handlers in
191
- // PropertyPanel; compose their exact shapes from FlatLayoutSection's own props
192
- // via Pick so a signature change there propagates here instead of drifting.
193
186
  Pick<
194
187
  Parameters<typeof FlatLayoutSection>[0],
195
188
  | "displayX"
@@ -227,12 +220,7 @@ export function PropertyPanelFlat({
227
220
  onCopyElementInfo: () => void;
228
221
  currentTime: number;
229
222
  }) {
230
- // Lazy initializer: pick whichever group actually renders for this element
231
- // (Text if text-editable, else Style if style-editable, else none open) so a
232
- // style-only element doesn't start with everything collapsed. Only runs on
233
- // mount — PropertyPanel.tsx keys <PropertyPanelFlat> by element identity so
234
- // switching the selection re-mounts this component and re-derives the
235
- // default instead of preserving stale state across unrelated elements.
223
+ // PropertyPanel keys this component by selection, so the default is per element.
236
224
  const [openGroupId, setOpenGroupId] = useState<string>(() =>
237
225
  isTextEditableSelection(element)
238
226
  ? "text"
@@ -243,33 +231,16 @@ export function PropertyPanelFlat({
243
231
  : "layout",
244
232
  );
245
233
 
246
- // Tracks which group(s) are actively transitioning this toggle cycle, so
247
- // their header/body gets the fast entrance animation (hf-flat-group-enter)
248
- // and no one else's does. Deliberately NOT derived from remounting alone:
249
- // FlatGroupHeader instances are keyed by group id and React normally
250
- // preserves them across re-renders, but toggling a non-adjacent group still
251
- // shifts the untouched collapsed siblings between the before/after-open
252
- // slices below, and Chromium restarts a CSS animation on that kind of
253
- // position shift even though nothing about the sibling actually changed.
254
- // Gating on these ids (cleared shortly after the 120ms CSS animation
255
- // finishes) keeps the animation scoped to only the groups that actually
256
- // just toggled. Two ids, not one: the clicked (newly-opening/closing) group
257
- // AND whichever group was open immediately before the click and got
258
- // implicitly closed by it — both freshly-mounted headers need to animate.
234
+ // Animate only the groups that changed during this toggle cycle.
259
235
  const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
260
236
  const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
237
+ const panelBodyRef = useRef<HTMLDivElement>(null);
261
238
  useEffect(() => {
262
239
  return () => {
263
240
  if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
264
241
  };
265
242
  }, []);
266
243
 
267
- // Grade group state. Called unconditionally (React rules-of-hooks) even when
268
- // sections.colorGrading is false — unlike the legacy ColorGradingSection,
269
- // which is only mounted when the section is active, PropertyPanelFlat is not
270
- // remounted per-section so the hook must run every render. Shares one state
271
- // object between the group's header accessory (compare/status/reset) and its
272
- // body (the FlatColorGradingSection controls).
273
244
  const colorGradingController = useColorGradingController({
274
245
  projectId,
275
246
  element,
@@ -281,9 +252,7 @@ export function PropertyPanelFlat({
281
252
  const isTextEditable = isTextEditableSelection(element);
282
253
  const elementKind = sections.media ? "media" : element.textFields.length > 0 ? "text" : "other";
283
254
  const toggleOpen = (groupId: string) => {
284
- // Capture what was open BEFORE this click (this render's closure over
285
- // openGroupId), so the group that's about to be implicitly closed can be
286
- // tracked too — not just the one the user clicked.
255
+ const isOpening = openGroupId !== groupId;
287
256
  const previousOpenGroupId = openGroupId;
288
257
  setOpenGroupId((current) => (current === groupId ? "" : groupId));
289
258
  const implicitlyClosedId =
@@ -291,34 +260,20 @@ export function PropertyPanelFlat({
291
260
  setJustToggledIds(implicitlyClosedId ? [groupId, implicitlyClosedId] : [groupId]);
292
261
  if (justToggledTimeoutRef.current) clearTimeout(justToggledTimeoutRef.current);
293
262
  justToggledTimeoutRef.current = setTimeout(() => setJustToggledIds([]), 200);
263
+ if (isOpening) {
264
+ requestAnimationFrame(() =>
265
+ panelBodyRef.current
266
+ ?.querySelector<HTMLElement>('[data-flat-group-open="true"]')
267
+ ?.scrollIntoView?.({ block: "start" }),
268
+ );
269
+ }
294
270
  };
295
- // Basis for the Layout keyframe gutter (X/Y/W/H/Angle + 3D Transform) —
296
- // must agree with Motion's Timing row (FlatTimingRow), which infers the
297
- // range from animations when there's no explicit data-duration. Computed
298
- // here (not threaded from PropertyPanel) both to keep that file under its
299
- // 600-LOC gate and because element/gsapAnimations are already in scope.
300
271
  const { start: elStart, duration: elDuration } = deriveElementTiming(element, gsapAnimations);
301
- // Trivial percentage→time seek, derived here rather than threaded from
302
- // PropertyPanel (keeps that file under its 600-LOC gate).
303
272
  const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
304
- // Playhead position within the SAME corrected elStart/elDuration basis as
305
- // seekFromKfPct above — recomputed here (not threaded as `currentPct` from
306
- // PropertyPanel, which still derives it against its own naive basis for the
307
- // legacy panel) so KeyframeNavigation's diamond active-state and prev/next
308
- // arrow targeting agree with where a keyframe click actually seeks to
309
- // (follow-up fix to 684ec4e87, which corrected the seek basis but left this
310
- // one still naive).
273
+ // Use the same timing basis for seeking and active keyframe state.
311
274
  const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
312
275
 
313
- // Motion group double-gate reproduces the legacy PropertyPanel gate exactly:
314
- // • Timing (sections.timing) shows via resolveEditingSections, same as today.
315
- // • The effect-card list shows only when STUDIO_GSAP_PANEL_ENABLED is on AND
316
- // all five edit handlers are present (identical to PropertyPanel's legacy
317
- // `<GsapAnimationSection>` guard).
318
- // Computing the narrowed handler bundle inside the `&&`-guarded ternary lets
319
- // TypeScript prove each handler non-undefined without a `!` assertion; the
320
- // noop bundle only fills the type when the gate is off (never invoked, since
321
- // FlatMotionSection guards every call behind showEffects).
276
+ // Match the legacy Motion gate while preserving TypeScript narrowing.
322
277
  const showMotionTiming = Boolean(sections.timing);
323
278
  const gsapEffectHandlers =
324
279
  STUDIO_GSAP_PANEL_ENABLED &&
@@ -347,9 +302,6 @@ export function PropertyPanelFlat({
347
302
  const showMotionEffects = gsapEffectHandlers !== null;
348
303
  const showMotionGroup = showMotionTiming || showMotionEffects;
349
304
 
350
- // Ordered group descriptors — one per FlatGroup this panel renders, gated by
351
- // the same conditions the inline JSX used. Split below into before-open/
352
- // open/after-open regions for the one-open accordion.
353
305
  const groups: FlatGroupDescriptor[] = [];
354
306
  if (isTextEditable) {
355
307
  groups.push({
@@ -372,8 +324,6 @@ export function PropertyPanelFlat({
372
324
  });
373
325
  }
374
326
  if (showEditableSections) {
375
- // Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
376
- // invisible element must summarize as 0%, not 100%.
377
327
  const opacityValue = parseFloat(styles.opacity ?? "1");
378
328
  const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
379
329
  groups.push({
@@ -398,8 +348,6 @@ export function PropertyPanelFlat({
398
348
  groups.push({
399
349
  id: "layout",
400
350
  title: "Layout",
401
- // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
402
- // (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
403
351
  summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
404
352
  content: (
405
353
  <FlatLayoutSection
@@ -470,15 +418,56 @@ export function PropertyPanelFlat({
470
418
  assets={assets}
471
419
  onImportAssets={onImportAssets}
472
420
  onCommitColorGrading={colorGradingController.commitColorGrading}
421
+ onPreviewColorGrading={colorGradingController.previewColorGrading}
473
422
  applyScope={colorGradingController.applyScope}
474
423
  applyBusy={colorGradingController.applyBusy}
475
424
  onSetApplyScope={colorGradingController.setApplyScope}
476
425
  onApplyToScope={() => void colorGradingController.applyToScope()}
477
426
  onApplyScopeAvailable={Boolean(onApplyColorGradingScope)}
478
427
  mediaMetadata={colorGradingController.mediaMetadata}
428
+ presetPreviews={colorGradingController.presetPreviews}
429
+ onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
430
+ />
431
+ ),
432
+ });
433
+ const activeEffects = activeColorGradingEffectCount(colorGradingController.grading);
434
+ const effectsProps = {
435
+ grading: colorGradingController.grading,
436
+ onCommitColorGrading: colorGradingController.commitColorGrading,
437
+ };
438
+ groups.push({
439
+ id: "effects",
440
+ title: "Effects",
441
+ accessory: <FlatEffectsAccessory {...effectsProps} />,
442
+ summary: activeEffects ? `${activeEffects} active` : "none",
443
+ content: (
444
+ <FlatEffectsSection
445
+ {...effectsProps}
446
+ previews={colorGradingController.effectPreviews}
447
+ presetPreviews={colorGradingController.presetPreviews}
448
+ onPreviewColorGrading={colorGradingController.previewColorGrading}
449
+ onRequestEffectPreviews={colorGradingController.requestEffectPreviews}
450
+ onRequestPresetPreviews={colorGradingController.requestPresetPreviews}
479
451
  />
480
452
  ),
481
453
  });
454
+ if (onAddMediaOverlay) {
455
+ groups.push({
456
+ id: "overlays",
457
+ title: "Overlays",
458
+ summary: "add layer",
459
+ content: (
460
+ <FlatOverlaysSection
461
+ onAddOverlay={(blockName) =>
462
+ onAddMediaOverlay(
463
+ blockName,
464
+ deriveMediaOverlayPlacement(element, { start: elStart, duration: elDuration }),
465
+ )
466
+ }
467
+ />
468
+ ),
469
+ });
470
+ }
482
471
  }
483
472
  if (sections.media) {
484
473
  groups.push({
@@ -499,13 +488,6 @@ export function PropertyPanelFlat({
499
488
  });
500
489
  }
501
490
 
502
- // Fixed-headers + scrollable-open-section layout (design_handoff
503
- // scrollable-open-section, replaces the prior sticky-stacking mechanism):
504
- // collapsed headers before/after the open group render in normal document
505
- // flow and never move. Only the open group's own body content scrolls, in
506
- // a dedicated region between the two fixed header stacks. When no group is
507
- // open, every group is just a collapsed header — there's no scrollable
508
- // middle region at all, since nothing is expanded.
509
491
  const openIndex = groups.findIndex((g) => g.id === openGroupId);
510
492
  const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
511
493
  const openGroup = openIndex === -1 ? null : groups[openIndex];
@@ -532,7 +514,11 @@ export function PropertyPanelFlat({
532
514
  showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
533
515
  />
534
516
  </DesignPanelInputProvider>
535
- <div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
517
+ <div
518
+ ref={panelBodyRef}
519
+ data-flat-panel-body="true"
520
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
521
+ >
536
522
  {beforeOpen.map((g) => (
537
523
  <DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
538
524
  <FlatGroupHeader
@@ -546,7 +532,7 @@ export function PropertyPanelFlat({
546
532
  ))}
547
533
  {openGroup && (
548
534
  <DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
549
- <div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
535
+ <div data-flat-group-open="true" className="flex min-h-[180px] flex-none flex-col">
550
536
  <FlatGroupHeader
551
537
  title={openGroup.title}
552
538
  isOpen
@@ -5,15 +5,15 @@ describe("patchMediaColorGradingInHtml", () => {
5
5
  it("adds color grading to video and image tags only", () => {
6
6
  const { html, count } = patchMediaColorGradingInHtml(
7
7
  `<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
8
- `{"preset":"natural-lift"}`,
8
+ `{"preset":"clean-studio"}`,
9
9
  );
10
10
 
11
11
  expect(count).toBe(2);
12
12
  expect(html).toContain(
13
- `video id="v" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
13
+ `video id="v" data-color-grading="{&quot;preset&quot;:&quot;clean-studio&quot;}"`,
14
14
  );
15
15
  expect(html).toContain(
16
- `img id="i" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
16
+ `img id="i" data-color-grading="{&quot;preset&quot;:&quot;clean-studio&quot;}"`,
17
17
  );
18
18
  expect(html).toContain(`<audio id="a"></audio>`);
19
19
  });
@@ -0,0 +1,65 @@
1
+ import { evaluateWiggleEase, parseWiggleEase } from "@hyperframes/core/wiggle-ease";
2
+ import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease";
3
+ import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
4
+
5
+ export function sampledPath(
6
+ samples: number,
7
+ x: (progress: number) => number,
8
+ y: (value: number) => number,
9
+ evaluate: (progress: number) => number,
10
+ ): string {
11
+ return Array.from({ length: samples + 1 }, (_, index) => {
12
+ const progress = index / samples;
13
+ return `${index === 0 ? "M" : "L"}${x(progress)},${y(evaluate(progress))}`;
14
+ }).join(" ");
15
+ }
16
+
17
+ export function holdCurvePath(left: number, bottom: number, right: number, top: number): string {
18
+ return `M${left},${bottom} L${right},${bottom} L${right},${top}`;
19
+ }
20
+
21
+ export function MiniCurveSvg({
22
+ ease,
23
+ active,
24
+ size = 24,
25
+ }: {
26
+ ease: string;
27
+ active: boolean;
28
+ size?: number;
29
+ }) {
30
+ const springBounce = parseSpringBounce(ease);
31
+ const wiggle = parseWiggleEase(ease);
32
+ const curve = resolveEaseCurveTuple(ease);
33
+ const [x1, y1, x2, y2] = curve;
34
+ const s = size;
35
+ const p = size / 8;
36
+ const g = s - p * 2;
37
+ const sx = (px: number) => p + g * px;
38
+ const sy = (py: number) => s - p - g * py;
39
+ const d =
40
+ ease === "hold"
41
+ ? holdCurvePath(p, s - p, s - p, p)
42
+ : wiggle
43
+ ? sampledPath(64, sx, sy, (progress) =>
44
+ evaluateWiggleEase(progress, wiggle.wiggles, wiggle.type, wiggle.amplitude),
45
+ )
46
+ : springBounce !== null
47
+ ? sampledPath(
48
+ 24,
49
+ sx,
50
+ (value) => sy(value / 1.2),
51
+ (progress) => evaluateSpringEase(progress, springBounce),
52
+ )
53
+ : `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
54
+ return (
55
+ <svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
56
+ <path
57
+ d={d}
58
+ fill="none"
59
+ stroke={active ? "#3CE6AC" : "#737373"}
60
+ strokeWidth="1.5"
61
+ strokeLinecap="round"
62
+ />
63
+ </svg>
64
+ );
65
+ }
@@ -0,0 +1,114 @@
1
+ // Imports the parsers SOURCE (not the published subpath) so newly-added eases
2
+ // like circ.inOut resolve before the parsers dist is rebuilt.
3
+ import { SUPPORTED_EASES } from "../../../../parsers/src/gsapConstants";
4
+ import { parseSpringBounce } from "@hyperframes/core/spring-ease";
5
+ import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
6
+ import { describe, expect, it } from "vitest";
7
+ import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary";
8
+ import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
9
+
10
+ const CUSTOM_EASE_PATTERN =
11
+ /^custom\(M0,0 C-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? -?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? 1,1\)$/;
12
+
13
+ describe("EASE_PRESETS", () => {
14
+ it("contains the complete 32-preset Graphs library with unique fields", () => {
15
+ expect(EASE_PRESETS).toHaveLength(32);
16
+ expect(new Set(EASE_PRESETS.map(({ id }) => id))).toHaveLength(32);
17
+ expect(new Set(EASE_PRESETS.map(({ label }) => label))).toHaveLength(32);
18
+ expect(new Set(EASE_PRESETS.map(({ ease }) => ease))).toHaveLength(32);
19
+ });
20
+
21
+ it("preserves the required standard, Flow, and Bounce mappings", () => {
22
+ const easeByLabel = Object.fromEntries(EASE_PRESETS.map(({ label, ease }) => [label, ease]));
23
+
24
+ expect(easeByLabel).toMatchObject({
25
+ Linear: "none",
26
+ "Ease In": "power1.in",
27
+ "Quad In": "power2.in",
28
+ "Cubic In": "power3.in",
29
+ "Ease Out": "power1.out",
30
+ "Quad Out": "power2.out",
31
+ "Cubic Out": "power3.out",
32
+ "Ease In & Out": "power1.inOut",
33
+ "Quad Ease": "power2.inOut",
34
+ "Cubic Ease": "power3.inOut",
35
+ "Circular Ease": "circ.inOut",
36
+ "Ease In Back": "back.in",
37
+ "Ease Out Back": "back.out",
38
+ "Flow 1": "wiggle(1,easeInOut,0.20)",
39
+ "Flow 2": "wiggle(2,easeInOut,0.15)",
40
+ "Flow 3": "wiggle(3,easeInOut,0.12)",
41
+ "Flow 4": "wiggle(4,easeInOut,0.10)",
42
+ "Flow 5": "wiggle(5,easeInOut,0.08)",
43
+ "Flow 6": "wiggle(6,easeInOut,0.07)",
44
+ "Flow 7": "wiggle(7,easeInOut,0.06)",
45
+ "Bounce 1": "wiggle(4,easeOut,0.22)",
46
+ "Bounce 2": "wiggle(6,easeOut,0.26)",
47
+ "Bounce 3": "wiggle(9,uniform,0.32)",
48
+ "Bounce 4": "wiggle(5,anticipate,0.28)",
49
+ Hold: "hold",
50
+ });
51
+
52
+ const flows = EASE_PRESETS.filter(({ id }) => id.startsWith("flow-"));
53
+ expect(flows.map(({ label }) => label)).toEqual([
54
+ "Flow 1",
55
+ "Flow 2",
56
+ "Flow 3",
57
+ "Flow 4",
58
+ "Flow 5",
59
+ "Flow 6",
60
+ "Flow 7",
61
+ ]);
62
+ expect(flows.every(({ ease }) => parseWiggleEase(ease) !== null)).toBe(true);
63
+ });
64
+
65
+ it("uses supported or valid custom eases that all resolve to finite geometry", () => {
66
+ for (const preset of EASE_PRESETS) {
67
+ expect(
68
+ SUPPORTED_EASES.includes(preset.ease as (typeof SUPPORTED_EASES)[number]) ||
69
+ CUSTOM_EASE_PATTERN.test(preset.ease) ||
70
+ parseSpringBounce(preset.ease) !== null ||
71
+ parseWiggleEase(preset.ease) !== null,
72
+ `${preset.label} has unsupported ease ${preset.ease}`,
73
+ ).toBe(true);
74
+ expect(resolveEaseCurveTuple(preset.ease).every(Number.isFinite)).toBe(true);
75
+ }
76
+ });
77
+
78
+ it("assigns every preset a valid kind", () => {
79
+ const kinds = new Set(["curve", "spring", "wiggle"]);
80
+
81
+ for (const preset of EASE_PRESETS) {
82
+ expect(kinds.has(preset.kind)).toBe(true);
83
+ }
84
+ });
85
+
86
+ it("uses valid bounce values for spring presets", () => {
87
+ for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "spring")) {
88
+ const bounce = parseSpringBounce(preset.ease);
89
+
90
+ expect(bounce).not.toBeNull();
91
+ expect(bounce).toBeGreaterThanOrEqual(0);
92
+ expect(bounce).toBeLessThanOrEqual(1);
93
+ }
94
+ });
95
+
96
+ it("uses parseable eases for wiggle presets", () => {
97
+ for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "wiggle")) {
98
+ expect(parseWiggleEase(preset.ease)).not.toBeNull();
99
+ }
100
+ });
101
+
102
+ it("preserves stable preset ids", () => {
103
+ for (const id of ["flow-7", "hold", "rebound-in"]) {
104
+ expect(EASE_PRESETS.some((preset) => preset.id === id)).toBe(true);
105
+ }
106
+ });
107
+
108
+ it("resolves preset labels by exact ease", () => {
109
+ expect(easePresetLabel("spring(0.6)")).toBe("Bouncy");
110
+ expect(easePresetLabel("back.in")).toBe("Ease In Back");
111
+ expect(easePresetLabel("wiggle(9,uniform,0.32)")).toBe("Bounce 3");
112
+ expect(easePresetLabel("custom(x)")).toBeNull();
113
+ });
114
+ });
@@ -0,0 +1,49 @@
1
+ export const EASE_PRESETS = [
2
+ { id: "linear", label: "Linear", ease: "none", kind: "curve" },
3
+ { id: "ease-in", label: "Ease In", ease: "power1.in", kind: "curve" },
4
+ { id: "quad-in", label: "Quad In", ease: "power2.in", kind: "curve" },
5
+ { id: "cubic-in", label: "Cubic In", ease: "power3.in", kind: "curve" },
6
+ { id: "ease-out", label: "Ease Out", ease: "power1.out", kind: "curve" },
7
+ { id: "quad-out", label: "Quad Out", ease: "power2.out", kind: "curve" },
8
+ { id: "cubic-out", label: "Cubic Out", ease: "power3.out", kind: "curve" },
9
+ { id: "ease", label: "Ease In & Out", ease: "power1.inOut", kind: "curve" },
10
+ { id: "quad-ease", label: "Quad Ease", ease: "power2.inOut", kind: "curve" },
11
+ { id: "cubic-ease", label: "Cubic Ease", ease: "power3.inOut", kind: "curve" },
12
+ { id: "circular-ease", label: "Circular Ease", ease: "circ.inOut", kind: "curve" },
13
+ { id: "rebound-in", label: "Ease In Back", ease: "back.in", kind: "curve" },
14
+ { id: "rebound-out", label: "Ease Out Back", ease: "back.out", kind: "curve" },
15
+ { id: "flow-1", label: "Flow 1", ease: "wiggle(1,easeInOut,0.20)", kind: "wiggle" },
16
+ { id: "flow-2", label: "Flow 2", ease: "wiggle(2,easeInOut,0.15)", kind: "wiggle" },
17
+ { id: "flow-3", label: "Flow 3", ease: "wiggle(3,easeInOut,0.12)", kind: "wiggle" },
18
+ { id: "flow-4", label: "Flow 4", ease: "wiggle(4,easeInOut,0.10)", kind: "wiggle" },
19
+ { id: "flow-5", label: "Flow 5", ease: "wiggle(5,easeInOut,0.08)", kind: "wiggle" },
20
+ { id: "flow-6", label: "Flow 6", ease: "wiggle(6,easeInOut,0.07)", kind: "wiggle" },
21
+ { id: "flow-7", label: "Flow 7", ease: "wiggle(7,easeInOut,0.06)", kind: "wiggle" },
22
+ { id: "bounce-1", label: "Bounce 1", ease: "wiggle(4,easeOut,0.22)", kind: "wiggle" },
23
+ { id: "bounce-2", label: "Bounce 2", ease: "wiggle(6,easeOut,0.26)", kind: "wiggle" },
24
+ { id: "bounce-3", label: "Bounce 3", ease: "wiggle(9,uniform,0.32)", kind: "wiggle" },
25
+ { id: "bounce-4", label: "Bounce 4", ease: "wiggle(5,anticipate,0.28)", kind: "wiggle" },
26
+ { id: "hold", label: "Hold", ease: "hold", kind: "curve" },
27
+ {
28
+ id: "rebound-ease",
29
+ label: "Ease In & Out Back",
30
+ ease: "back.inOut",
31
+ kind: "curve",
32
+ },
33
+ { id: "expo-in", label: "Expo In", ease: "expo.in", kind: "curve" },
34
+ { id: "expo-out", label: "Expo Out", ease: "expo.out", kind: "curve" },
35
+ // Runtime spring(bounce) approximates Figma stiffness and damping with bounce alone.
36
+ { id: "spring-gentle", label: "Gentle", ease: "spring(0.15)", kind: "spring" },
37
+ { id: "spring-quick", label: "Quick", ease: "spring(0.4)", kind: "spring" },
38
+ { id: "spring-bouncy", label: "Bouncy", ease: "spring(0.6)", kind: "spring" },
39
+ { id: "spring-slow", label: "Slow", ease: "spring(0.25)", kind: "spring" },
40
+ ] as const satisfies ReadonlyArray<{
41
+ id: string;
42
+ label: string;
43
+ ease: string;
44
+ kind: "curve" | "spring" | "wiggle";
45
+ }>;
46
+
47
+ export function easePresetLabel(ease: string): string | null {
48
+ return EASE_PRESETS.find((preset) => preset.ease === ease)?.label ?? null;
49
+ }
@@ -1,4 +1,4 @@
1
- import { controlPointsForGsapEase } from "./studioMotion";
1
+ import { controlPointsForGsapEase, parseStudioCustomEaseData } from "./studioMotion";
2
2
 
3
3
  export const METHOD_LABELS: Record<string, string> = {
4
4
  set: "Set",
@@ -116,9 +116,14 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
116
116
  "back.out": [0.34, 1.56, 0.64, 1],
117
117
  "back.in": [0.36, 0, 0.66, -0.56],
118
118
  "back.inOut": [0.68, -0.55, 0.27, 1.55],
119
+ "circ.inOut": [0.785, 0.135, 0.15, 0.86],
119
120
  "expo.out": [0.16, 1, 0.3, 1],
120
121
  "expo.in": [0.7, 0, 0.84, 0],
121
122
  "expo.inOut": [0.87, 0, 0.13, 1],
123
+ "bounce.out": [0.34, 1.56, 0.64, 0.74],
124
+ "bounce.in": [0.36, 0.26, 0.66, -0.56],
125
+ "elastic.out(1,0.3)": [0.16, 1.45, 0.28, 0.82],
126
+ "elastic.inOut(1,0.3)": [0.68, -0.55, 0.32, 1.55],
122
127
  // After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
123
128
  // "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
124
129
  "ae-ease": [0.333, 0, 0.667, 1],
@@ -126,18 +131,15 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
126
131
  "ae-ease-out": [0.333, 0, 0.667, 0.667],
127
132
  };
128
133
 
129
- export function parseCustomEaseFromString(ease: string): {
130
- x1: number;
131
- y1: number;
132
- x2: number;
133
- y2: number;
134
- } {
135
- const match = ease.match(/^custom\((.+)\)$/);
136
- if (!match) return controlPointsForGsapEase("power2.out");
137
- const data = match[1];
138
- const nums = data.match(/[\d.]+/g)?.map(Number);
139
- if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out");
140
- return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
134
+ export function resolveEaseCurveTuple(ease: string): [number, number, number, number] {
135
+ if (ease.startsWith("custom(")) {
136
+ const points = parseStudioCustomEaseData(ease.match(/^custom\((.+)\)$/)?.[1]);
137
+ if (points) return [points.x1, points.y1, points.x2, points.y2];
138
+ }
139
+ const curve = EASE_CURVES[ease];
140
+ if (curve) return curve;
141
+ const points = controlPointsForGsapEase(ease);
142
+ return [points.x1, points.y1, points.x2, points.y2];
141
143
  }
142
144
 
143
145
  export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);