@hyperframes/studio 0.6.97 → 0.6.98

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/index-Ce3pBm_I.js +252 -0
  4. package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
  5. package/dist/assets/index-D-bS9Dxx.js +1 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-Cfye9xzo.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -2,18 +2,12 @@ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
4
 
5
- export interface StudioContextValue {
5
+ export interface StudioShellValue {
6
6
  projectId: string;
7
7
  activeCompPath: string | null;
8
8
  setActiveCompPath: (path: string | null) => void;
9
9
  showToast: (message: string, tone?: "error" | "info") => void;
10
10
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
11
- captionEditMode: boolean;
12
- compositionLoading: boolean;
13
- refreshKey: number;
14
- setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
15
- timelineElements: TimelineElement[];
16
- isPlaying: boolean;
17
11
  editHistory: {
18
12
  canUndo: boolean;
19
13
  canRedo: boolean;
@@ -32,24 +26,49 @@ export interface StudioContextValue {
32
26
  compositionDimensions: CompositionDimensions | null;
33
27
  waitForPendingDomEditSaves: () => Promise<void>;
34
28
  handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
35
- refreshPreviewDocumentVersion: () => void;
36
29
  timelineVisible: boolean;
37
30
  toggleTimelineVisibility: () => void;
38
31
  }
39
32
 
40
- const StudioContext = createContext<StudioContextValue | null>(null);
33
+ export interface StudioPlaybackValue {
34
+ captionEditMode: boolean;
35
+ compositionLoading: boolean;
36
+ refreshKey: number;
37
+ setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
38
+ timelineElements: TimelineElement[];
39
+ isPlaying: boolean;
40
+ refreshPreviewDocumentVersion: () => void;
41
+ }
42
+
43
+ export type StudioContextValue = StudioShellValue & StudioPlaybackValue;
41
44
 
42
- export function useStudioContext(): StudioContextValue {
43
- const ctx = useContext(StudioContext);
44
- if (!ctx) throw new Error("useStudioContext must be used within StudioProvider");
45
+ const StudioShellContext = createContext<StudioShellValue | null>(null);
46
+ const StudioPlaybackContext = createContext<StudioPlaybackValue | null>(null);
47
+
48
+ export function useStudioShellContext(): StudioShellValue {
49
+ const ctx = useContext(StudioShellContext);
50
+ if (!ctx) throw new Error("useStudioShellContext must be used within StudioShellProvider");
45
51
  return ctx;
46
52
  }
47
53
 
48
- export function StudioProvider({
54
+ export function useStudioPlaybackContext(): StudioPlaybackValue {
55
+ const ctx = useContext(StudioPlaybackContext);
56
+ if (!ctx) throw new Error("useStudioPlaybackContext must be used within StudioPlaybackProvider");
57
+ return ctx;
58
+ }
59
+
60
+ /** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
61
+ export function useStudioContext(): StudioContextValue {
62
+ const shell = useStudioShellContext();
63
+ const playback = useStudioPlaybackContext();
64
+ return useMemo(() => ({ ...shell, ...playback }), [shell, playback]);
65
+ }
66
+
67
+ export function StudioShellProvider({
49
68
  value,
50
69
  children,
51
70
  }: {
52
- value: StudioContextValue;
71
+ value: StudioShellValue;
53
72
  children: ReactNode;
54
73
  }) {
55
74
  const {
@@ -58,12 +77,6 @@ export function StudioProvider({
58
77
  setActiveCompPath,
59
78
  showToast,
60
79
  previewIframeRef,
61
- captionEditMode,
62
- compositionLoading,
63
- refreshKey,
64
- setRefreshKey,
65
- timelineElements,
66
- isPlaying,
67
80
  editHistory,
68
81
  handleUndo,
69
82
  handleRedo,
@@ -71,24 +84,17 @@ export function StudioProvider({
71
84
  compositionDimensions,
72
85
  waitForPendingDomEditSaves,
73
86
  handlePreviewIframeRef,
74
- refreshPreviewDocumentVersion,
75
87
  timelineVisible,
76
88
  toggleTimelineVisibility,
77
89
  } = value;
78
90
 
79
- const stable = useMemo<StudioContextValue>(
91
+ const stable = useMemo<StudioShellValue>(
80
92
  () => ({
81
93
  projectId,
82
94
  activeCompPath,
83
95
  setActiveCompPath,
84
96
  showToast,
85
97
  previewIframeRef,
86
- captionEditMode,
87
- compositionLoading,
88
- refreshKey,
89
- setRefreshKey,
90
- timelineElements,
91
- isPlaying,
92
98
  editHistory,
93
99
  handleUndo,
94
100
  handleRedo,
@@ -96,36 +102,80 @@ export function StudioProvider({
96
102
  compositionDimensions,
97
103
  waitForPendingDomEditSaves,
98
104
  handlePreviewIframeRef,
99
- refreshPreviewDocumentVersion,
100
105
  timelineVisible,
101
106
  toggleTimelineVisibility,
102
107
  }),
103
- // Representative subset of deps that actually change — stable callbacks
104
- // (showToast, setActiveCompPath, etc.) are included for correctness but
105
- // won't trigger re-renders on their own.
106
108
  [
107
109
  projectId,
108
110
  activeCompPath,
109
- captionEditMode,
110
- compositionLoading,
111
- refreshKey,
112
- isPlaying,
113
111
  compositionDimensions,
114
112
  timelineVisible,
115
113
  editHistory,
116
- timelineElements,
117
114
  renderQueue,
118
115
  setActiveCompPath,
119
116
  showToast,
120
117
  previewIframeRef,
121
- setRefreshKey,
122
118
  handleUndo,
123
119
  handleRedo,
124
120
  waitForPendingDomEditSaves,
125
121
  handlePreviewIframeRef,
126
- refreshPreviewDocumentVersion,
127
122
  toggleTimelineVisibility,
128
123
  ],
129
124
  );
130
- return <StudioContext value={stable}>{children}</StudioContext>;
125
+ return <StudioShellContext value={stable}>{children}</StudioShellContext>;
126
+ }
127
+
128
+ export function StudioPlaybackProvider({
129
+ value,
130
+ children,
131
+ }: {
132
+ value: StudioPlaybackValue;
133
+ children: ReactNode;
134
+ }) {
135
+ const {
136
+ captionEditMode,
137
+ compositionLoading,
138
+ refreshKey,
139
+ setRefreshKey,
140
+ timelineElements,
141
+ isPlaying,
142
+ refreshPreviewDocumentVersion,
143
+ } = value;
144
+
145
+ const stable = useMemo<StudioPlaybackValue>(
146
+ () => ({
147
+ captionEditMode,
148
+ compositionLoading,
149
+ refreshKey,
150
+ setRefreshKey,
151
+ timelineElements,
152
+ isPlaying,
153
+ refreshPreviewDocumentVersion,
154
+ }),
155
+ [
156
+ captionEditMode,
157
+ compositionLoading,
158
+ refreshKey,
159
+ timelineElements,
160
+ isPlaying,
161
+ setRefreshKey,
162
+ refreshPreviewDocumentVersion,
163
+ ],
164
+ );
165
+ return <StudioPlaybackContext value={stable}>{children}</StudioPlaybackContext>;
166
+ }
167
+
168
+ /** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */
169
+ export function StudioProvider({
170
+ value,
171
+ children,
172
+ }: {
173
+ value: StudioContextValue;
174
+ children: ReactNode;
175
+ }) {
176
+ return (
177
+ <StudioShellProvider value={value}>
178
+ <StudioPlaybackProvider value={value}>{children}</StudioPlaybackProvider>
179
+ </StudioShellProvider>
180
+ );
131
181
  }
@@ -0,0 +1,47 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+ import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
+
4
+ const TimelineEditContext = createContext<TimelineEditCallbacks | null>(null);
5
+
6
+ export function useTimelineEditContext(): TimelineEditCallbacks {
7
+ const ctx = useContext(TimelineEditContext);
8
+ if (!ctx) throw new Error("useTimelineEditContext must be used within TimelineEditProvider");
9
+ return ctx;
10
+ }
11
+
12
+ /**
13
+ * Optional access — returns an empty object when outside a provider.
14
+ * Useful in components that can render both inside and outside the NLE.
15
+ */
16
+ export function useTimelineEditContextOptional(): TimelineEditCallbacks {
17
+ return useContext(TimelineEditContext) ?? {};
18
+ }
19
+
20
+ export function TimelineEditProvider({
21
+ value,
22
+ children,
23
+ }: {
24
+ value: TimelineEditCallbacks;
25
+ children: ReactNode;
26
+ }) {
27
+ const memoized = useMemo(
28
+ () => value,
29
+ // Each callback is a stable reference from the parent — memoize the bag
30
+ // so consumers don't re-render when unrelated parent state changes.
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ [
33
+ value.onMoveElement,
34
+ value.onResizeElement,
35
+ value.onBlockedEditAttempt,
36
+ value.onSplitElement,
37
+ value.onRazorSplit,
38
+ value.onRazorSplitAll,
39
+ value.onDeleteKeyframe,
40
+ value.onDeleteAllKeyframes,
41
+ value.onChangeKeyframeEase,
42
+ value.onMoveKeyframe,
43
+ value.onToggleKeyframeAtPlayhead,
44
+ ],
45
+ );
46
+ return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
47
+ }
@@ -0,0 +1,14 @@
1
+ import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import type { PatchOperation } from "../utils/sourcePatcher";
3
+
4
+ export type PersistDomEditOperations = (
5
+ selection: DomEditSelection,
6
+ operations: PatchOperation[],
7
+ options?: {
8
+ label?: string;
9
+ coalesceKey?: string;
10
+ skipRefresh?: boolean;
11
+ prepareContent?: (html: string, sourceFile: string) => string;
12
+ shouldSave?: () => boolean;
13
+ },
14
+ ) => Promise<void>;
@@ -6,11 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
9
- import {
10
- absoluteToPercentage,
11
- resolveTweenStart,
12
- resolveTweenDuration,
13
- } from "../utils/globalTimeCompiler";
9
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
10
+ import { roundTo3 } from "../utils/rounding";
11
+ import { computeElementPercentage } from "./gsapShared";
14
12
  export interface GsapDragCommitCallbacks {
15
13
  commitMutation: (
16
14
  selection: DomEditSelection,
@@ -26,25 +24,12 @@ export interface GsapDragCommitCallbacks {
26
24
  fetchAnimations?: () => Promise<GsapAnimation[]>;
27
25
  }
28
26
 
29
- // ── Percentage computation ─────────────────────────────────────────────────
30
-
27
+ // Re-export for backward compatibility with existing imports.
31
28
  export function computeCurrentPercentage(
32
29
  selection: DomEditSelection,
33
30
  animation?: GsapAnimation,
34
31
  ): number {
35
- const currentTime = usePlayerStore.getState().currentTime;
36
- if (animation) {
37
- const start = resolveTweenStart(animation);
38
- const duration = resolveTweenDuration(animation);
39
- if (start !== null) {
40
- return absoluteToPercentage(currentTime, start, duration);
41
- }
42
- }
43
- const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
44
- const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
45
- return elDuration > 0
46
- ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
47
- : 0;
32
+ return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
48
33
  }
49
34
 
50
35
  // ── Dynamic keyframe materialization ──────────────────────────────────────
@@ -133,8 +118,8 @@ async function extendTweenAndAddKeyframe(
133
118
  type: "replace-with-keyframes",
134
119
  animationId: anim.id,
135
120
  targetSelector: anim.targetSelector,
136
- position: Math.round(newStart * 1000) / 1000,
137
- duration: Math.round(newDuration * 1000) / 1000,
121
+ position: roundTo3(newStart),
122
+ duration: roundTo3(newDuration),
138
123
  keyframes: remappedKfs,
139
124
  },
140
125
  { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
@@ -330,8 +315,8 @@ export async function commitGsapPositionFromDrag(
330
315
  {
331
316
  type: "add-with-keyframes",
332
317
  targetSelector: anim.targetSelector,
333
- position: Math.round(newStart * 1000) / 1000,
334
- duration: Math.round(newDuration * 1000) / 1000,
318
+ position: roundTo3(newStart),
319
+ duration: roundTo3(newDuration),
335
320
  keyframes,
336
321
  },
337
322
  { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
+ import { toAbsoluteTime } from "./gsapShared";
7
8
 
8
9
  export function updateKeyframeCacheFromParsed(
9
10
  animations: GsapAnimation[],
@@ -29,7 +30,7 @@ export function updateKeyframeCacheFromParsed(
29
30
  const elStart = timelineEl?.start ?? 0;
30
31
  const elDuration = timelineEl?.duration ?? 1;
31
32
  const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
32
- const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
33
+ const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
33
34
  const clipPct =
34
35
  elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
35
36
  return {
@@ -1,18 +1,8 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4
-
5
- const PROPERTY_DEFAULTS: Record<string, number> = {
6
- opacity: 1,
7
- x: 0,
8
- y: 0,
9
- scale: 1,
10
- scaleX: 1,
11
- scaleY: 1,
12
- rotation: 0,
13
- width: 100,
14
- height: 100,
15
- };
4
+ import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared";
5
+ import { roundToCenti } from "../utils/rounding";
16
6
 
17
7
  type CommitFn = (
18
8
  selection: DomEditSelection,
@@ -32,7 +22,7 @@ export async function commitKeyframeAtTimeImpl(
32
22
  properties: Record<string, number | string>,
33
23
  commitMutation: CommitFn,
34
24
  ): Promise<void> {
35
- const selector = selection.id ? `#${selection.id}` : selection.selector;
25
+ const selector = selectorFromSelection(selection);
36
26
  if (!selector) return;
37
27
 
38
28
  const tween = findTweenAtTime(absoluteTime, animations, selector);
@@ -64,7 +54,7 @@ export async function commitKeyframeAtTimeImpl(
64
54
  backfillDefaults,
65
55
  },
66
56
  {
67
- label: `Add keyframe at ${Math.round(absoluteTime * 100) / 100}s`,
57
+ label: `Add keyframe at ${roundToCenti(absoluteTime)}s`,
68
58
  coalesceKey: `keyframe:${tween.id}:${pct}`,
69
59
  softReload: true,
70
60
  },
@@ -84,7 +74,7 @@ export async function commitKeyframeAtTimeImpl(
84
74
  ],
85
75
  },
86
76
  {
87
- label: `New animation at ${Math.round(absoluteTime * 100) / 100}s`,
77
+ label: `New animation at ${roundToCenti(absoluteTime)}s`,
88
78
  softReload: true,
89
79
  },
90
80
  );
@@ -20,37 +20,20 @@ import {
20
20
  } from "./gsapDragCommit";
21
21
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
22
22
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
23
+ import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared";
24
+ import { roundTo3 } from "../utils/rounding";
23
25
 
24
26
  // ── Runtime reads ──────────────────────────────────────────────────────────
25
27
 
26
- interface IframeGsap {
27
- getProperty: (el: Element, prop: string) => number;
28
- }
29
-
30
28
  // fallow-ignore-next-line complexity
31
29
  function readGsapPositionFromIframe(
32
30
  iframe: HTMLIFrameElement | null,
33
31
  elementSelector: string,
34
32
  ): { x: number; y: number } | null {
35
- if (!iframe?.contentWindow) return null;
36
-
37
- let gsap: IframeGsap | undefined;
38
- try {
39
- gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
40
- } catch {
41
- return null;
42
- }
43
- if (!gsap?.getProperty) return null;
44
-
45
- let doc: Document | null = null;
46
- try {
47
- doc = iframe.contentDocument;
48
- } catch {
49
- return null;
50
- }
51
- if (!doc) return null;
33
+ const gsap = getIframeGsap(iframe);
34
+ if (!gsap) return null;
52
35
 
53
- const element = doc.querySelector(elementSelector);
36
+ const element = queryIframeElement(iframe, elementSelector);
54
37
  if (!element) return null;
55
38
 
56
39
  const x = Number(gsap.getProperty(element, "x")) || 0;
@@ -99,12 +82,6 @@ function findGsapPositionAnimation(
99
82
 
100
83
  // ── Selector resolution ────────────────────────────────────────────────────
101
84
 
102
- function selectorForSelection(selection: DomEditSelection): string | null {
103
- if (selection.id) return `#${selection.id}`;
104
- if (selection.selector) return selection.selector;
105
- return null;
106
- }
107
-
108
85
  // ── Property-group tween resolution ───────────────────────────────────────
109
86
 
110
87
  /**
@@ -193,7 +170,7 @@ export async function tryGsapDragIntercept(
193
170
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
194
171
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
195
172
  ): Promise<boolean> {
196
- const selector = selectorForSelection(selection);
173
+ const selector = selectorFromSelection(selection);
197
174
  if (!selector) return false;
198
175
 
199
176
  // Resolve the position-group tween, splitting legacy mixed tweens if needed.
@@ -284,15 +261,15 @@ export async function tryGsapResizeIntercept(
284
261
  const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
285
262
  const ct = usePlayerStore.getState().currentTime;
286
263
  const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
287
- const sel = selectorForSelection(selection);
264
+ const sel = selectorFromSelection(selection);
288
265
  if (!sel) return false;
289
266
  await commitMutation(
290
267
  selection,
291
268
  {
292
269
  type: "add-with-keyframes",
293
270
  targetSelector: sel,
294
- position: Math.round(elStart * 1000) / 1000,
295
- duration: Math.round(elDuration * 1000) / 1000,
271
+ position: roundTo3(elStart),
272
+ duration: roundTo3(elDuration),
296
273
  keyframes: [
297
274
  {
298
275
  percentage: Math.max(0, Math.min(100, pct)),
@@ -310,7 +287,7 @@ export async function tryGsapResizeIntercept(
310
287
  if (activeKeyframePct != null) setActiveKeyframePct(null);
311
288
  const coalesceKey = `gsap:resize:${anim.id}`;
312
289
 
313
- const selector = selectorForSelection(selection);
290
+ const selector = selectorFromSelection(selection);
314
291
  const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
315
292
 
316
293
  let resizeProps: Record<string, number>;
@@ -320,7 +297,7 @@ export async function tryGsapResizeIntercept(
320
297
  // saved by the draft system before it ran.
321
298
  const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? "");
322
299
  const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200;
323
- const newScale = Math.round((size.width / cssW) * 1000) / 1000;
300
+ const newScale = roundTo3(size.width / cssW);
324
301
  resizeProps = { scale: newScale };
325
302
  } else {
326
303
  resizeProps = {
@@ -395,8 +372,8 @@ export async function tryGsapResizeIntercept(
395
372
  type: "replace-with-keyframes",
396
373
  animationId: anim.id,
397
374
  targetSelector: anim.targetSelector,
398
- position: Math.round(newStart * 1000) / 1000,
399
- duration: Math.round(newDuration * 1000) / 1000,
375
+ position: roundTo3(newStart),
376
+ duration: roundTo3(newDuration),
400
377
  keyframes: remapped,
401
378
  },
402
379
  { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey },
@@ -455,25 +432,14 @@ export async function tryGsapRotationIntercept(
455
432
  }
456
433
  if (!anim) return false;
457
434
 
458
- const selector = selectorForSelection(selection);
435
+ const selector = selectorFromSelection(selection);
459
436
  if (!selector) return false;
460
437
 
461
438
  let gsapRotation = 0;
462
- if (iframe?.contentWindow) {
463
- try {
464
- const gsap = (
465
- iframe.contentWindow as unknown as {
466
- gsap?: { getProperty: (el: Element, prop: string) => number };
467
- }
468
- ).gsap;
469
- const doc = iframe.contentDocument;
470
- const el = doc?.querySelector(selector);
471
- if (gsap?.getProperty && el) {
472
- gsapRotation = Number(gsap.getProperty(el, "rotation")) || 0;
473
- }
474
- } catch {
475
- /* cross-origin guard */
476
- }
439
+ const gsap = getIframeGsap(iframe);
440
+ const rotEl = gsap ? queryIframeElement(iframe, selector) : null;
441
+ if (gsap && rotEl) {
442
+ gsapRotation = Number(gsap.getProperty(rotEl, "rotation")) || 0;
477
443
  }
478
444
 
479
445
  const pct = computeCurrentPercentage(selection, anim);
@@ -3,6 +3,8 @@
3
3
  * Used to discover dynamic keyframes that the AST parser can't resolve
4
4
  * (loops, variables, computed selectors).
5
5
  */
6
+ import { parsePercentageKeyframes } from "./gsapShared";
7
+ import { roundTo3 } from "../utils/rounding";
6
8
 
7
9
  interface RuntimeTween {
8
10
  targets?: () => Element[];
@@ -66,33 +68,8 @@ export function readRuntimeKeyframes(
66
68
  const vars = tween.vars;
67
69
  if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
68
70
 
69
- const kfObj = vars.keyframes as Record<string, unknown>;
70
- const result: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
71
- let easeEach: string | undefined;
72
-
73
- for (const [key, val] of Object.entries(kfObj)) {
74
- if (key === "easeEach") {
75
- if (typeof val === "string") easeEach = val;
76
- continue;
77
- }
78
- const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
79
- if (!pctMatch || !val || typeof val !== "object") continue;
80
- const percentage = parseFloat(pctMatch[1]);
81
- const properties: Record<string, number | string> = {};
82
- for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
83
- if (pk === "ease") continue;
84
- if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
85
- else if (typeof pv === "string") properties[pk] = pv;
86
- }
87
- if (Object.keys(properties).length > 0) {
88
- result.push({ percentage, properties });
89
- }
90
- }
91
-
92
- if (result.length > 0) {
93
- result.sort((a, b) => a.percentage - b.percentage);
94
- return { keyframes: result, easeEach };
95
- }
71
+ const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
72
+ if (parsed) return parsed;
96
73
  }
97
74
  return null;
98
75
  }
@@ -133,38 +110,12 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
133
110
  const vars = tween.vars;
134
111
 
135
112
  if (vars.keyframes && typeof vars.keyframes === "object") {
136
- const kfObj = vars.keyframes as Record<string, unknown>;
137
- const keyframes: Array<{
138
- percentage: number;
139
- properties: Record<string, number | string>;
140
- }> = [];
141
- let easeEach: string | undefined;
142
-
143
- for (const [key, val] of Object.entries(kfObj)) {
144
- if (key === "easeEach") {
145
- if (typeof val === "string") easeEach = val;
146
- continue;
147
- }
148
- const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
149
- if (!pctMatch || !val || typeof val !== "object") continue;
150
- const percentage = parseFloat(pctMatch[1]);
151
- const properties: Record<string, number | string> = {};
152
- for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
153
- if (pk === "ease") continue;
154
- if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
155
- else if (typeof pv === "string") properties[pk] = pv;
156
- }
157
- if (Object.keys(properties).length > 0) {
158
- keyframes.push({ percentage, properties });
159
- }
160
- }
161
-
162
- if (keyframes.length > 0) {
163
- keyframes.sort((a, b) => a.percentage - b.percentage);
113
+ const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
114
+ if (parsed) {
164
115
  for (const target of tween.targets()) {
165
116
  const id = (target as HTMLElement).id;
166
117
  if (id && !result.has(id)) {
167
- result.set(id, { keyframes, easeEach });
118
+ result.set(id, parsed);
168
119
  }
169
120
  }
170
121
  continue;
@@ -195,7 +146,7 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
195
146
  ]);
196
147
  for (const [k, v] of Object.entries(vars)) {
197
148
  if (skip.has(k)) continue;
198
- if (typeof v === "number") properties[k] = Math.round(v * 1000) / 1000;
149
+ if (typeof v === "number") properties[k] = roundTo3(v);
199
150
  else if (typeof v === "string") properties[k] = v;
200
151
  }
201
152
  if (Object.keys(properties).length === 0) continue;