@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
@@ -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
+ });