@hyperframes/studio 0.6.86 → 0.6.87

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 (87) hide show
  1. package/dist/assets/index-BA19FAPN.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -0,0 +1,120 @@
1
+ import { memo } from "react";
2
+ import type { KeyframeCacheEntry } from "../store/playerStore";
3
+
4
+ const SUB_TRACK_H = 24;
5
+ const DIAMOND_SIZE = 6;
6
+ const HALF = DIAMOND_SIZE / 2;
7
+
8
+ interface TimelinePropertyRowsProps {
9
+ keyframesData: KeyframeCacheEntry;
10
+ clipWidthPx: number;
11
+ clipLeftPx: number;
12
+ accentColor: string;
13
+ isSelected: boolean;
14
+ currentPercentage: number;
15
+ elementId: string;
16
+ selectedKeyframes: Set<string>;
17
+ onClickKeyframe?: (percentage: number) => void;
18
+ }
19
+
20
+ function extractProperties(data: KeyframeCacheEntry): string[] {
21
+ const props = new Set<string>();
22
+ for (const kf of data.keyframes) {
23
+ for (const key of Object.keys(kf.properties)) {
24
+ props.add(key);
25
+ }
26
+ }
27
+ return Array.from(props).sort();
28
+ }
29
+
30
+ export const TimelinePropertyRows = memo(function TimelinePropertyRows({
31
+ keyframesData,
32
+ clipWidthPx,
33
+ clipLeftPx,
34
+ accentColor,
35
+ isSelected,
36
+ currentPercentage,
37
+ elementId,
38
+ selectedKeyframes,
39
+ onClickKeyframe,
40
+ }: TimelinePropertyRowsProps) {
41
+ const properties = extractProperties(keyframesData);
42
+ if (properties.length === 0 || clipWidthPx < 20) return null;
43
+
44
+ return (
45
+ <div className="flex flex-col">
46
+ {properties.map((prop) => {
47
+ const propKeyframes = keyframesData.keyframes.filter((kf) => prop in kf.properties);
48
+ if (propKeyframes.length === 0) return null;
49
+
50
+ return (
51
+ <div key={prop} className="relative flex items-center" style={{ height: SUB_TRACK_H }}>
52
+ <span className="absolute left-1 text-[8px] font-medium text-neutral-600 z-10 select-none">
53
+ {prop}
54
+ </span>
55
+ <svg
56
+ className="absolute"
57
+ style={{ left: clipLeftPx, width: clipWidthPx, height: SUB_TRACK_H }}
58
+ viewBox={`0 0 ${clipWidthPx} ${SUB_TRACK_H}`}
59
+ >
60
+ <line
61
+ x1={0}
62
+ y1={SUB_TRACK_H / 2}
63
+ x2={clipWidthPx}
64
+ y2={SUB_TRACK_H / 2}
65
+ stroke={isSelected ? accentColor : "#525252"}
66
+ strokeOpacity={0.15}
67
+ strokeWidth={1}
68
+ />
69
+ {propKeyframes.map((kf) => {
70
+ const x = (kf.percentage / 100) * clipWidthPx;
71
+ const y = SUB_TRACK_H / 2;
72
+ const key = `${elementId}:${kf.percentage}`;
73
+ const isKfSelected = selectedKeyframes.has(key);
74
+ const isHold = kf.ease === "steps(1)";
75
+ const fillColor =
76
+ isKfSelected || (isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5)
77
+ ? accentColor
78
+ : isSelected
79
+ ? `${accentColor}80`
80
+ : "#737373";
81
+
82
+ return (
83
+ <g
84
+ key={kf.percentage}
85
+ onClick={(e) => {
86
+ e.stopPropagation();
87
+ onClickKeyframe?.(kf.percentage);
88
+ }}
89
+ style={{ cursor: "pointer" }}
90
+ >
91
+ {isHold ? (
92
+ <rect
93
+ x={x - HALF}
94
+ y={y - HALF}
95
+ width={DIAMOND_SIZE}
96
+ height={DIAMOND_SIZE}
97
+ fill={fillColor}
98
+ />
99
+ ) : (
100
+ <rect
101
+ x={x - HALF}
102
+ y={y - HALF}
103
+ width={DIAMOND_SIZE}
104
+ height={DIAMOND_SIZE}
105
+ fill={fillColor}
106
+ transform={`rotate(45, ${x}, ${y})`}
107
+ />
108
+ )}
109
+ </g>
110
+ );
111
+ })}
112
+ </svg>
113
+ </div>
114
+ );
115
+ })}
116
+ </div>
117
+ );
118
+ });
119
+
120
+ export { SUB_TRACK_H };
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
4
+
5
+ function makeDoc(html: string): Document {
6
+ const d = document.implementation.createHTMLDocument();
7
+ d.body.innerHTML = html;
8
+ return d;
9
+ }
10
+
11
+ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
12
+ it("harvests hfId from a data-start element that has data-hf-id", () => {
13
+ const doc = makeDoc(`
14
+ <div data-composition-id="root">
15
+ <div id="hero" class="clip" data-start="0" data-duration="5" data-hf-id="hf-abc123"></div>
16
+ </div>
17
+ `);
18
+
19
+ const elements = parseTimelineFromDOM(doc, 10);
20
+ const hero = elements.find((el) => el.domId === "hero");
21
+
22
+ expect(hero).toBeDefined();
23
+ expect(hero?.hfId).toBe("hf-abc123");
24
+ });
25
+
26
+ it("leaves hfId undefined when element has no data-hf-id", () => {
27
+ const doc = makeDoc(`
28
+ <div data-composition-id="root">
29
+ <div id="plain" class="clip" data-start="0" data-duration="5"></div>
30
+ </div>
31
+ `);
32
+
33
+ const elements = parseTimelineFromDOM(doc, 10);
34
+ const plain = elements.find((el) => el.domId === "plain");
35
+
36
+ expect(plain).toBeDefined();
37
+ expect(plain?.hfId).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
42
+ it("harvests hfId from an implicit layer child that has data-hf-id", () => {
43
+ const doc = makeDoc(`
44
+ <div data-composition-id="root">
45
+ <div id="layer" class="clip" data-hf-id="hf-xyz789"></div>
46
+ </div>
47
+ `);
48
+
49
+ const layers = createImplicitTimelineLayersFromDOM(doc, 10);
50
+ const layer = layers.find((el) => el.domId === "layer");
51
+
52
+ expect(layer).toBeDefined();
53
+ expect(layer?.hfId).toBe("hf-xyz789");
54
+ });
55
+ });
@@ -26,14 +26,21 @@ import {
26
26
 
27
27
  // Re-export helpers that were previously public from this module so that
28
28
  // existing import sites (hook + tests) don't need to change.
29
+ // fallow-ignore-next-line unused-exports
29
30
  export {
30
31
  readTimelineDurationFromDocument,
32
+ // fallow-ignore-next-line unused-exports
31
33
  resolveMediaElement,
34
+ // fallow-ignore-next-line unused-exports
32
35
  applyMediaMetadataFromElement,
33
36
  getTimelineElementSelector,
37
+ // fallow-ignore-next-line unused-exports
34
38
  getTimelineElementSourceFile,
39
+ // fallow-ignore-next-line unused-exports
35
40
  getTimelineElementSelectorIndex,
41
+ // fallow-ignore-next-line unused-exports
36
42
  buildTimelineElementIdentity,
43
+ // fallow-ignore-next-line unused-exports
37
44
  getTimelineElementIdentity,
38
45
  findTimelineDomNodeForClip,
39
46
  } from "./timelineElementHelpers";
@@ -72,8 +79,10 @@ export function createTimelineElementFromManifestClip(params: {
72
79
  let selectorIndex: number | undefined;
73
80
  let sourceFile: string | undefined;
74
81
 
82
+ let hfId: string | undefined;
75
83
  if (hostEl) {
76
84
  domId = hostEl.id || undefined;
85
+ hfId = hostEl.getAttribute("data-hf-id") || undefined;
77
86
  selector = getTimelineElementSelector(hostEl);
78
87
  selectorIndex =
79
88
  doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
@@ -98,6 +107,7 @@ export function createTimelineElementFromManifestClip(params: {
98
107
  duration: clip.duration,
99
108
  track: clip.track,
100
109
  domId,
110
+ hfId,
101
111
  selector,
102
112
  selectorIndex,
103
113
  sourceFile,
@@ -127,6 +137,7 @@ export function createTimelineElementFromManifestClip(params: {
127
137
  }
128
138
  if (hostEl) {
129
139
  entry.domId = hostEl.id || undefined;
140
+ entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
130
141
  entry.selector = getTimelineElementSelector(hostEl);
131
142
  entry.selectorIndex =
132
143
  doc && entry.selector
@@ -187,6 +198,7 @@ export function createImplicitTimelineLayersFromDOM(
187
198
 
188
199
  layers.push({
189
200
  domId: child.id || undefined,
201
+ hfId: child.getAttribute("data-hf-id") || undefined,
190
202
  duration: rootDuration,
191
203
  id: identity.id,
192
204
  key: identity.key,
@@ -262,6 +274,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
262
274
  duration: dur,
263
275
  track: isNaN(track) ? 0 : track,
264
276
  domId: el.id || undefined,
277
+ hfId: el.getAttribute("data-hf-id") || undefined,
265
278
  selector,
266
279
  selectorIndex,
267
280
  sourceFile,
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { buildMissingCompositionElements } from "./timelineIframeHelpers";
4
+ import type { IframeWindow } from "./playbackTypes";
5
+
6
+ function makeDoc(html: string): Document {
7
+ const d = document.implementation.createHTMLDocument();
8
+ d.body.innerHTML = html;
9
+ return d;
10
+ }
11
+
12
+ describe("buildMissingCompositionElements — hfId (R7)", () => {
13
+ it("harvests hfId from data-hf-id on composition host elements", () => {
14
+ const doc = makeDoc(`
15
+ <div data-composition-id="root">
16
+ <div
17
+ data-composition-id="scene-a"
18
+ data-composition-src="scenes/a.html"
19
+ data-hf-id="hf-scene1"
20
+ data-start="0"
21
+ data-duration="5"
22
+ ></div>
23
+ </div>
24
+ `);
25
+
26
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
27
+ const entry = missing[0];
28
+
29
+ expect(entry).toBeDefined();
30
+ expect(entry?.hfId).toBe("hf-scene1");
31
+ });
32
+
33
+ it("leaves hfId undefined when element has no data-hf-id", () => {
34
+ const doc = makeDoc(`
35
+ <div data-composition-id="root">
36
+ <div
37
+ data-composition-id="scene-b"
38
+ data-composition-src="scenes/b.html"
39
+ data-start="0"
40
+ data-duration="5"
41
+ ></div>
42
+ </div>
43
+ `);
44
+
45
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
46
+ const entry = missing[0];
47
+
48
+ expect(entry).toBeDefined();
49
+ expect(entry?.hfId).toBeUndefined();
50
+ });
51
+ });
@@ -286,6 +286,7 @@ export function buildMissingCompositionElements(
286
286
  duration: dur,
287
287
  track: isNaN(track) ? 0 : track,
288
288
  domId: el.id || undefined,
289
+ hfId: el.getAttribute("data-hf-id") || undefined,
289
290
  selector,
290
291
  selectorIndex,
291
292
  sourceFile,
@@ -22,6 +22,8 @@ export interface TimelineElement {
22
22
  duration: number;
23
23
  track: number;
24
24
  domId?: string;
25
+ /** Stable `data-hf-id` attribute value — used as primary patch target when present */
26
+ hfId?: string;
25
27
  /** Best-effort selector used when patching source HTML back from timeline edits */
26
28
  selector?: string;
27
29
  /** Zero-based occurrence index for non-unique selectors */
@@ -68,6 +70,23 @@ interface PlayerState {
68
70
  toggleSelectedKeyframe: (key: string) => void;
69
71
  clearSelectedKeyframes: () => void;
70
72
 
73
+ /** Multi-select: additional selected elements beyond selectedElementId. */
74
+ selectedElementIds: Set<string>;
75
+ toggleSelectedElementId: (id: string) => void;
76
+ clearSelectedElementIds: () => void;
77
+
78
+ /** Clipboard for keyframe copy/paste — stores keyframes with relative times. */
79
+ keyframeClipboard: Array<{
80
+ relativeTime: number;
81
+ properties: Record<string, number | string>;
82
+ ease?: string;
83
+ }> | null;
84
+ setKeyframeClipboard: (data: PlayerState["keyframeClipboard"]) => void;
85
+
86
+ /** Elements with expanded property rows in the timeline. */
87
+ expandedTimelineElements: Set<string>;
88
+ toggleExpandedElement: (id: string) => void;
89
+
71
90
  /** Keyframe data per element id, populated from parsed GSAP animations. */
72
91
  keyframeCache: Map<string, KeyframeCacheEntry>;
73
92
  setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
@@ -138,6 +157,28 @@ export const usePlayerStore = create<PlayerState>((set) => ({
138
157
  }),
139
158
  clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
140
159
 
160
+ keyframeClipboard: null,
161
+ setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
162
+
163
+ selectedElementIds: new Set<string>(),
164
+ toggleSelectedElementId: (id: string) =>
165
+ set((s) => {
166
+ const next = new Set(s.selectedElementIds);
167
+ if (next.has(id)) next.delete(id);
168
+ else next.add(id);
169
+ return { selectedElementIds: next };
170
+ }),
171
+ clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
172
+
173
+ expandedTimelineElements: new Set<string>(),
174
+ toggleExpandedElement: (id: string) =>
175
+ set((s) => {
176
+ const next = new Set(s.expandedTimelineElements);
177
+ if (next.has(id)) next.delete(id);
178
+ else next.add(id);
179
+ return { expandedTimelineElements: next };
180
+ }),
181
+
141
182
  keyframeCache: new Map(),
142
183
  setKeyframeCache: (elementId, data) =>
143
184
  set((s) => {
@@ -210,6 +251,8 @@ export const usePlayerStore = create<PlayerState>((set) => ({
210
251
  inPoint: null,
211
252
  outPoint: null,
212
253
  selectedKeyframes: new Set(),
254
+ selectedElementIds: new Set(),
255
+ expandedTimelineElements: new Set(),
213
256
  keyframeCache: new Map(),
214
257
  }),
215
258
  }));
@@ -0,0 +1,58 @@
1
+ const WINDOW_SIZE = 1024;
2
+ const HOP_SIZE = 512;
3
+
4
+ // fallow-ignore-next-line complexity
5
+ export async function detectBeats(audioBuffer: AudioBuffer): Promise<number[]> {
6
+ const channelData = audioBuffer.getChannelData(0);
7
+ const sampleRate = audioBuffer.sampleRate;
8
+
9
+ const energies: number[] = [];
10
+ for (let i = 0; i < channelData.length - WINDOW_SIZE; i += HOP_SIZE) {
11
+ let sum = 0;
12
+ for (let j = 0; j < WINDOW_SIZE; j++) {
13
+ const sample = channelData[i + j]!;
14
+ sum += sample * sample;
15
+ }
16
+ energies.push(sum / WINDOW_SIZE);
17
+ }
18
+
19
+ const beats: number[] = [];
20
+ const localWindowSize = 20;
21
+
22
+ for (let i = localWindowSize; i < energies.length - localWindowSize; i++) {
23
+ let localMean = 0;
24
+ for (let j = i - localWindowSize; j < i + localWindowSize; j++) {
25
+ localMean += energies[j]!;
26
+ }
27
+ localMean /= localWindowSize * 2;
28
+
29
+ const threshold = localMean * 1.5;
30
+ const current = energies[i]!;
31
+
32
+ if (
33
+ current > threshold &&
34
+ current > (energies[i - 1] ?? 0) &&
35
+ current > (energies[i + 1] ?? 0)
36
+ ) {
37
+ const timeInSeconds = (i * HOP_SIZE) / sampleRate;
38
+ if (beats.length === 0 || timeInSeconds - beats[beats.length - 1]! > 0.1) {
39
+ beats.push(Math.round(timeInSeconds * 1000) / 1000);
40
+ }
41
+ }
42
+ }
43
+
44
+ return beats;
45
+ }
46
+
47
+ // fallow-ignore-next-line complexity
48
+ export async function detectBeatsFromUrl(url: string): Promise<number[]> {
49
+ const audioContext = new AudioContext();
50
+ try {
51
+ const response = await fetch(url);
52
+ const arrayBuffer = await response.arrayBuffer();
53
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
54
+ return detectBeats(audioBuffer);
55
+ } finally {
56
+ await audioContext.close();
57
+ }
58
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ absoluteToPercentage,
4
+ absoluteToPercentageForAnimation,
5
+ findTweenAtTime,
6
+ isTimeWithinTween,
7
+ percentageToAbsolute,
8
+ percentageToAbsoluteForAnimation,
9
+ resolveTweenDuration,
10
+ resolveTweenStart,
11
+ } from "./globalTimeCompiler";
12
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
+
14
+ function makeAnim(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
15
+ return {
16
+ id: "#el-to-0",
17
+ targetSelector: "#el",
18
+ method: "to",
19
+ position: 0,
20
+ properties: { x: 100 },
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("absoluteToPercentage", () => {
26
+ test("mid-point of a tween", () => {
27
+ expect(absoluteToPercentage(0.5, 0, 2)).toBe(25);
28
+ });
29
+
30
+ test("tween with offset start", () => {
31
+ expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50);
32
+ });
33
+
34
+ test("clamps below tween start to 0%", () => {
35
+ expect(absoluteToPercentage(-1, 0, 2)).toBe(0);
36
+ });
37
+
38
+ test("clamps past tween end to 100%", () => {
39
+ expect(absoluteToPercentage(5, 0, 2)).toBe(100);
40
+ });
41
+
42
+ test("zero duration returns 0", () => {
43
+ expect(absoluteToPercentage(1, 0, 0)).toBe(0);
44
+ });
45
+ });
46
+
47
+ describe("percentageToAbsolute", () => {
48
+ test("converts percentage back to absolute time", () => {
49
+ expect(percentageToAbsolute(50, 0.5, 1)).toBe(1.0);
50
+ });
51
+
52
+ test("0% returns tween start", () => {
53
+ expect(percentageToAbsolute(0, 2, 3)).toBe(2);
54
+ });
55
+
56
+ test("100% returns tween end", () => {
57
+ expect(percentageToAbsolute(100, 2, 3)).toBe(5);
58
+ });
59
+ });
60
+
61
+ describe("isTimeWithinTween", () => {
62
+ test("time inside returns true", () => {
63
+ expect(isTimeWithinTween(0.5, 0, 2)).toBe(true);
64
+ });
65
+
66
+ test("time at start returns true", () => {
67
+ expect(isTimeWithinTween(0, 0, 2)).toBe(true);
68
+ });
69
+
70
+ test("time at end returns true", () => {
71
+ expect(isTimeWithinTween(2, 0, 2)).toBe(true);
72
+ });
73
+
74
+ test("time before returns false", () => {
75
+ expect(isTimeWithinTween(-0.1, 0, 2)).toBe(false);
76
+ });
77
+
78
+ test("time after returns false", () => {
79
+ expect(isTimeWithinTween(2.1, 0, 2)).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe("resolveTweenStart", () => {
84
+ test("numeric position", () => {
85
+ expect(resolveTweenStart(makeAnim({ position: 1.5 }))).toBe(1.5);
86
+ });
87
+
88
+ test("parseable string position", () => {
89
+ expect(resolveTweenStart(makeAnim({ position: "2.5" }))).toBe(2.5);
90
+ });
91
+
92
+ test("unparseable string position returns null", () => {
93
+ expect(resolveTweenStart(makeAnim({ position: "myLabel" }))).toBeNull();
94
+ });
95
+
96
+ test("relative position +=0.5 returns null", () => {
97
+ expect(resolveTweenStart(makeAnim({ position: "+=0.5" }))).toBeNull();
98
+ });
99
+ });
100
+
101
+ describe("resolveTweenDuration", () => {
102
+ test("explicit duration", () => {
103
+ expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
104
+ });
105
+
106
+ test("missing duration defaults to 1", () => {
107
+ expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
108
+ });
109
+ });
110
+
111
+ describe("findTweenAtTime", () => {
112
+ const anims = [
113
+ makeAnim({ id: "#el-to-0", position: 0, duration: 0.5 }),
114
+ makeAnim({ id: "#el-to-1", position: 1, duration: 1 }),
115
+ makeAnim({
116
+ id: "#other-to-0",
117
+ targetSelector: "#other",
118
+ position: 0,
119
+ duration: 2,
120
+ }),
121
+ ];
122
+
123
+ test("finds tween at time within range", () => {
124
+ expect(findTweenAtTime(0.3, anims, "#el")?.id).toBe("#el-to-0");
125
+ });
126
+
127
+ test("finds second tween", () => {
128
+ expect(findTweenAtTime(1.5, anims, "#el")?.id).toBe("#el-to-1");
129
+ });
130
+
131
+ test("returns null for gap between tweens", () => {
132
+ expect(findTweenAtTime(0.7, anims, "#el")).toBeNull();
133
+ });
134
+
135
+ test("filters by selector", () => {
136
+ expect(findTweenAtTime(0.3, anims, "#other")?.id).toBe("#other-to-0");
137
+ });
138
+
139
+ test("returns null for unmatched selector", () => {
140
+ expect(findTweenAtTime(0.3, anims, "#missing")).toBeNull();
141
+ });
142
+
143
+ test("skips tweens with unresolvable string positions", () => {
144
+ const withLabel = [makeAnim({ id: "#el-to-0", position: "myLabel", duration: 1 })];
145
+ expect(findTweenAtTime(0.5, withLabel, "#el")).toBeNull();
146
+ });
147
+ });
148
+
149
+ describe("animation-level helpers", () => {
150
+ const anim = makeAnim({ position: 0.5, duration: 2 });
151
+
152
+ test("absoluteToPercentageForAnimation", () => {
153
+ expect(absoluteToPercentageForAnimation(1.5, anim)).toBe(50);
154
+ });
155
+
156
+ test("absoluteToPercentageForAnimation returns null for string position", () => {
157
+ const labelAnim = makeAnim({ position: "label" });
158
+ expect(absoluteToPercentageForAnimation(0.5, labelAnim)).toBeNull();
159
+ });
160
+
161
+ test("percentageToAbsoluteForAnimation", () => {
162
+ expect(percentageToAbsoluteForAnimation(50, anim)).toBe(1.5);
163
+ });
164
+
165
+ test("percentageToAbsoluteForAnimation returns null for string position", () => {
166
+ const labelAnim = makeAnim({ position: "+=1" });
167
+ expect(percentageToAbsoluteForAnimation(50, labelAnim)).toBeNull();
168
+ });
169
+ });
@@ -0,0 +1,77 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+
3
+ export function absoluteToPercentage(
4
+ time: number,
5
+ tweenStart: number,
6
+ tweenDuration: number,
7
+ ): number {
8
+ if (tweenDuration <= 0) return 0;
9
+ const raw = ((time - tweenStart) / tweenDuration) * 100;
10
+ return Math.max(0, Math.min(100, Math.round(raw * 10) / 10));
11
+ }
12
+
13
+ export function percentageToAbsolute(
14
+ pct: number,
15
+ tweenStart: number,
16
+ tweenDuration: number,
17
+ ): number {
18
+ return tweenStart + (pct / 100) * tweenDuration;
19
+ }
20
+
21
+ export function isTimeWithinTween(
22
+ time: number,
23
+ tweenStart: number,
24
+ tweenDuration: number,
25
+ ): boolean {
26
+ return time >= tweenStart && time <= tweenStart + tweenDuration;
27
+ }
28
+
29
+ export function resolveTweenStart(animation: GsapAnimation): number | null {
30
+ if (typeof animation.position === "number") return animation.position;
31
+ const parsed = Number.parseFloat(animation.position as string);
32
+ if (!Number.isNaN(parsed)) return parsed;
33
+ return null;
34
+ }
35
+
36
+ export function resolveTweenDuration(animation: GsapAnimation): number {
37
+ return animation.duration ?? 1;
38
+ }
39
+
40
+ export function findTweenAtTime(
41
+ time: number,
42
+ animations: GsapAnimation[],
43
+ selector: string,
44
+ ): GsapAnimation | null {
45
+ for (const anim of animations) {
46
+ if (!matchesSelector(anim.targetSelector, selector)) continue;
47
+ const start = resolveTweenStart(anim);
48
+ if (start === null) continue;
49
+ const duration = resolveTweenDuration(anim);
50
+ if (isTimeWithinTween(time, start, duration)) return anim;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ export function absoluteToPercentageForAnimation(
56
+ time: number,
57
+ animation: GsapAnimation,
58
+ ): number | null {
59
+ const start = resolveTweenStart(animation);
60
+ if (start === null) return null;
61
+ const duration = resolveTweenDuration(animation);
62
+ return absoluteToPercentage(time, start, duration);
63
+ }
64
+
65
+ export function percentageToAbsoluteForAnimation(
66
+ pct: number,
67
+ animation: GsapAnimation,
68
+ ): number | null {
69
+ const start = resolveTweenStart(animation);
70
+ if (start === null) return null;
71
+ const duration = resolveTweenDuration(animation);
72
+ return percentageToAbsolute(pct, start, duration);
73
+ }
74
+
75
+ function matchesSelector(tweenSelector: string, querySelector: string): boolean {
76
+ return tweenSelector.split(",").some((part) => part.trim() === querySelector);
77
+ }