@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
+ import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { usePlayerStore } from "../player/store/playerStore";
5
6
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
@@ -15,6 +16,8 @@ import {
15
16
  readJsonResponseBody,
16
17
  } from "./gsapScriptCommitHelpers";
17
18
  import type {
19
+ CommitMutation,
20
+ CommitMutationCall,
18
21
  CommitMutationOptions,
19
22
  GsapScriptCommitsParams,
20
23
  MutationResult,
@@ -47,6 +50,76 @@ async function mutateGsapScript(
47
50
  return result;
48
51
  }
49
52
 
53
+ async function mutateGsapScriptBatch(
54
+ projectId: string,
55
+ sourceFile: string,
56
+ mutations: Record<string, unknown>[],
57
+ ): Promise<MutationResult> {
58
+ const res = await fetch(
59
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
60
+ {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ mutations }),
64
+ },
65
+ );
66
+ if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res));
67
+ const result = (await res.json()) as MutationResult;
68
+ if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`);
69
+ return result;
70
+ }
71
+
72
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
73
+
74
+ async function runMutationRequest(
75
+ mutations: Record<string, unknown>[],
76
+ options: CommitMutationOptions,
77
+ showToast: ShowToast | undefined,
78
+ request: () => Promise<MutationResult>,
79
+ ): Promise<MutationResult | undefined> {
80
+ const unsafeFields = mutations.flatMap((mutation) => findUnsafeMutationValues(mutation));
81
+ if (unsafeFields.length > 0) {
82
+ showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
83
+ if (options.skipReload) return;
84
+ throw new Error(
85
+ `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
86
+ );
87
+ }
88
+ try {
89
+ return await request();
90
+ } catch (error) {
91
+ if (error instanceof GsapMutationHttpError)
92
+ showToast?.(formatGsapMutationRejectionToast(error), "error");
93
+ if (options.skipReload) return;
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ function finishUnchangedMutation(
99
+ iframe: HTMLIFrameElement | null,
100
+ result: MutationResult,
101
+ options: CommitMutationOptions,
102
+ reloadPreview: () => void,
103
+ ): boolean {
104
+ if (result.changed !== false) return false;
105
+ if (!options.skipReload && options.instantPatch) {
106
+ applyPreviewSync(iframe, result, options, reloadPreview);
107
+ }
108
+ return true;
109
+ }
110
+
111
+ function refreshMutationPreview(
112
+ iframe: HTMLIFrameElement | null,
113
+ result: MutationResult,
114
+ options: CommitMutationOptions,
115
+ reloadPreview: () => void,
116
+ onCacheInvalidate: () => void,
117
+ ): void {
118
+ options.beforeReload?.();
119
+ applyPreviewSync(iframe, result, options, reloadPreview);
120
+ onCacheInvalidate();
121
+ }
122
+
50
123
  /**
51
124
  * Apply a soft reload and enforce the U4 invariant via the richer
52
125
  * `SoftReloadResult`, with telemetry on every non-success path so the invariant
@@ -141,63 +214,78 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
141
214
  // the same animationId so their POSTs can't interleave. Held in a ref so the
142
215
  // chain survives re-renders.
143
216
  const serializerRef = useRef(createKeyedSerializer());
144
- // fallow-ignore-next-line complexity
145
- const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
146
- const pid = projectIdRef.current;
147
- if (!pid) return;
148
- const unsafeFields = findUnsafeMutationValues(mutation);
149
- if (unsafeFields.length > 0) {
150
- showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
151
- if (options.skipReload) return;
152
- throw new Error(`Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`);
153
- }
154
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
155
- let result: MutationResult;
156
- try {
157
- result = await mutateGsapScript(pid, targetPath, mutation);
158
- } catch (error) {
159
- if (error instanceof GsapMutationHttpError) showToast?.(formatGsapMutationRejectionToast(error), "error");
160
- if (options.skipReload) return;
161
- throw error;
162
- }
163
- if (result.changed === false) {
164
- // The FILE already matched, but a deferred instant patch may still be
165
- // owed to the RUNTIME: paired commits (x with skipReload, then y carrying
166
- // the patch for both) rely on the SECOND commit to sync the preview — if
167
- // that half happens to be a no-op (a purely-horizontal drag or resize
168
- // compensation), returning here would leave the runtime showing the old
169
- // value while the file holds the new one. Patching in place is idempotent
170
- // when the values truly match everywhere.
171
- if (!options.skipReload && options.instantPatch) {
172
- applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
173
- }
174
- return;
175
- }
217
+ const recordMutationEdit = useCallback(async (targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
218
+ if (result.before == null || result.after == null) return;
219
+ await editHistory.recordEdit({
220
+ label: options.label,
221
+ kind: "manual",
222
+ coalesceKey: options.coalesceKey,
223
+ coalesceMs: options.coalesceMs,
224
+ files: { [targetPath]: { before: result.before, after: result.after } },
225
+ });
226
+ }, [editHistory]);
227
+
228
+ const finalizeSuccessfulMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
229
+ // A no-op file write may still owe the runtime a deferred instant patch.
230
+ if (finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview)) return;
176
231
  domEditSaveTimestampRef.current = Date.now();
177
- if (result.before != null && result.after != null) {
178
- await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
179
- }
232
+ await recordMutationEdit(targetPath, result, options);
180
233
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
181
234
  // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
182
235
  // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
183
236
  forceReloadSdkSession?.();
184
237
  if (options.skipReload) return;
185
238
  if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
186
- options.beforeReload?.();
187
- applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
188
- onCacheInvalidate();
189
- }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
239
+ refreshMutationPreview(
240
+ previewIframeRef.current,
241
+ result,
242
+ options,
243
+ reloadPreview,
244
+ onCacheInvalidate,
245
+ );
246
+ }, [previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
247
+
248
+ const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
249
+ const pid = projectIdRef.current;
250
+ if (!pid) return;
251
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
252
+ const result = await runMutationRequest([mutation], options, showToast, () =>
253
+ mutateGsapScript(pid, targetPath, mutation),
254
+ );
255
+ if (!result) return;
256
+ await finalizeSuccessfulMutation(selection, mutation, targetPath, result, options);
257
+ }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
258
+
259
+ const runBatchCommit = useCallback(async (calls: CommitMutationCall[], options: CommitMutationOptions) => {
260
+ const pid = projectIdRef.current;
261
+ const first = calls[0];
262
+ const last = calls.at(-1);
263
+ if (!pid || !first || !last) return;
264
+ const targetPath = first.selection.sourceFile || activeCompPath || "index.html";
265
+ const mutations = calls.map(({ mutation }) => mutation);
266
+ const result = await runMutationRequest(mutations, options, showToast, () =>
267
+ mutateGsapScriptBatch(pid, targetPath, mutations),
268
+ );
269
+ if (!result) return;
270
+ await finalizeSuccessfulMutation(last.selection, last.mutation, targetPath, result, options);
271
+ }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
272
+
190
273
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
191
274
  // commits to the SAME file (any op type, any animation) interleave server-side,
192
275
  // so serialize per target file by default; an explicit serializeKey overrides.
193
- const commitMutation = useCallback(
194
- (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
276
+ const commitMutation = useMemo<CommitMutation>(() => {
277
+ const commit: CommitMutation = (selection, mutation, options) => {
195
278
  const file = selection.sourceFile || activeCompPath || "index.html";
196
279
  const key = options.serializeKey ?? `gsap-file:${file}`;
197
280
  return serializerRef.current(key, () => runCommit(selection, mutation, options));
198
- },
199
- [runCommit, activeCompPath],
200
- );
281
+ };
282
+ commit.batch = (calls, options) => {
283
+ const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
284
+ const key = options.serializeKey ?? `gsap-file:${file}`;
285
+ return serializerRef.current(key, () => runBatchCommit(calls, options));
286
+ };
287
+ return commit;
288
+ }, [runCommit, runBatchCommit, activeCompPath]);
201
289
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
202
290
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
203
291
 
@@ -238,14 +326,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
238
326
  // exact prior content as its undo `before` (matching the style/delete paths),
239
327
  // instead of a normalized full-DOM re-emit that would reformat the whole file.
240
328
  const readProjectFileContent = useCallback(
241
- async (path: string): Promise<string> => {
329
+ (path: string): Promise<string> => {
242
330
  const pid = projectIdRef.current;
243
331
  if (!pid) throw new Error("No active project");
244
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
245
- if (!res.ok) throw new Error(`Failed to read ${path}`);
246
- const data = (await res.json()) as { content?: string };
247
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
248
- return data.content;
332
+ return readSharedProjectFileContent(pid, path);
249
333
  },
250
334
  [projectIdRef],
251
335
  );
@@ -5,6 +5,7 @@ import { usePlayerStore } from "../player";
5
5
  import { computeCurrentPercentage } from "./gsapDragCommit";
6
6
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
7
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
8
9
 
9
10
  /**
10
11
  * Thin useCallback wrappers that guard on `domEditSelection` before
@@ -75,8 +76,14 @@ export function useGsapSelectionHandlers({
75
76
  animId: string,
76
77
  percentage: number,
77
78
  properties: Record<string, number | string>,
79
+ commitOverrides?: Partial<CommitMutationOptions>,
78
80
  ) => Promise<void>;
79
- removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
81
+ removeKeyframe: (
82
+ sel: DomEditSelection,
83
+ animId: string,
84
+ percentage: number,
85
+ commitOverrides?: Partial<CommitMutationOptions>,
86
+ ) => void;
80
87
  moveKeyframe: (
81
88
  sel: DomEditSelection,
82
89
  animId: string,
@@ -95,6 +102,7 @@ export function useGsapSelectionHandlers({
95
102
  animId: string,
96
103
  resolvedFromValues?: Record<string, number | string>,
97
104
  duration?: number,
105
+ commitOverrides?: Partial<CommitMutationOptions>,
98
106
  ) => Promise<void>;
99
107
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
100
108
 
@@ -232,20 +240,36 @@ export function useGsapSelectionHandlers({
232
240
  );
233
241
 
234
242
  const handleGsapAddKeyframeBatch = useCallback(
235
- (animId: string, percentage: number, properties: Record<string, number | string>) => {
243
+ (
244
+ animId: string,
245
+ percentage: number,
246
+ properties: Record<string, number | string>,
247
+ commitOverrides?: Partial<CommitMutationOptions>,
248
+ ) => {
236
249
  if (!domEditSelection) return Promise.resolve();
237
- return addKeyframeBatch(domEditSelection, animId, percentage, properties).catch((error) => {
250
+ return addKeyframeBatch(
251
+ domEditSelection,
252
+ animId,
253
+ percentage,
254
+ properties,
255
+ commitOverrides,
256
+ ).catch((error) => {
238
257
  trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
239
258
  });
240
259
  },
241
260
  [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
242
261
  );
243
262
  const handleGsapRemoveKeyframe = useCallback(
244
- (animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
263
+ (
264
+ animId: string,
265
+ percentage: number,
266
+ commitOverrides?: Partial<CommitMutationOptions>,
267
+ selectionOverride?: DomEditSelection | null,
268
+ ) => {
245
269
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
246
270
  if (!sel) return;
247
271
  trackStudioEvent("keyframe", { action: "remove" });
248
- removeKeyframe(sel, animId, percentage);
272
+ removeKeyframe(sel, animId, percentage, commitOverrides);
249
273
  },
250
274
  [domEditSelection, removeKeyframe],
251
275
  );
@@ -302,18 +326,27 @@ export function useGsapSelectionHandlers({
302
326
  );
303
327
 
304
328
  const handleGsapConvertToKeyframes = useCallback(
305
- (animId: string, resolvedFromValues?: Record<string, number | string>, duration?: number) => {
329
+ (
330
+ animId: string,
331
+ resolvedFromValues?: Record<string, number | string>,
332
+ duration?: number,
333
+ commitOverrides?: Partial<CommitMutationOptions>,
334
+ ) => {
306
335
  if (!domEditSelection) return Promise.resolve();
307
- return convertToKeyframes(domEditSelection, animId, resolvedFromValues, duration).catch(
308
- (error) => {
309
- trackGsapHandlerFailure(
310
- error,
311
- domEditSelection,
312
- "convert-to-keyframes",
313
- "Convert to keyframes",
314
- );
315
- },
316
- );
336
+ return convertToKeyframes(
337
+ domEditSelection,
338
+ animId,
339
+ resolvedFromValues,
340
+ duration,
341
+ commitOverrides,
342
+ ).catch((error) => {
343
+ trackGsapHandlerFailure(
344
+ error,
345
+ domEditSelection,
346
+ "convert-to-keyframes",
347
+ "Convert to keyframes",
348
+ );
349
+ });
317
350
  },
318
351
  [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
319
352
  );
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useMemo, useRef } from "react";
2
2
  import { usePlayerStore } from "../player/store/playerStore";
3
- import { isMusicTrack } from "../utils/timelineInspector";
3
+ import { resolveBeatSourceTrack } from "../utils/timelineInspector";
4
4
  import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
5
5
  import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
6
6
  import { mergeUserBeats } from "../utils/beatEditing";
@@ -52,6 +52,44 @@ async function resolveBeats(
52
52
  return { ...detected, hasFile: false };
53
53
  }
54
54
 
55
+ /** True when the beats file for a track exists and holds at least one beat. */
56
+ async function readHasSavedBeats(io: ProjectIo, beatPath: string): Promise<boolean> {
57
+ try {
58
+ const content = await io.readOptionalProjectFile(beatPath);
59
+ const parsed = content ? parseBeats(content) : null;
60
+ return !!(parsed && parsed.times.length > 0);
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ type MusicAnalysis = Awaited<ReturnType<typeof analyzeMusicFromUrl>>;
67
+
68
+ /**
69
+ * Analyze a track (memoized per URL) and fold in any saved beat edits. Null on
70
+ * decode/analysis failure — the caller then clears state and drops the cache
71
+ * entry (only when the effect is still live).
72
+ */
73
+ async function loadBeatAnalysis(
74
+ musicSrc: string,
75
+ beatPath: string,
76
+ io: ProjectIo,
77
+ ): Promise<{ analysis: MusicAnalysis; times: number[]; strengths: number[] } | null> {
78
+ let promise = analysisCache.get(musicSrc);
79
+ if (!promise) {
80
+ promise = analyzeMusicFromUrl(musicSrc);
81
+ cacheAnalysis(musicSrc, promise);
82
+ }
83
+ try {
84
+ const analysis = await promise;
85
+ const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
86
+ const { times, strengths } = await resolveBeats(beatPath, detected, io);
87
+ return { analysis, times, strengths };
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
55
93
  export function useMusicBeatAnalysis(): void {
56
94
  const elements = usePlayerStore((s) => s.elements);
57
95
  const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
@@ -71,9 +109,12 @@ export function useMusicBeatAnalysis(): void {
71
109
  ? { readOptionalProjectFile, writeProjectFile }
72
110
  : null;
73
111
 
74
- const musicSrc = useMemo(() => {
75
- const el = elements.find((e) => isMusicTrack(e));
76
- return el?.src ?? null;
112
+ const { musicSrc, isFallbackTrack } = useMemo(() => {
113
+ const resolved = resolveBeatSourceTrack(elements);
114
+ return {
115
+ musicSrc: resolved?.element.src ?? null,
116
+ isFallbackTrack: resolved?.isFallback ?? false,
117
+ };
77
118
  }, [elements]);
78
119
 
79
120
  // ── Load: decode for strength data, then use the saved beat file if present,
@@ -95,50 +136,45 @@ export function useMusicBeatAnalysis(): void {
95
136
  const beatPath = beatFilePathForSrc(musicSrc);
96
137
  const io = ioRef.current;
97
138
 
98
- // Only run expensive audio decode + beat analysis when the user has an
99
- // explicit beats file saved. Without one, skip entirely — no surprise
100
- // green lines on the timeline after dragging unrelated assets.
139
+ // For explicitly tagged/named music tracks: only run expensive audio decode
140
+ // + beat analysis when the user has an explicit beats file saved. Without
141
+ // one, skip entirely — no surprise green lines on the timeline after
142
+ // dragging unrelated assets.
143
+ //
144
+ // For fallback tracks (audio dropped from Finder with no role/music-id):
145
+ // always run analysis so the Beat tool becomes usable immediately.
101
146
  (async () => {
102
147
  if (!beatPath || !io) return;
103
- let hasSavedBeats = false;
104
- try {
105
- const content = await io.readOptionalProjectFile(beatPath);
106
- const parsed = content ? parseBeats(content) : null;
107
- hasSavedBeats = !!(parsed && parsed.times.length > 0);
108
- } catch {
109
- /* no file */
148
+ if (!isFallbackTrack) {
149
+ const hasSavedBeats = await readHasSavedBeats(io, beatPath);
150
+ if (cancelled) return;
151
+ if (!hasSavedBeats) {
152
+ setBeatAnalysis(null);
153
+ return;
154
+ }
110
155
  }
111
156
  if (cancelled) return;
112
- if (!hasSavedBeats) {
157
+
158
+ const result = await loadBeatAnalysis(musicSrc, beatPath, io);
159
+ if (cancelled) return;
160
+ if (!result) {
113
161
  setBeatAnalysis(null);
162
+ analysisCache.delete(musicSrc);
114
163
  return;
115
164
  }
116
-
117
- let promise = analysisCache.get(musicSrc);
118
- if (!promise) {
119
- promise = analyzeMusicFromUrl(musicSrc);
120
- cacheAnalysis(musicSrc, promise);
121
- }
122
- try {
123
- const analysis = await promise;
124
- const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
125
- const { times, strengths } = await resolveBeats(beatPath, detected, io);
126
- if (cancelled) return;
127
- setBeatEdits(null);
128
- resetBeatHistory();
129
- setBeatAnalysis({ ...analysis, beatTimes: times, beatStrengths: strengths });
130
- } catch {
131
- if (!cancelled) {
132
- setBeatAnalysis(null);
133
- analysisCache.delete(musicSrc);
134
- }
135
- }
165
+ setBeatEdits(null);
166
+ resetBeatHistory();
167
+ setBeatAnalysis({
168
+ ...result.analysis,
169
+ beatTimes: result.times,
170
+ beatStrengths: result.strengths,
171
+ });
136
172
  })();
137
173
 
138
174
  return () => {
139
175
  cancelled = true;
140
176
  };
141
- }, [musicSrc, setBeatAnalysis, setBeatEdits, resetBeatHistory]);
177
+ }, [musicSrc, isFallbackTrack, setBeatAnalysis, setBeatEdits, resetBeatHistory]);
142
178
 
143
179
  // ── Persist: register a debounced writer fired by every beat edit/undo/redo.
144
180
  // Flushes any pending write on cleanup so the last edit is never lost. ──
@@ -213,6 +213,39 @@ describe("createPersistentEditHistoryController", () => {
213
213
  expect(store.snapshot().canRedo).toBe(true);
214
214
  });
215
215
 
216
+ it("returns per-file restored/previous content so the preview can soft-apply", async () => {
217
+ const storage = createMemoryEditHistoryStorage();
218
+ const store = createPersistentEditHistoryStore({
219
+ projectId: "project-1",
220
+ storage,
221
+ initialState: createEmptyEditHistory(),
222
+ now: () => 100,
223
+ onChange: () => {},
224
+ });
225
+ await store.recordEdit({
226
+ label: "Move layer",
227
+ kind: "manual",
228
+ files: { "index.html": { before: "OLD", after: "NEW" } },
229
+ });
230
+ const disk: Record<string, string> = { "index.html": "NEW" };
231
+ const undo = await store.undo({
232
+ readFile: async (p) => disk[p],
233
+ writeFile: async (p, c) => {
234
+ disk[p] = c;
235
+ },
236
+ });
237
+ // `restored` = bytes written (the undo target), `previous` = the current live bytes.
238
+ expect(undo.files).toEqual({ "index.html": { previous: "NEW", restored: "OLD" } });
239
+
240
+ const redo = await store.redo({
241
+ readFile: async (p) => disk[p],
242
+ writeFile: async (p, c) => {
243
+ disk[p] = c;
244
+ },
245
+ });
246
+ expect(redo.files).toEqual({ "index.html": { previous: "OLD", restored: "NEW" } });
247
+ });
248
+
216
249
  it("rolls back files when an undo write fails partway through", async () => {
217
250
  const storage = createMemoryEditHistoryStorage();
218
251
  const store = createPersistentEditHistoryStore({