@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
@@ -14,7 +14,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
- import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
17
+ import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
18
18
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
19
  import { roundTo3 } from "../utils/rounding";
20
20
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
@@ -161,8 +161,8 @@ async function commitSetProps(
161
161
  /**
162
162
  * Static element (no keyframes on ANY of its tweens): persist the 3D props as a
163
163
  * `tl.set` — NEVER keyframes. Mirrors manual drag / resize / rotate, which `tl.set`
164
- * a static element instead of animating it. Updates an existing `set` in place, or
165
- * creates a dedicated `set` at position 0 when the element has none.
164
+ * a static element instead of animating it. Updates an existing same-group static
165
+ * hold in place, or creates a dedicated `set` at position 0 when the element has none.
166
166
  */
167
167
  async function commitStaticSet(
168
168
  selection: DomEditSelection,
@@ -172,12 +172,12 @@ async function commitStaticSet(
172
172
  commit: Commit,
173
173
  ): Promise<void> {
174
174
  if (!selector) return;
175
- // One commit per PROPERTY GROUP, each into a set that owns that group — never a
176
- // flat `to`/`from`, and never a foreign-group set (a width edit used to merge
177
- // into the element's position set, producing a mixed set the split machinery
178
- // exists to prevent). Within a group everything batches into ONE commit: a
179
- // set's id is group-derived, so a per-prop loop would shift the id mid-way and
180
- // 404 the next update.
175
+ // One commit per PROPERTY GROUP, each into a static write that owns that group —
176
+ // never a live tween, and never a foreign-group write (a width edit used to
177
+ // merge into the element's position set, producing a mixed write the split
178
+ // machinery exists to prevent). Within a group everything batches into ONE
179
+ // commit: a write's id is group-derived, so a per-prop loop would shift the id
180
+ // mid-way and 404 the next update.
181
181
  const byGroup = new Map<string, [string, number | string][]>();
182
182
  for (const entry of propEntries) {
183
183
  const group = classifyPropertyGroup(entry[0]);
@@ -185,24 +185,24 @@ async function commitStaticSet(
185
185
  batch.push(entry);
186
186
  byGroup.set(group, batch);
187
187
  }
188
- const sets = animations.filter((a) => a.method === "set" && a.targetSelector === selector);
188
+ const staticWrites = animations.filter((a) => isInstantHold(a) && a.targetSelector === selector);
189
189
  // Resolve every group's target BEFORE committing anything, and coalesce
190
- // groups that land on the SAME set into one commit: the `sets` snapshot is
191
- // captured once, so if two groups resolved to one legacy mixed set, a first
190
+ // groups that land on the SAME write into one commit: the snapshot is captured
191
+ // once, so if two groups resolved to one legacy mixed write, a first
192
192
  // commit could re-shape it server-side and leave the second chasing a stale
193
193
  // id (404 on legacy pre-split files).
194
- const byTargetSet = new Map<GsapAnimation, [string, number | string][]>();
194
+ const byTargetWrite = new Map<GsapAnimation, [string, number | string][]>();
195
195
  const newSetBatches: [string, number | string][][] = [];
196
196
  for (const [group, batch] of byGroup) {
197
- const existingSet = findGroupOwningSet(sets, group);
198
- if (existingSet) {
199
- byTargetSet.set(existingSet, [...(byTargetSet.get(existingSet) ?? []), ...batch]);
197
+ const existingWrite = findGroupOwningStaticWrite(staticWrites, group);
198
+ if (existingWrite) {
199
+ byTargetWrite.set(existingWrite, [...(byTargetWrite.get(existingWrite) ?? []), ...batch]);
200
200
  } else {
201
201
  newSetBatches.push(batch);
202
202
  }
203
203
  }
204
- for (const [targetSet, batch] of byTargetSet) {
205
- await commitSetProps(selection, targetSet, batch, selector, animations, commit);
204
+ for (const [targetWrite, batch] of byTargetWrite) {
205
+ await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
206
206
  }
207
207
  // Fresh adds don't reshape existing sets, so their ids can't go stale.
208
208
  for (const batch of newSetBatches) {
@@ -211,14 +211,19 @@ async function commitStaticSet(
211
211
  }
212
212
 
213
213
  /**
214
- * The set that owns a property group: one already dedicated to the group wins;
215
- * else a mixed set that already carries a property of the group (merging
216
- * same-group values there beats spawning a second writer for the channel).
214
+ * The static write that owns a property group: one already dedicated to the
215
+ * group wins; else a mixed write that already carries a property of the group
216
+ * (merging same-group values there beats spawning a second writer for the channel).
217
217
  */
218
- function findGroupOwningSet(sets: GsapAnimation[], group: string): GsapAnimation | undefined {
218
+ function findGroupOwningStaticWrite(
219
+ staticWrites: GsapAnimation[],
220
+ group: string,
221
+ ): GsapAnimation | undefined {
219
222
  return (
220
- sets.find((a) => a.propertyGroup === group) ??
221
- sets.find((a) => Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group))
223
+ staticWrites.find((a) => a.propertyGroup === group) ??
224
+ staticWrites.find((a) =>
225
+ Object.keys(a.properties).some((k) => classifyPropertyGroup(k) === group),
226
+ )
222
227
  );
223
228
  }
224
229
 
@@ -445,8 +450,8 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
445
450
  }
446
451
 
447
452
  // Existing static hold on a NON-animated element — merge the props into the
448
- // `set` in place (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
449
- if (anim?.method === "set") {
453
+ // same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
454
+ if (anim && isInstantHold(anim)) {
450
455
  await commitSetProps(
451
456
  selection,
452
457
  anim,
@@ -4,7 +4,7 @@ import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
6
6
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
- import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
7
+ import { isEditableTarget } from "../utils/timelineDiscovery";
8
8
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
@@ -81,6 +81,8 @@ interface HistoryResult {
81
81
  reason?: string;
82
82
  label?: string;
83
83
  paths?: string[];
84
+ /** Per-file restored/previous content, used to soft-apply the preview. */
85
+ files?: Record<string, { previous: string; restored: string }>;
84
86
  }
85
87
  interface HistoryFileCallbacks {
86
88
  readFile: (path: string) => Promise<string>;
@@ -96,7 +98,6 @@ interface EditHistoryHandle {
96
98
  }
97
99
 
98
100
  interface UseAppHotkeysParams {
99
- toggleTimelineVisibility: () => void;
100
101
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
101
102
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
102
103
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
@@ -108,7 +109,10 @@ interface UseAppHotkeysParams {
108
109
  writeProjectFile: (path: string, content: string) => Promise<void>;
109
110
  domEditSaveTimestampRef: React.MutableRefObject<number>;
110
111
  showToast: (message: string, tone?: "error" | "info") => void;
111
- syncHistoryPreviewAfterApply: (paths: string[] | undefined) => Promise<void>;
112
+ syncHistoryPreviewAfterApply: (restore: {
113
+ paths?: string[];
114
+ files?: Record<string, { previous: string; restored: string }>;
115
+ }) => Promise<void>;
112
116
  waitForPendingDomEditSaves: () => Promise<void>;
113
117
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
114
118
  handleCopy: () => boolean;
@@ -135,7 +139,6 @@ interface UseAppHotkeysParams {
135
139
  // ── Extracted keydown dispatch (pure function, no hooks) ──
136
140
 
137
141
  interface HotkeyCallbacks {
138
- toggleTimelineVisibility: () => void;
139
142
  handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
140
143
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
141
144
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
@@ -291,9 +294,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
291
294
  return;
292
295
  }
293
296
  }
294
- const { selectedElementId, elements } = usePlayerStore.getState();
295
- if (selectedElementId) {
296
- const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
297
+ // Delete acts on the primary selection OR the marquee multi-selection —
298
+ // the delete handler expands a clip that is part of the multi-selection
299
+ // into an atomic delete of the whole selection (single undo).
300
+ const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
301
+ const selectionKeys = new Set(selectedElementIds);
302
+ if (selectedElementId) selectionKeys.add(selectedElementId);
303
+ if (selectionKeys.size > 0) {
304
+ const el = elements.find((e) => selectionKeys.has(e.key ?? e.id));
297
305
  if (el) {
298
306
  event.preventDefault();
299
307
  void cb.handleTimelineElementDelete(el);
@@ -317,7 +325,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
317
325
  // ── Hook ──
318
326
 
319
327
  export function useAppHotkeys({
320
- toggleTimelineVisibility,
321
328
  handleTimelineElementDelete,
322
329
  handleTimelineElementSplit,
323
330
  handleDomEditElementDelete,
@@ -346,15 +353,6 @@ export function useAppHotkeys({
346
353
  const previewHotkeyWindowRef = useRef<Window | null>(null);
347
354
  const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
348
355
 
349
- const handleTimelineToggleHotkey = useCallback(
350
- (event: KeyboardEvent) => {
351
- if (!shouldHandleTimelineToggleHotkey(event)) return;
352
- event.preventDefault();
353
- toggleTimelineVisibility();
354
- },
355
- [toggleTimelineVisibility],
356
- );
357
-
358
356
  // ── Undo / Redo ──
359
357
 
360
358
  const readHistoryFile = useCallback(
@@ -397,7 +395,7 @@ export function useAppHotkeys({
397
395
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
398
396
  forceReloadSdkSession?.();
399
397
  }
400
- await syncHistoryPreviewAfterApply(result.paths);
398
+ await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
401
399
  showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
402
400
  }
403
401
  },
@@ -421,7 +419,6 @@ export function useAppHotkeys({
421
419
 
422
420
  const cbRef = useRef<HotkeyCallbacks>(null!);
423
421
  cbRef.current = {
424
- toggleTimelineVisibility,
425
422
  handleTimelineElementDelete,
426
423
  handleTimelineElementSplit,
427
424
  handleDomEditElementDelete,
@@ -444,11 +441,6 @@ export function useAppHotkeys({
444
441
 
445
442
  const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
446
443
  const cb = cbRef.current;
447
- if (shouldHandleTimelineToggleHotkey(event)) {
448
- event.preventDefault();
449
- cb.toggleTimelineVisibility();
450
- return;
451
- }
452
444
  const key = event.key.toLowerCase();
453
445
  if (event.metaKey || event.ctrlKey) {
454
446
  dispatchModifierKey(event, key, cb);
@@ -537,6 +529,5 @@ export function useAppHotkeys({
537
529
  handleRedo,
538
530
  syncPreviewTimelineHotkey,
539
531
  syncPreviewHistoryHotkey,
540
- handleTimelineToggleHotkey,
541
532
  };
542
533
  }
@@ -2,7 +2,7 @@
2
2
  * Block drop/add handlers for the Studio.
3
3
  * Extracted from App.tsx to keep file sizes under the 600-line limit.
4
4
  */
5
- import { useCallback, useMemo, useState } from "react";
5
+ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { addBlockToProject } from "../utils/blockInstaller";
@@ -83,17 +83,41 @@ export function useBlockHandlers({
83
83
  ],
84
84
  );
85
85
 
86
+ // Block installs hit the server and end in a full preview reload; without a
87
+ // guard, repeat drops while one is in flight stack duplicate installs.
88
+ const installingBlockRef = useRef(false);
89
+ const runBlockInstall = useCallback(
90
+ async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
91
+ if (installingBlockRef.current) {
92
+ blockCtx.showToast("A block is already installing — one moment…", "info");
93
+ return null;
94
+ }
95
+ installingBlockRef.current = true;
96
+ blockCtx.showToast(`Adding ${blockName}…`, "info");
97
+ try {
98
+ return await install();
99
+ } finally {
100
+ installingBlockRef.current = false;
101
+ }
102
+ },
103
+ [blockCtx],
104
+ );
105
+
86
106
  const handleAddBlock = useCallback(
87
107
  (blockName: string) => {
88
108
  if (!projectId) return;
109
+ // fallow-ignore-next-line complexity
89
110
  void (async () => {
90
- const result = await addBlockToProject({
91
- projectId,
92
- blockName,
93
- ...blockCtx,
94
- previewIframe: previewIframeRef.current,
95
- currentTime: usePlayerStore.getState().currentTime,
96
- });
111
+ const result = await runBlockInstall(blockName, () =>
112
+ addBlockToProject({
113
+ projectId,
114
+ blockName,
115
+ ...blockCtx,
116
+ previewIframe: previewIframeRef.current,
117
+ currentTime: usePlayerStore.getState().currentTime,
118
+ }),
119
+ );
120
+ if (result === null) return;
97
121
  const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
98
122
  if (params?.length) {
99
123
  setActiveBlockParams({
@@ -107,37 +131,41 @@ export function useBlockHandlers({
107
131
  }
108
132
  })();
109
133
  },
110
- [projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab],
134
+ [projectId, blockCtx, previewIframeRef, runBlockInstall, setRightCollapsed, setRightPanelTab],
111
135
  );
112
136
 
113
137
  const handleTimelineBlockDrop = useCallback(
114
138
  (blockName: string, placement: { start: number; track: number }) => {
115
139
  if (!projectId) return;
116
- void addBlockToProject({
117
- projectId,
118
- blockName,
119
- placement,
120
- ...blockCtx,
121
- previewIframe: previewIframeRef.current,
122
- currentTime: usePlayerStore.getState().currentTime,
123
- });
140
+ void runBlockInstall(blockName, () =>
141
+ addBlockToProject({
142
+ projectId,
143
+ blockName,
144
+ placement,
145
+ ...blockCtx,
146
+ previewIframe: previewIframeRef.current,
147
+ currentTime: usePlayerStore.getState().currentTime,
148
+ }),
149
+ );
124
150
  },
125
- [projectId, blockCtx, previewIframeRef],
151
+ [projectId, blockCtx, previewIframeRef, runBlockInstall],
126
152
  );
127
153
 
128
154
  const handlePreviewBlockDrop = useCallback(
129
155
  (blockName: string, position: { left: number; top: number }) => {
130
156
  if (!projectId) return;
131
- void addBlockToProject({
132
- projectId,
133
- blockName,
134
- visualPosition: position,
135
- ...blockCtx,
136
- previewIframe: previewIframeRef.current,
137
- currentTime: usePlayerStore.getState().currentTime,
138
- });
157
+ void runBlockInstall(blockName, () =>
158
+ addBlockToProject({
159
+ projectId,
160
+ blockName,
161
+ visualPosition: position,
162
+ ...blockCtx,
163
+ previewIframe: previewIframeRef.current,
164
+ currentTime: usePlayerStore.getState().currentTime,
165
+ }),
166
+ );
139
167
  },
140
- [projectId, blockCtx, previewIframeRef],
168
+ [projectId, blockCtx, previewIframeRef, runBlockInstall],
141
169
  );
142
170
 
143
171
  return {
@@ -1,5 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
  import { useCaptionStore } from "../captions/store";
3
+ import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
3
4
  import { useCaptionSync } from "../captions/hooks/useCaptionSync";
4
5
  import { parseCaptionComposition } from "../captions/parser";
5
6
 
@@ -111,6 +112,7 @@ export function useCaptionDetection({
111
112
  const handleMessage = (e: MessageEvent) => {
112
113
  const data = e.data;
113
114
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
115
+ if (!acceptStudioRuntimeMessage(data)) return;
114
116
  tryActivateCaptions();
115
117
  }
116
118
  };
@@ -1,6 +1,33 @@
1
1
  import { useState } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+ import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
5
+
6
+ function readCompositionSizeMessage(data: unknown): CompositionDimensions | null {
7
+ if (!isStageSizeMessage(data)) return null;
8
+ const message = data;
9
+ if (!acceptStudioRuntimeMessage(message)) return null;
10
+ return readPositiveDimensions(message.width, message.height);
11
+ }
12
+
13
+ function isStageSizeMessage(value: unknown): value is Record<string, unknown> {
14
+ if (typeof value !== "object") return false;
15
+ if (value === null) return false;
16
+ const message = value as Record<string, unknown>;
17
+ return message.source === "hf-preview" && message.type === "stage-size";
18
+ }
19
+
20
+ function readPositiveNumber(value: unknown): number | null {
21
+ if (typeof value !== "number") return null;
22
+ return Number.isFinite(value) && value > 0 ? value : null;
23
+ }
24
+
25
+ function readPositiveDimensions(width: unknown, height: unknown): CompositionDimensions | null {
26
+ const parsedWidth = readPositiveNumber(width);
27
+ const parsedHeight = readPositiveNumber(height);
28
+ if (parsedWidth === null || parsedHeight === null) return null;
29
+ return { width: parsedWidth, height: parsedHeight };
30
+ }
4
31
 
5
32
  export function useCompositionDimensions() {
6
33
  const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
@@ -9,12 +36,12 @@ export function useCompositionDimensions() {
9
36
 
10
37
  useMountEffect(() => {
11
38
  const handleMessage = (e: MessageEvent) => {
12
- const data = e.data;
13
- if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
14
- const { width, height } = data as { width: number; height: number };
15
- if (!(width > 0) || !(height > 0)) return;
39
+ const dimensions = readCompositionSizeMessage(e.data);
40
+ if (!dimensions) return;
16
41
  setCompositionDimensions((prev) =>
17
- prev && prev.width === width && prev.height === height ? prev : { width, height },
42
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
43
+ ? prev
44
+ : dimensions,
18
45
  );
19
46
  };
20
47
  window.addEventListener("message", handleMessage);
@@ -1,26 +1,50 @@
1
1
  import { useCallback, useEffect, useRef, type RefObject } from "react";
2
2
 
3
3
  /**
4
- * Shared dismiss logic for context menus: closes on outside click or Escape.
5
- * Returns a ref to attach to the menu container element.
4
+ * Shared dismiss logic for context menus: closes on ANY pointerdown outside the
5
+ * menu (mouse, pen, or touch), or Escape.
6
+ *
7
+ * Two failure modes this guards against, both seen in the canvas editor:
8
+ *
9
+ * 1. The menu lives inside DomEditOverlay, whose own pointer handlers call
10
+ * `event.stopPropagation()` on several branches (marquee start, shift-select).
11
+ * A bubble-phase `mousedown`/`pointerdown` listener on `document` never fires
12
+ * for those events — the overlay eats them first — so the menu stayed open.
13
+ * Listening in the CAPTURE phase runs this dismiss BEFORE any bubble-phase
14
+ * stopPropagation, so an outside press always closes the menu.
15
+ *
16
+ * 2. `mousedown` alone misses pointer/touch-only gestures. `pointerdown` is the
17
+ * superset (fires for mouse, pen, and touch) and is exactly the event the
18
+ * overlay itself acts on, so hooking it here dismisses on the same press that
19
+ * starts a canvas gesture — no second click required.
6
20
  */
7
21
  export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
8
22
  const menuRef = useRef<HTMLDivElement>(null);
9
23
 
10
24
  const dismiss = useCallback(
11
- (e: MouseEvent | KeyboardEvent) => {
12
- if (e instanceof KeyboardEvent && e.key !== "Escape") return;
13
- if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
25
+ (e: PointerEvent | MouseEvent | KeyboardEvent) => {
26
+ if (e instanceof KeyboardEvent) {
27
+ if (e.key === "Escape") onClose();
28
+ return;
29
+ }
30
+ // Any press inside the menu is a menu interaction — leave it open (the
31
+ // item's own handler will close it after acting).
32
+ if (menuRef.current?.contains(e.target as Node)) return;
14
33
  onClose();
15
34
  },
16
35
  [onClose],
17
36
  );
18
37
 
19
38
  useEffect(() => {
20
- document.addEventListener("mousedown", dismiss);
39
+ // Capture phase so overlay/iframe-side handlers that stopPropagation on the
40
+ // bubble phase can't swallow the dismiss. `pointerdown` covers mouse + touch
41
+ // + pen; keep `mousedown` too for any synthetic-mouse path that skips it.
42
+ document.addEventListener("pointerdown", dismiss, true);
43
+ document.addEventListener("mousedown", dismiss, true);
21
44
  document.addEventListener("keydown", dismiss);
22
45
  return () => {
23
- document.removeEventListener("mousedown", dismiss);
46
+ document.removeEventListener("pointerdown", dismiss, true);
47
+ document.removeEventListener("mousedown", dismiss, true);
24
48
  document.removeEventListener("keydown", dismiss);
25
49
  };
26
50
  }, [dismiss]);