@hyperframes/studio 0.7.55 → 0.7.56

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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40414 -37680
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -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({
@@ -7,8 +7,10 @@ import {
7
7
  redoEditHistory,
8
8
  undoEditHistory,
9
9
  type BuildEditHistoryEntryInput,
10
+ type EditHistoryEntry,
10
11
  type EditHistoryKind,
11
12
  type EditHistoryState,
13
+ type EditHistoryTransitionResult,
12
14
  } from "../utils/editHistory";
13
15
  import {
14
16
  createIndexedDbEditHistoryStorage,
@@ -21,6 +23,7 @@ interface RecordEditInput {
21
23
  label: string;
22
24
  kind: EditHistoryKind;
23
25
  coalesceKey?: string;
26
+ coalesceMs?: number;
24
27
  files: BuildEditHistoryEntryInput["files"];
25
28
  }
26
29
 
@@ -35,11 +38,24 @@ interface UsePersistentEditHistoryOptions {
35
38
  now?: () => number;
36
39
  }
37
40
 
41
+ /**
42
+ * Per-file content the restore just applied. `restored` is the bytes written to
43
+ * disk (the undo/redo target); `previous` is what was on disk immediately before
44
+ * (the current live preview state). The undo preview-sync diffs these to decide
45
+ * whether the restore is soft-reloadable (attributes/style/GSAP-script only) or
46
+ * needs a full iframe reload.
47
+ */
48
+ interface ApplyRestoredFile {
49
+ previous: string;
50
+ restored: string;
51
+ }
52
+
38
53
  interface ApplyResult {
39
54
  ok: boolean;
40
55
  reason?: "empty" | "content-mismatch";
41
56
  label?: string;
42
57
  paths?: string[];
58
+ files?: Record<string, ApplyRestoredFile>;
43
59
  }
44
60
 
45
61
  interface PersistentEditHistoryStoreOptions {
@@ -55,6 +71,18 @@ type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
55
71
  result: T;
56
72
  }>;
57
73
 
74
+ /** Pair the just-written (`restored`) bytes with the pre-write (`previous`) bytes per path. */
75
+ function restoredFilesMap(
76
+ filesToWrite: Record<string, string>,
77
+ currentFiles: Record<string, string>,
78
+ ): Record<string, ApplyRestoredFile> {
79
+ const out: Record<string, ApplyRestoredFile> = {};
80
+ for (const [path, restored] of Object.entries(filesToWrite)) {
81
+ out[path] = { previous: currentFiles[path] ?? "", restored };
82
+ }
83
+ return out;
84
+ }
85
+
58
86
  function createEntryId(now: number): string {
59
87
  return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
60
88
  }
@@ -120,6 +148,52 @@ async function writeFilesWithRollback({
120
148
  }
121
149
  }
122
150
 
151
+ /**
152
+ * Apply one undo/redo step: read current on-disk hashes, run the direction's
153
+ * transition, write the restored files with rollback, and shape the ApplyResult.
154
+ * `entry` is the stack top used to know which paths to hash before applying.
155
+ */
156
+ async function applyHistoryStep(
157
+ currentState: EditHistoryState,
158
+ entry: EditHistoryEntry | undefined,
159
+ transition: (
160
+ state: EditHistoryState,
161
+ currentHashes: Record<string, string>,
162
+ now: number,
163
+ ) => EditHistoryTransitionResult,
164
+ now: () => number,
165
+ callbacks: ApplyCallbacks,
166
+ ): Promise<{ state: EditHistoryState; result: ApplyResult }> {
167
+ if (!entry) {
168
+ return { state: currentState, result: { ok: false, reason: "empty" } };
169
+ }
170
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(
171
+ Object.keys(entry.files),
172
+ callbacks.readFile,
173
+ );
174
+ const result = transition(currentState, currentHashes, now());
175
+ if (!result.ok) {
176
+ return {
177
+ state: currentState,
178
+ result: { ok: false, reason: result.reason },
179
+ };
180
+ }
181
+ await writeFilesWithRollback({
182
+ files: result.filesToWrite,
183
+ rollbackFiles: currentFiles,
184
+ writeFile: callbacks.writeFile,
185
+ });
186
+ return {
187
+ state: result.state,
188
+ result: {
189
+ ok: true,
190
+ label: result.entry.label,
191
+ paths: Object.keys(result.entry.files),
192
+ files: restoredFilesMap(result.filesToWrite, currentFiles),
193
+ },
194
+ };
195
+ }
196
+
123
197
  export function createPersistentEditHistoryStore({
124
198
  projectId,
125
199
  storage,
@@ -171,66 +245,26 @@ export function createPersistentEditHistoryStore({
171
245
  });
172
246
  },
173
247
  async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
174
- return mutate<ApplyResult>(async (currentState) => {
175
- const entry = currentState.undo[currentState.undo.length - 1];
176
- if (!entry) {
177
- return {
178
- state: currentState,
179
- result: { ok: false, reason: "empty" },
180
- };
181
- }
182
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
183
- Object.keys(entry.files),
184
- callbacks.readFile,
185
- );
186
- const result = undoEditHistory(currentState, currentHashes, now());
187
- if (!result.ok) {
188
- return {
189
- state: currentState,
190
- result: { ok: false, reason: result.reason },
191
- };
192
- }
193
- await writeFilesWithRollback({
194
- files: result.filesToWrite,
195
- rollbackFiles: currentFiles,
196
- writeFile: callbacks.writeFile,
197
- });
198
- return {
199
- state: result.state,
200
- result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
201
- };
202
- });
248
+ return mutate<ApplyResult>((currentState) =>
249
+ applyHistoryStep(
250
+ currentState,
251
+ currentState.undo[currentState.undo.length - 1],
252
+ undoEditHistory,
253
+ now,
254
+ callbacks,
255
+ ),
256
+ );
203
257
  },
204
258
  async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
205
- return mutate<ApplyResult>(async (currentState) => {
206
- const entry = currentState.redo[currentState.redo.length - 1];
207
- if (!entry) {
208
- return {
209
- state: currentState,
210
- result: { ok: false, reason: "empty" },
211
- };
212
- }
213
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
214
- Object.keys(entry.files),
215
- callbacks.readFile,
216
- );
217
- const result = redoEditHistory(currentState, currentHashes, now());
218
- if (!result.ok) {
219
- return {
220
- state: currentState,
221
- result: { ok: false, reason: result.reason },
222
- };
223
- }
224
- await writeFilesWithRollback({
225
- files: result.filesToWrite,
226
- rollbackFiles: currentFiles,
227
- writeFile: callbacks.writeFile,
228
- });
229
- return {
230
- state: result.state,
231
- result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
232
- };
233
- });
259
+ return mutate<ApplyResult>((currentState) =>
260
+ applyHistoryStep(
261
+ currentState,
262
+ currentState.redo[currentState.redo.length - 1],
263
+ redoEditHistory,
264
+ now,
265
+ callbacks,
266
+ ),
267
+ );
234
268
  },
235
269
  };
236
270
  }
@@ -10,6 +10,14 @@ import type { EditHistoryKind } from "../utils/editHistory";
10
10
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
11
  import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
12
12
  import { trackStudioEvent } from "../utils/studioTelemetry";
13
+ import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapSoftReload";
14
+ import { usePlayerStore } from "../player";
15
+
16
+ /** The restore payload the undo/redo preview-sync consumes (from the history store). */
17
+ interface HistoryPreviewRestore {
18
+ paths?: string[];
19
+ files?: Record<string, UndoRestoreFile>;
20
+ }
13
21
 
14
22
  // ── Types ──
15
23
 
@@ -105,13 +113,12 @@ export function usePreviewPersistence({
105
113
  writeProjectFile: _writeProjectFile,
106
114
  recordEdit: _recordEdit,
107
115
  previewIframeRef,
108
- activeCompPathRef: _activeCompPathRef,
116
+ activeCompPathRef,
109
117
  domEditSaveTimestampRef,
110
118
  reloadPreview,
111
119
  pendingTimelineEditPathRef,
112
120
  }: UsePreviewPersistenceParams) {
113
121
  void _recordEdit;
114
- void _activeCompPathRef;
115
122
 
116
123
  const [domEditSaveQueuePaused, setDomEditSaveQueuePaused] = useState<string | null>(null);
117
124
 
@@ -190,12 +197,23 @@ export function usePreviewPersistence({
190
197
  // ── Sync preview after undo/redo ──
191
198
 
192
199
  const syncHistoryPreviewAfterApply = useCallback(
193
- async (_paths: string[] | undefined) => {
194
- // Motion data is now stored in HTML attributes any undo/redo that touches HTML
195
- // files triggers a full reload which picks up the changes automatically.
196
- reloadPreview();
200
+ async (restore: HistoryPreviewRestore) => {
201
+ // Prefer an in-place soft reload for a soft-reloadable restore (the change
202
+ // is confined to the active comp's element attributes / inline-style and/or
203
+ // its GSAP script) — a full iframe remount blanks the frame black and
204
+ // re-flashes the WebGL context. applyUndoRestoreToPreview syncs the reverted
205
+ // attributes onto the live DOM and re-runs the timeline at the SAME playhead,
206
+ // falling back to reloadPreview for anything structural (split/delete undo),
207
+ // multi-file, sub-comp, or a permanent soft-reload failure.
208
+ applyUndoRestoreToPreview(
209
+ previewIframeRef.current,
210
+ activeCompPathRef.current,
211
+ restore.files,
212
+ usePlayerStore.getState().currentTime,
213
+ reloadPreview,
214
+ );
197
215
  },
198
- [reloadPreview],
216
+ [previewIframeRef, activeCompPathRef, reloadPreview],
199
217
  );
200
218
 
201
219
  // ── Migrate legacy studio-motion.json ──
@@ -1,20 +1,25 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
3
+ import { act } from "react";
5
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
6
+ import { splitElementInHtml } from "@hyperframes/studio-server/source-mutation";
6
7
  import type { TimelineElement } from "../player";
8
+ import { usePlayerStore } from "../player";
7
9
  import { useRazorSplit } from "./useRazorSplit";
8
10
  import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
9
11
  import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
10
- import { createEmptyEditHistory } from "../utils/editHistory";
12
+ import {
13
+ createEmptyEditHistory,
14
+ hashEditHistoryContent,
15
+ undoEditHistory,
16
+ } from "../utils/editHistory";
17
+ import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
11
18
 
12
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
20
 
14
- const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
15
- const SPLIT =
16
- `<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
17
- `<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;
21
+ const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4" data-hf-id="hf-clip">hi</div>`;
22
+ const SPLIT = splitElementInHtml(ORIGINAL, { id: "clip1" }, 2, "clip1-split").html;
18
23
 
19
24
  const element: TimelineElement = {
20
25
  id: "clip1",
@@ -28,13 +33,15 @@ const element: TimelineElement = {
28
33
  };
29
34
 
30
35
  type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
36
+ type SplitAll = (splitTime: number) => Promise<void>;
31
37
 
32
38
  interface Harness {
33
39
  disk: Record<string, string>;
34
40
  store: ReturnType<typeof createPersistentEditHistoryStore>;
35
41
  splitRef: { current: Split | undefined };
36
- root: ReturnType<typeof createRoot>;
42
+ root: ReturnType<typeof mountProbe>;
37
43
  expected: string;
44
+ previewWrites: string[];
38
45
  }
39
46
 
40
47
  const SPLIT_GSAP = SPLIT.replace(
@@ -43,9 +50,10 @@ const SPLIT_GSAP = SPLIT.replace(
43
50
  'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
44
51
  );
45
52
 
46
- function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
53
+ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}): Harness {
47
54
  const disk: Record<string, string> = { "index.html": ORIGINAL };
48
55
  const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
56
+ const previewWrites: string[] = [];
49
57
 
50
58
  const storage = createMemoryEditHistoryStorage();
51
59
  const store = createPersistentEditHistoryStore({
@@ -109,23 +117,28 @@ function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
109
117
  },
110
118
  recordEdit: store.recordEdit,
111
119
  domEditSaveTimestampRef: { current: 0 },
112
- reloadPreview: () => {},
120
+ reloadPreview: () => {
121
+ if (!opts.previewStamp) return;
122
+ const stamped = ensureHfIds(disk["index.html"]);
123
+ const idsBefore = (disk["index.html"].match(/\bdata-hf-id=/g) ?? []).length;
124
+ const idsAfter = (stamped.match(/\bdata-hf-id=/g) ?? []).length;
125
+ if (idsAfter > idsBefore) {
126
+ disk["index.html"] = stamped;
127
+ previewWrites.push(stamped);
128
+ }
129
+ },
130
+ forceReloadSdkSession: () => {},
113
131
  });
114
132
  splitRef.current = handleRazorSplit;
115
133
  return null;
116
134
  }
117
135
 
118
- const host = document.createElement("div");
119
- document.body.append(host);
120
- const root = createRoot(host);
121
- act(() => {
122
- root.render(<Component />);
123
- });
136
+ const root = mountProbe(Component);
124
137
 
125
- return { disk, store, splitRef, root, expected: finalContent };
138
+ return { disk, store, splitRef, root, expected: finalContent, previewWrites };
126
139
  }
127
140
 
128
- async function undoViaDisk(harness: Harness) {
141
+ async function undoViaDisk(harness: Pick<Harness, "disk" | "store">) {
129
142
  return harness.store.undo({
130
143
  readFile: async (path) => harness.disk[path],
131
144
  writeFile: async (path, content) => {
@@ -140,6 +153,26 @@ afterEach(() => {
140
153
  });
141
154
 
142
155
  describe("useRazorSplit — split is undoable via edit history", () => {
156
+ it("keeps history aligned when preview reload checks hf-id persistence", async () => {
157
+ const harness = mountRazorSplit({ previewStamp: true });
158
+
159
+ await act(async () => {
160
+ await harness.splitRef.current!(element, 2);
161
+ });
162
+
163
+ const snapshot = harness.store.snapshot().state;
164
+ const entry = snapshot.undo.at(-1)!;
165
+ const currentHash = hashEditHistoryContent(harness.disk["index.html"]);
166
+ expect(entry.files["index.html"].afterHash).toBe(currentHash);
167
+
168
+ const undo = undoEditHistory(snapshot, { "index.html": currentHash }, 2000);
169
+ expect(undo.ok).toBe(true);
170
+ expect(undo.filesToWrite).toEqual({ "index.html": ORIGINAL });
171
+ expect(harness.previewWrites).toHaveLength(0);
172
+
173
+ act(() => harness.root.unmount());
174
+ });
175
+
143
176
  for (const gsap of [false, true]) {
144
177
  describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
145
178
  let harness: Harness;
@@ -171,3 +204,100 @@ describe("useRazorSplit — split is undoable via edit history", () => {
171
204
  });
172
205
  }
173
206
  });
207
+
208
+ const BATCH_ORIGINALS = {
209
+ "index.html": `<div class="clip" id="clip1" data-start="0" data-duration="4">one</div>`,
210
+ "scenes/two.html": `<div class="clip" id="clip2" data-start="0" data-duration="4">two</div>`,
211
+ };
212
+
213
+ const batchElements: TimelineElement[] = [
214
+ element,
215
+ {
216
+ ...element,
217
+ id: "clip2",
218
+ domId: "clip2",
219
+ sourceFile: "scenes/two.html",
220
+ track: 1,
221
+ },
222
+ ];
223
+
224
+ interface SplitAllHarness {
225
+ disk: Record<string, string>;
226
+ store: ReturnType<typeof createPersistentEditHistoryStore>;
227
+ splitAllRef: { current: SplitAll | undefined };
228
+ root: ReturnType<typeof mountProbe>;
229
+ }
230
+
231
+ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
232
+ const disk: Record<string, string> = { ...BATCH_ORIGINALS };
233
+ const store = createPersistentEditHistoryStore({
234
+ projectId: "p1",
235
+ storage: createMemoryEditHistoryStorage(),
236
+ initialState: createEmptyEditHistory(),
237
+ now: () => 1000,
238
+ onChange: () => {},
239
+ });
240
+ let splitCount = 0;
241
+
242
+ vi.stubGlobal(
243
+ "fetch",
244
+ createSplitFetchMock(disk, () => {
245
+ splitCount++;
246
+ if (splitCount === failOnSplit) throw new Error("simulated split failure");
247
+ }),
248
+ );
249
+
250
+ const splitAllRef: { current: SplitAll | undefined } = { current: undefined };
251
+ function Component() {
252
+ const { handleRazorSplitAll } = useRazorSplit({
253
+ projectId: "p1",
254
+ activeCompPath: "index.html",
255
+ showToast: () => {},
256
+ writeProjectFile: async (path, content) => {
257
+ disk[path] = content;
258
+ },
259
+ recordEdit: store.recordEdit,
260
+ domEditSaveTimestampRef: { current: 0 },
261
+ reloadPreview: () => {},
262
+ });
263
+ splitAllRef.current = handleRazorSplitAll;
264
+ return null;
265
+ }
266
+
267
+ const root = mountProbe(Component);
268
+ usePlayerStore.setState({ elements: batchElements });
269
+ return { disk, store, splitAllRef, root };
270
+ }
271
+
272
+ describe("useRazorSplit — split-all batch history", () => {
273
+ afterEach(() => {
274
+ usePlayerStore.setState({ elements: [] });
275
+ });
276
+
277
+ async function runSplitAll(failOnSplit?: number) {
278
+ const harness = mountRazorSplitAll(failOnSplit);
279
+ await act(async () => {
280
+ await harness.splitAllRef.current!(2);
281
+ });
282
+ return harness;
283
+ }
284
+
285
+ it("records one undo entry that restores every split file", async () => {
286
+ const harness = await runSplitAll();
287
+
288
+ expect(harness.store.snapshot().canUndo).toBe(true);
289
+ const result = await undoViaDisk(harness);
290
+ expect(result.ok).toBe(true);
291
+ expect(harness.disk).toEqual(BATCH_ORIGINALS);
292
+ expect(harness.store.snapshot().canUndo).toBe(false);
293
+ act(() => harness.root.unmount());
294
+ });
295
+
296
+ it("restores completed writes and records no undo when a later split fails", async () => {
297
+ const harness = await runSplitAll(2);
298
+
299
+ expect(harness.disk).toEqual(BATCH_ORIGINALS);
300
+ expect(harness.store.snapshot().canUndo).toBe(false);
301
+ act(() => harness.root.unmount());
302
+ });
303
+ });