@hyperframes/studio 0.6.96 → 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-BWFaypdT.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-0esDKGRk.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-BA979yF1.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
@@ -5,9 +5,9 @@ import {
5
5
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
6
6
  } from "./editor/manualEditingAvailability";
7
7
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
8
- import { useStudioContext } from "../contexts/StudioContext";
8
+ import { useStudioShellContext } from "../contexts/StudioContext";
9
9
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
10
- import { useDomEditContext } from "../contexts/DomEditContext";
10
+ import { useDomEditActionsContext } from "../contexts/DomEditContext";
11
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
12
 
13
13
  export interface StudioHeaderProps {
@@ -150,9 +150,9 @@ export function StudioHeader({
150
150
  inspectorPanelActive,
151
151
  onExport,
152
152
  }: StudioHeaderProps) {
153
- const { projectId, editHistory, handleUndo, handleRedo } = useStudioContext();
153
+ const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
154
154
  const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
155
- const { clearDomSelection } = useDomEditContext();
155
+ const { clearDomSelection } = useDomEditActionsContext();
156
156
 
157
157
  return (
158
158
  <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -4,7 +4,7 @@ import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar";
4
4
  import { MediaPreview } from "./MediaPreview";
5
5
  import { isMediaFile } from "../utils/mediaTypes";
6
6
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
7
- import { useStudioContext } from "../contexts/StudioContext";
7
+ import { useStudioShellContext } from "../contexts/StudioContext";
8
8
  import { useFileManagerContext } from "../contexts/FileManagerContext";
9
9
  import { getPersistedRenderSettings } from "./renders/renderSettings";
10
10
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
@@ -39,7 +39,7 @@ export function StudioLeftSidebar({
39
39
  handlePanelResizeMove,
40
40
  handlePanelResizeEnd,
41
41
  } = usePanelLayoutContext();
42
- const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioContext();
42
+ const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
43
43
  const {
44
44
  compositions,
45
45
  assets,
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from "react";
1
+ import { useState, useMemo, type ReactNode } from "react";
2
2
  import { NLELayout } from "./nle/NLELayout";
3
3
  import { CaptionOverlay } from "../captions/components/CaptionOverlay";
4
4
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
@@ -13,10 +13,13 @@ import {
13
13
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
14
14
  STUDIO_PREVIEW_SELECTION_ENABLED,
15
15
  } from "./editor/manualEditingAvailability";
16
- import { useStudioContext } from "../contexts/StudioContext";
16
+ import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
17
17
  import { useDomEditContext } from "../contexts/DomEditContext";
18
+ import { TimelineEditProvider } from "../contexts/TimelineEditContext";
18
19
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
19
20
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
21
+ import { fetchParsedAnimations } from "../hooks/useGsapTweenCache";
22
+ import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove";
20
23
  import type { GestureRecordingState } from "./editor/GestureRecordControl";
21
24
 
22
25
  export interface StudioPreviewAreaProps {
@@ -91,18 +94,20 @@ export function StudioPreviewArea({
91
94
  }: StudioPreviewAreaProps) {
92
95
  const {
93
96
  projectId,
94
- refreshKey,
95
97
  activeCompPath,
96
98
  setActiveCompPath,
97
- captionEditMode,
98
- compositionLoading,
99
- isPlaying,
100
99
  previewIframeRef,
101
- refreshPreviewDocumentVersion,
102
100
  handlePreviewIframeRef,
103
101
  timelineVisible,
104
102
  toggleTimelineVisibility,
105
- } = useStudioContext();
103
+ } = useStudioShellContext();
104
+ const {
105
+ refreshKey,
106
+ captionEditMode,
107
+ compositionLoading,
108
+ isPlaying,
109
+ refreshPreviewDocumentVersion,
110
+ } = useStudioPlaybackContext();
106
111
 
107
112
  const {
108
113
  domEditHoverSelection,
@@ -125,6 +130,7 @@ export function StudioPreviewArea({
125
130
  handleGsapAddKeyframe,
126
131
  handleGsapConvertToKeyframes,
127
132
  handleGsapDeleteAllForElement,
133
+ buildDomSelectionForTimelineElement,
128
134
  } = useDomEditContext();
129
135
 
130
136
  const [snapPrefs, setSnapPrefs] = useState(() => {
@@ -137,187 +143,223 @@ export function StudioPreviewArea({
137
143
  };
138
144
  });
139
145
 
146
+ // fallow-ignore-next-line complexity
147
+ const timelineEditCallbacks = useMemo(
148
+ () => ({
149
+ onMoveElement: handleTimelineElementMove,
150
+ onResizeElement: handleTimelineElementResize,
151
+ onBlockedEditAttempt: handleBlockedTimelineEdit,
152
+ onSplitElement: handleTimelineElementSplit,
153
+ onRazorSplit: handleRazorSplit,
154
+ onRazorSplitAll: handleRazorSplitAll,
155
+ onDeleteAllKeyframes: (elId: string) => {
156
+ const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId;
157
+ handleGsapDeleteAllForElement(`#${rawId}`);
158
+ },
159
+ onDeleteKeyframe: (_elId: string, pct: number) => {
160
+ const cacheKey = domEditSelection?.id ?? "";
161
+ const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
162
+ const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
163
+ const group = kf?.propertyGroup;
164
+ const anim =
165
+ (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
166
+ selectedGsapAnimations.find((a) => a.keyframes);
167
+ if (!anim) return;
168
+ handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct);
169
+ },
170
+ onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
171
+ for (const anim of selectedGsapAnimations) {
172
+ if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
173
+ }
174
+ },
175
+ // fallow-ignore-next-line complexity
176
+ onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => {
177
+ // Resolve the dragged element's selection + parsed animations on demand
178
+ // (both awaited and cached) rather than relying on the async DOM-edit
179
+ // session being loaded for this element — that coupling made the commit
180
+ // intermittently no-op (revert) when dragging before the session caught up.
181
+ if (!projectId) return;
182
+ const sourceFile = _el.sourceFile || activeCompPath || "index.html";
183
+ const [selection, parsed] = await Promise.all([
184
+ buildDomSelectionForTimelineElement(_el),
185
+ fetchParsedAnimations(projectId, sourceFile),
186
+ ]);
187
+ if (!selection || !parsed) return;
188
+
189
+ const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id);
190
+ const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
191
+ const origAbsTime = _el.start + (oldPct / 100) * _el.duration;
192
+ const anim = pickKeyframeTween(
193
+ parsed.animations,
194
+ _el,
195
+ origAbsTime,
196
+ cachedKf?.propertyGroup,
197
+ );
198
+ if (!anim) return;
199
+
200
+ const plan = computeKeyframeMovePlan(
201
+ anim,
202
+ cachedKf?.tweenPercentage ?? oldPct,
203
+ _el,
204
+ newPct,
205
+ );
206
+ if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection);
207
+ for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection);
208
+ for (const add of plan.adds) {
209
+ for (const [prop, val] of Object.entries(add.properties)) {
210
+ handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection);
211
+ }
212
+ }
213
+ },
214
+ onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
215
+ const currentTime = usePlayerStore.getState().currentTime;
216
+ const pct =
217
+ el.duration > 0
218
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
219
+ : 0;
220
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
221
+ if (anim?.keyframes) {
222
+ const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
223
+ if (existing) {
224
+ handleGsapRemoveKeyframe(anim.id, existing.percentage);
225
+ } else {
226
+ handleGsapAddKeyframe(anim.id, pct, "x", 0);
227
+ }
228
+ } else {
229
+ const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
230
+ if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
231
+ }
232
+ },
233
+ }),
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ [
236
+ handleTimelineElementMove,
237
+ handleTimelineElementResize,
238
+ handleBlockedTimelineEdit,
239
+ handleTimelineElementSplit,
240
+ handleRazorSplit,
241
+ handleRazorSplitAll,
242
+ handleGsapDeleteAllForElement,
243
+ domEditSelection?.id,
244
+ selectedGsapAnimations,
245
+ handleGsapRemoveKeyframe,
246
+ handleGsapUpdateMeta,
247
+ handleGsapAddKeyframe,
248
+ handleGsapConvertToKeyframes,
249
+ buildDomSelectionForTimelineElement,
250
+ projectId,
251
+ activeCompPath,
252
+ ],
253
+ );
254
+
140
255
  return (
141
256
  <div className="flex-1 flex flex-col relative min-w-0">
142
257
  <div className="flex-1 min-h-0 relative">
143
- <NLELayout
144
- projectId={projectId}
145
- refreshKey={refreshKey}
146
- activeCompositionPath={activeCompPath}
147
- timelineToolbar={timelineToolbar}
148
- renderClipContent={renderClipContent}
149
- onDeleteElement={handleTimelineElementDelete}
150
- onAssetDrop={handleTimelineAssetDrop}
151
- onBlockDrop={handleTimelineBlockDrop}
152
- onPreviewBlockDrop={handlePreviewBlockDrop}
153
- onFileDrop={handleTimelineFileDrop}
154
- onMoveElement={handleTimelineElementMove}
155
- onResizeElement={handleTimelineElementResize}
156
- onBlockedEditAttempt={handleBlockedTimelineEdit}
157
- onSplitElement={handleTimelineElementSplit}
158
- onRazorSplit={handleRazorSplit}
159
- onRazorSplitAll={handleRazorSplitAll}
160
- onSelectTimelineElement={handleTimelineElementSelect}
161
- onDeleteAllKeyframes={(elId) => {
162
- const rawId = elId.includes("#") ? elId.split("#").pop()! : elId;
163
- handleGsapDeleteAllForElement(`#${rawId}`);
164
- }}
165
- onDeleteKeyframe={(_elId, pct) => {
166
- const cacheKey = domEditSelection?.id ?? "";
167
- const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
168
- const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
169
- const group = kf?.propertyGroup;
170
- const anim =
171
- (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
172
- selectedGsapAnimations.find((a) => a.keyframes);
173
- if (!anim) return;
174
- handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct);
175
- }}
176
- onChangeKeyframeEase={(_elId, _pct, ease) => {
177
- for (const anim of selectedGsapAnimations) {
178
- if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
179
- }
180
- }}
181
- // fallow-ignore-next-line complexity
182
- onMoveKeyframe={(_el, oldPct, newPct) => {
183
- const cacheKey = domEditSelection?.id ?? "";
184
- const cached = usePlayerStore.getState().keyframeCache.get(cacheKey);
185
- const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2);
186
- const group = cachedKf?.propertyGroup;
187
- const anim =
188
- (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
189
- selectedGsapAnimations.find((a) => a.keyframes);
190
- if (!anim?.keyframes) return;
191
- const tweenOldPct = cachedKf?.tweenPercentage ?? oldPct;
192
- const kf = anim.keyframes.keyframes.find(
193
- (k) => Math.abs(k.percentage - tweenOldPct) < 0.2,
194
- );
195
- if (!kf) return;
196
- const tweenStart = anim.resolvedStart ?? 0;
197
- const tweenDur = anim.duration ?? 1;
198
- const newAbsTime = _el.start + (newPct / 100) * _el.duration;
199
- const tweenNewPct =
200
- tweenDur > 0
201
- ? Math.max(
202
- 0,
203
- Math.min(100, Math.round(((newAbsTime - tweenStart) / tweenDur) * 1000) / 10),
204
- )
205
- : 0;
206
- handleGsapRemoveKeyframe(anim.id, tweenOldPct);
207
- for (const [prop, val] of Object.entries(kf.properties)) {
208
- handleGsapAddKeyframe(anim.id, tweenNewPct, prop, val);
209
- }
210
- }}
211
- onToggleKeyframeAtPlayhead={(el) => {
212
- const currentTime = usePlayerStore.getState().currentTime;
213
- const pct =
214
- el.duration > 0
215
- ? Math.max(
216
- 0,
217
- Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
218
- )
219
- : 0;
220
- const anim = selectedGsapAnimations.find((a) => a.keyframes);
221
- if (anim?.keyframes) {
222
- const existing = anim.keyframes.keyframes.find(
223
- (k) => Math.abs(k.percentage - pct) <= 1,
224
- );
225
- if (existing) {
226
- handleGsapRemoveKeyframe(anim.id, existing.percentage);
227
- } else {
228
- handleGsapAddKeyframe(anim.id, pct, "x", 0);
258
+ <TimelineEditProvider value={timelineEditCallbacks}>
259
+ <NLELayout
260
+ projectId={projectId}
261
+ refreshKey={refreshKey}
262
+ activeCompositionPath={activeCompPath}
263
+ timelineToolbar={timelineToolbar}
264
+ renderClipContent={renderClipContent}
265
+ onDeleteElement={handleTimelineElementDelete}
266
+ onAssetDrop={handleTimelineAssetDrop}
267
+ onBlockDrop={handleTimelineBlockDrop}
268
+ onPreviewBlockDrop={handlePreviewBlockDrop}
269
+ onFileDrop={handleTimelineFileDrop}
270
+ onSelectTimelineElement={handleTimelineElementSelect}
271
+ onCompIdToSrcChange={setCompIdToSrc}
272
+ onCompositionLoadingChange={setCompositionLoading}
273
+ onCompositionChange={(compPath) => {
274
+ // Sync activeCompPath when user drills down via timeline double-click
275
+ // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
276
+ // Guard against no-op updates to prevent circular refresh cascades
277
+ // between activeCompPath compositionStack → onCompositionChange.
278
+ if (compPath !== activeCompPath) {
279
+ setActiveCompPath(compPath);
280
+ refreshPreviewDocumentVersion();
229
281
  }
230
- } else {
231
- const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
232
- if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
233
- }
234
- }}
235
- onCompIdToSrcChange={setCompIdToSrc}
236
- onCompositionLoadingChange={setCompositionLoading}
237
- onCompositionChange={(compPath) => {
238
- // Sync activeCompPath when user drills down via timeline double-click
239
- // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
240
- // Guard against no-op updates to prevent circular refresh cascades
241
- // between activeCompPath → compositionStack → onCompositionChange.
242
- if (compPath !== activeCompPath) {
243
- setActiveCompPath(compPath);
244
- refreshPreviewDocumentVersion();
245
- }
246
- }}
247
- onIframeRef={handlePreviewIframeRef}
248
- previewOverlay={
249
- blockPreview ? (
250
- <div className="absolute inset-0 z-30 bg-black pointer-events-none">
251
- {blockPreview.videoUrl ? (
252
- <video
253
- src={blockPreview.videoUrl}
254
- autoPlay
255
- muted
256
- loop
257
- playsInline
258
- className="w-full h-full object-contain"
259
- />
260
- ) : blockPreview.posterUrl ? (
261
- <img
262
- src={blockPreview.posterUrl}
263
- alt={blockPreview.title}
264
- className="w-full h-full object-contain"
282
+ }}
283
+ onIframeRef={handlePreviewIframeRef}
284
+ previewOverlay={
285
+ blockPreview ? (
286
+ <div className="absolute inset-0 z-30 bg-black pointer-events-none">
287
+ {blockPreview.videoUrl ? (
288
+ <video
289
+ src={blockPreview.videoUrl}
290
+ autoPlay
291
+ muted
292
+ loop
293
+ playsInline
294
+ className="w-full h-full object-contain"
295
+ />
296
+ ) : blockPreview.posterUrl ? (
297
+ <img
298
+ src={blockPreview.posterUrl}
299
+ alt={blockPreview.title}
300
+ className="w-full h-full object-contain"
301
+ />
302
+ ) : null}
303
+ </div>
304
+ ) : captionEditMode ? (
305
+ <CaptionOverlay iframeRef={previewIframeRef} />
306
+ ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
307
+ <>
308
+ <DomEditOverlay
309
+ iframeRef={previewIframeRef}
310
+ activeCompositionPath={activeCompPath}
311
+ hoverSelection={
312
+ STUDIO_PREVIEW_SELECTION_ENABLED &&
313
+ !captionEditMode &&
314
+ !compositionLoading &&
315
+ !isPlaying
316
+ ? domEditHoverSelection
317
+ : null
318
+ }
319
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
320
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
321
+ allowCanvasMovement={
322
+ STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording
323
+ }
324
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
325
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
326
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
327
+ onSelectionChange={applyDomSelection}
328
+ onBlockedMove={handleBlockedDomMove}
329
+ onManualDragStart={handleDomManualDragStart}
330
+ onPathOffsetCommit={handleDomPathOffsetCommit}
331
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
332
+ onBoxSizeCommit={handleDomBoxSizeCommit}
333
+ onRotationCommit={handleDomRotationCommit}
334
+ gridVisible={snapPrefs.gridVisible}
335
+ gridSpacing={snapPrefs.gridSpacing}
336
+ recordingState={recordingState}
337
+ onToggleRecording={onToggleRecording}
265
338
  />
266
- ) : null}
267
- </div>
268
- ) : captionEditMode ? (
269
- <CaptionOverlay iframeRef={previewIframeRef} />
270
- ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
271
- <>
272
- <DomEditOverlay
273
- iframeRef={previewIframeRef}
274
- activeCompositionPath={activeCompPath}
275
- hoverSelection={
276
- STUDIO_PREVIEW_SELECTION_ENABLED &&
277
- !captionEditMode &&
278
- !compositionLoading &&
279
- !isPlaying
280
- ? domEditHoverSelection
281
- : null
282
- }
283
- selection={shouldShowSelectedDomBounds ? domEditSelection : null}
284
- groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
285
- allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording}
286
- onCanvasMouseDown={handlePreviewCanvasMouseDown}
287
- onCanvasPointerMove={handlePreviewCanvasPointerMove}
288
- onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
289
- onSelectionChange={applyDomSelection}
290
- onBlockedMove={handleBlockedDomMove}
291
- onManualDragStart={handleDomManualDragStart}
292
- onPathOffsetCommit={handleDomPathOffsetCommit}
293
- onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
294
- onBoxSizeCommit={handleDomBoxSizeCommit}
295
- onRotationCommit={handleDomRotationCommit}
296
- gridVisible={snapPrefs.gridVisible}
297
- gridSpacing={snapPrefs.gridSpacing}
298
- recordingState={recordingState}
299
- onToggleRecording={onToggleRecording}
300
- />
301
- <SnapToolbar onSnapChange={setSnapPrefs} />
302
- {gestureOverlay}
303
- </>
304
- ) : null
305
- }
306
- timelineFooter={
307
- captionEditMode ? (
308
- <div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
309
- <div className="flex items-center gap-1.5 px-2 py-0.5">
310
- <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
311
- Captions
312
- </span>
339
+ <SnapToolbar onSnapChange={setSnapPrefs} />
340
+ {gestureOverlay}
341
+ </>
342
+ ) : null
343
+ }
344
+ timelineFooter={
345
+ captionEditMode ? (
346
+ <div
347
+ className="border-t border-neutral-800/30 flex-shrink-0"
348
+ style={{ height: 60 }}
349
+ >
350
+ <div className="flex items-center gap-1.5 px-2 py-0.5">
351
+ <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
352
+ Captions
353
+ </span>
354
+ </div>
355
+ <CaptionTimeline pixelsPerSecond={100} />
313
356
  </div>
314
- <CaptionTimeline pixelsPerSecond={100} />
315
- </div>
316
- ) : undefined
317
- }
318
- timelineVisible={timelineVisible}
319
- onToggleTimeline={toggleTimelineVisibility}
320
- />
357
+ ) : undefined
358
+ }
359
+ timelineVisible={timelineVisible}
360
+ onToggleTimeline={toggleTimelineVisibility}
361
+ />
362
+ </TimelineEditProvider>
321
363
  </div>
322
364
  <StudioFeedbackBar />
323
365
  </div>
@@ -8,7 +8,7 @@ import type { RenderJob } from "./renders/useRenderQueue";
8
8
  import type { BlockParam } from "@hyperframes/core/registry";
9
9
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
10
10
 
11
- import { useStudioContext } from "../contexts/StudioContext";
11
+ import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
12
12
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
13
13
  import { useFileManagerContext } from "../contexts/FileManagerContext";
14
14
  import { useDomEditContext } from "../contexts/DomEditContext";
@@ -47,14 +47,14 @@ export function StudioRightPanel({
47
47
  } = usePanelLayoutContext();
48
48
 
49
49
  const {
50
- captionEditMode,
51
50
  previewIframeRef,
52
51
  projectId,
53
52
  activeCompPath,
54
53
  compositionDimensions,
55
54
  waitForPendingDomEditSaves,
56
55
  renderQueue,
57
- } = useStudioContext();
56
+ } = useStudioShellContext();
57
+ const { captionEditMode } = useStudioPlaybackContext();
58
58
 
59
59
  const {
60
60
  domEditSelection,
@@ -16,6 +16,7 @@ import { Scissors } from "../icons/SystemIcons";
16
16
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
17
17
  import type { DomEditSelection } from "./editor/domEditingTypes";
18
18
  import { canSplitElement } from "../utils/timelineElementSplit";
19
+ import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
19
20
 
20
21
  interface DomEditSessionSlice extends EnableKeyframesSession {
21
22
  domEditSelection: DomEditSelection | null;
@@ -70,6 +71,9 @@ export function TimelineToolbar({
70
71
  }: TimelineToolbarProps) {
71
72
  const activeTool = usePlayerStore((s) => s.activeTool);
72
73
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
74
+ // Subscribe so the add-beat button reacts to playhead movement and analysis load.
75
+ const currentTime = usePlayerStore((s) => s.currentTime);
76
+ const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
73
77
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
74
78
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
75
79
  const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
@@ -178,6 +182,27 @@ export function TimelineToolbar({
178
182
  </Tooltip>
179
183
  );
180
184
  })()}
185
+ {beatAnalysisReady &&
186
+ canAddBeatAt(currentTime) &&
187
+ (() => (
188
+ <Tooltip label="Add beat at playhead">
189
+ <button
190
+ type="button"
191
+ onClick={() => addBeatAtCompositionTime(currentTime)}
192
+ className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-[#22c55e]"
193
+ >
194
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
195
+ <path
196
+ d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
197
+ stroke="currentColor"
198
+ strokeWidth="2"
199
+ strokeLinecap="round"
200
+ strokeLinejoin="round"
201
+ />
202
+ </svg>
203
+ </button>
204
+ </Tooltip>
205
+ ))()}
181
206
  </div>
182
207
  <div className="flex items-center gap-1">
183
208
  <Tooltip label="Fit timeline to width">
@@ -4,6 +4,7 @@ import { type DomEditSelection } from "./domEditing";
4
4
  import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
5
5
  import {
6
6
  type BlockedMoveState,
7
+ type DomEditGroupPathOffsetCommit,
7
8
  type FocusableDomEditOverlay,
8
9
  type GestureState,
9
10
  type GroupGestureState,
@@ -27,11 +28,7 @@ export {
27
28
  resolveDomEditResizeGesture,
28
29
  resolveDomEditRotationGesture,
29
30
  } from "./domEditOverlayGestures";
30
-
31
- export interface DomEditGroupPathOffsetCommit {
32
- selection: DomEditSelection;
33
- next: { x: number; y: number };
34
- }
31
+ export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
35
32
 
36
33
  interface DomEditOverlayProps {
37
34
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -1,5 +1,6 @@
1
1
  import { memo, useCallback, useRef, useState } from "react";
2
2
  import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
3
+ import { roundToCenti } from "../../utils/rounding";
3
4
 
4
5
  const PRESET_GRID_EASES = [
5
6
  "none",
@@ -75,9 +76,7 @@ const EasePresetGrid = memo(function EasePresetGrid({
75
76
  );
76
77
  });
77
78
 
78
- function round2(n: number): number {
79
- return Math.round(n * 100) / 100;
80
- }
79
+ const round2 = roundToCenti;
81
80
 
82
81
  export function EaseCurveSection({
83
82
  ease,
@@ -6,7 +6,7 @@ interface GestureTrailOverlayProps {
6
6
  sampleCount?: number;
7
7
  trail?: Array<{ x: number; y: number }>;
8
8
  simplifiedPoints?: Map<number, Record<string, number>>;
9
- canvasRect: { left: number; top: number; width: number; height: number };
9
+ canvasRect: { left: number; top: number; width: number; height: number } | null;
10
10
  compositionSize?: { width: number; height: number };
11
11
  mode: "recording" | "preview";
12
12
  accentColor?: string;
@@ -23,6 +23,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
23
23
  accentColor = "#3CE6AC",
24
24
  }: GestureTrailOverlayProps) {
25
25
  const trailPoints = useMemo(() => {
26
+ if (!canvasRect) return "";
26
27
  if (trail && trail.length > 1) {
27
28
  return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" ");
28
29
  }
@@ -32,7 +33,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
32
33
  .map((s) => `${s.properties.x},${s.properties.y}`)
33
34
  .join(" ");
34
35
  // eslint-disable-next-line react-hooks/exhaustive-deps
35
- }, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]);
36
+ }, [samples, trail, sampleCount, canvasRect?.left, canvasRect?.top]);
36
37
 
37
38
  const simplifiedPath = useMemo(() => {
38
39
  if (!simplifiedPoints || simplifiedPoints.size === 0) return "";
@@ -58,7 +59,7 @@ export const GestureTrailOverlay = memo(function GestureTrailOverlay({
58
59
  return pts.sort((a, b) => a.pct - b.pct);
59
60
  }, [simplifiedPoints]);
60
61
 
61
- if (samples.length < 2 && !simplifiedPoints) return null;
62
+ if (!canvasRect || (samples.length < 2 && !simplifiedPoints)) return null;
62
63
 
63
64
  return (
64
65
  <svg