@hyperframes/studio 0.7.55 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,17 +1,20 @@
1
1
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
- import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
2
+ import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
3
3
  import {
4
4
  formatTimelineAttributeNumber,
5
5
  type TimelineStackingReorderIntent,
6
6
  } from "../player/components/timelineEditing";
7
7
  import { getElementZIndex } from "../player/lib/layerOrdering";
8
- import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
9
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
10
- import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
11
- import type { EditHistoryKind } from "../utils/editHistory";
8
+ import {
9
+ furthestClipEndFromSource,
10
+ getTimelineElementIdentity,
11
+ } from "../player/lib/timelineElementHelpers";
12
+ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
12
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
13
- import { extendRootDurationInSource } from "../utils/rootDuration";
14
-
14
+ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
+ import { readFileContent } from "./timelineTimingSync";
16
+ export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
17
+ export { readFileContent };
15
18
  function isHTMLElement(element: Element | null): element is HTMLElement {
16
19
  if (!element) return false;
17
20
  // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
@@ -20,7 +23,6 @@ function isHTMLElement(element: Element | null): element is HTMLElement {
20
23
  const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
21
24
  return element instanceof Ctor;
22
25
  }
23
-
24
26
  /**
25
27
  * Resolve a timeline vertical move to a z-index stacking reorder and commit it
26
28
  * through the shared layers-panel reorder path. Reads live sibling z-index from
@@ -38,13 +40,12 @@ export function applyTimelineStackingReorder(input: {
38
40
  iframe: HTMLIFrameElement | null;
39
41
  activeCompPath: string | null;
40
42
  commit: TimelineZIndexReorderCommit | null | undefined;
43
+ coalesceKey?: string;
41
44
  }): Promise<void> {
42
45
  // Audio has no visual stacking; a vertical drag on it must never write z-index.
43
46
  if (input.element.tag === "audio") return Promise.resolve();
44
-
45
47
  const intent = input.stackingReorder ?? null;
46
48
  if (intent == null || intent.zIndexChanges.length === 0) return Promise.resolve();
47
-
48
49
  // Resolve each change's live element from the change's OWN locator (the intent
49
50
  // is self-contained), falling back to the top-level element list. Sub-comp
50
51
  // children aren't in `timelineElements`, so a list-only lookup would miss them.
@@ -67,7 +68,6 @@ export function applyTimelineStackingReorder(input: {
67
68
  sourceFile: string;
68
69
  key: string;
69
70
  }> = [];
70
-
71
71
  for (const change of intent.zIndexChanges) {
72
72
  const sibling = siblingByKey.get(change.key);
73
73
  const domId = change.domId ?? sibling?.domId;
@@ -86,48 +86,17 @@ export function applyTimelineStackingReorder(input: {
86
86
  key: change.key,
87
87
  });
88
88
  }
89
-
90
89
  if (commitEntries.length === 0) return Promise.resolve();
91
- return input.commit?.(commitEntries) ?? Promise.resolve();
92
- }
93
-
94
- /**
95
- * Remove the keyframes currently selected in the player store from the active
96
- * element's GSAP animation. Reads selection lazily so it stays correct when
97
- * invoked from a ref callback. Extracted from StudioApp to keep it under the
98
- * studio 600-LOC cap.
99
- */
100
- export function deleteSelectedKeyframes(session: {
101
- selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
102
- handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
103
- }): void {
104
- const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
105
- const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
106
- if (!animation) return;
107
- // Only the active element's keyframes; a stale cross-element selection must not delete here.
108
- for (const pct of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
109
- session.handleGsapRemoveKeyframe(animation.id, pct);
110
- }
90
+ return input.commit?.(commitEntries, input.coalesceKey) ?? Promise.resolve();
111
91
  }
112
-
113
92
  export function extendRootDurationIfNeeded(newEnd: number): boolean {
114
93
  const store = usePlayerStore.getState();
115
94
  if (newEnd <= store.duration) return false;
116
95
  store.setDuration(newEnd);
117
96
  return true;
118
97
  }
119
-
120
98
  // ── Types ──
121
-
122
- export interface RecordEditInput {
123
- label: string;
124
- kind: EditHistoryKind;
125
- coalesceKey?: string;
126
- /** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
127
- coalesceMs?: number;
128
- files: Record<string, { before: string; after: string }>;
129
- }
130
-
99
+ export type { RecordEditInput } from "../utils/studioFileHistory";
131
100
  export function buildPatchTarget(element: {
132
101
  domId?: string;
133
102
  hfId?: string;
@@ -150,9 +119,7 @@ export function buildPatchTarget(element: {
150
119
  }
151
120
  return null;
152
121
  }
153
-
154
122
  export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
155
-
156
123
  // The runtime re-reads data-start/data-duration from the DOM on each sync tick
157
124
  // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
158
125
  // picked up automatically on the next frame without a rebind call.
@@ -172,7 +139,6 @@ export function findTimelineElementInIframe(
172
139
  return null;
173
140
  }
174
141
  }
175
-
176
142
  export function patchIframeDomTiming(
177
143
  iframe: HTMLIFrameElement | null,
178
144
  element: TimelineElement,
@@ -186,24 +152,6 @@ export function patchIframeDomTiming(
186
152
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
187
153
  }
188
154
  }
189
-
190
- function postRootDurationToPreview(
191
- iframe: HTMLIFrameElement | null,
192
- durationSeconds: number,
193
- ): void {
194
- const duration = Number(durationSeconds);
195
- if (!Number.isFinite(duration) || duration <= 0) return;
196
- iframe?.contentWindow?.postMessage(
197
- {
198
- source: "hf-parent",
199
- type: "control",
200
- action: "set-root-duration",
201
- durationSeconds: duration,
202
- },
203
- "*",
204
- );
205
- }
206
-
207
155
  // fallow-ignore-next-line complexity
208
156
  function resolveResizePlaybackStart(
209
157
  original: string,
@@ -236,13 +184,32 @@ export function buildTimelineMoveTimingPatch(
236
184
  target: PatchTarget,
237
185
  start: number,
238
186
  duration: number,
187
+ track?: number,
239
188
  ): string {
240
- const patched = applyPatchByTarget(original, target, {
189
+ if (!Number.isFinite(start) || !Number.isFinite(duration)) {
190
+ console.warn(
191
+ `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
192
+ );
193
+ return original;
194
+ }
195
+ let patched = applyPatchByTarget(original, target, {
241
196
  type: "attribute",
242
197
  property: "start",
243
198
  value: formatTimelineAttributeNumber(start),
244
199
  });
245
- return extendRootDurationInSource(patched, start + duration);
200
+ if (track != null && Number.isFinite(track)) {
201
+ patched = applyPatchByTarget(patched, target, {
202
+ type: "attribute",
203
+ property: "track-index",
204
+ value: formatTimelineAttributeNumber(track),
205
+ });
206
+ }
207
+ // Content-driven duration: sync data-duration to the furthest clip end read
208
+ // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
209
+ // past the end and shrinks if the furthest clip moved left. Measured from the
210
+ // source, NOT the store — store durations are runtime-truncated to the current
211
+ // comp length, which would ratchet the duration down every move.
212
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
246
213
  }
247
214
 
248
215
  export function buildTimelineResizeTimingPatch(
@@ -269,7 +236,10 @@ export function buildTimelineResizeTimingPatch(
269
236
  value: formatTimelineAttributeNumber(pbs.value),
270
237
  });
271
238
  }
272
- return extendRootDurationInSource(patched, updates.start + updates.duration);
239
+ // Content-driven duration from the PATCHED SOURCE (raw data-duration)
240
+ // grows/shrinks to the furthest clip end. Not from the store, whose
241
+ // durations are runtime-truncated.
242
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
273
243
  }
274
244
 
275
245
  export interface PersistTimelineEditInput {
@@ -349,13 +319,24 @@ export async function persistTimelineBatchEdit(
349
319
  }
350
320
 
351
321
  const current = patchedByPath.get(targetPath) ?? original;
352
- const patched = change.buildPatches(current, patchTarget);
353
- if (patched === current) {
322
+ // Resolve the target FIRST: byte-identical output below is only a legit
323
+ // no-op when the member actually resolved in the source. A mistargeted
324
+ // member (stale id/selector) must fail loudly like the single-edit path,
325
+ // not be silently dropped as "already at target".
326
+ if (!findTagByTarget(current, patchTarget)) {
354
327
  throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
355
328
  }
329
+ const patched = change.buildPatches(current, patchTarget);
330
+ // The target resolved, so a member whose attributes already hold the target
331
+ // values patches to the identical string — e.g. a track-insert renumber
332
+ // where one clip's lane is already correct. That is a legitimate no-op:
333
+ // skip it instead of aborting (and rolling back) the whole batch.
334
+ if (patched === current) continue;
356
335
  patchedByPath.set(targetPath, patched);
357
336
  }
358
337
 
338
+ if (patchedByPath.size === 0) return;
339
+
359
340
  const files = Object.fromEntries(patchedByPath);
360
341
  for (const targetPath of Object.keys(files)) {
361
342
  input.pendingTimelineEditPathRef.current.add(targetPath);
@@ -374,226 +355,6 @@ export async function persistTimelineBatchEdit(
374
355
  input.domEditSaveTimestampRef.current = Date.now();
375
356
  }
376
357
 
377
- export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
378
- if (targetPath.includes("\0") || targetPath.includes("..")) {
379
- throw new Error(`Unsafe path: ${targetPath}`);
380
- }
381
- const response = await fetch(
382
- `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
383
- );
384
- if (!response.ok) {
385
- throw new Error(`Failed to read ${targetPath}`);
386
- }
387
- const data = (await response.json()) as { content?: string };
388
- if (typeof data.content !== "string") {
389
- throw new Error(`Missing file contents for ${targetPath}`);
390
- }
391
- return data.content;
392
- }
393
-
394
- export type GsapMutationStatus = { mutated: boolean };
395
-
396
- function isRecord(value: unknown): value is Record<string, unknown> {
397
- return typeof value === "object" && value !== null;
398
- }
399
-
400
- function readMutationStatus(value: unknown): GsapMutationStatus {
401
- if (!isRecord(value)) return { mutated: false };
402
- return { mutated: value.mutated === true || value.changed === true };
403
- }
404
-
405
- function readMutationError(value: unknown, fallback: string): string {
406
- if (isRecord(value) && typeof value.error === "string") return value.error;
407
- return fallback;
408
- }
409
-
410
- export async function finishTimelineTimingFallback(input: {
411
- iframe: HTMLIFrameElement | null;
412
- needsExtension: boolean;
413
- rootDurationSeconds: number;
414
- reloadPreview: () => void;
415
- gsapMutation?: () => Promise<GsapMutationStatus>;
416
- onGsapError: (error: unknown) => void;
417
- }): Promise<void> {
418
- let gsapMutated = false;
419
- if (input.gsapMutation) {
420
- try {
421
- gsapMutated = (await input.gsapMutation()).mutated;
422
- } catch (error) {
423
- input.onGsapError(error);
424
- return;
425
- }
426
- }
427
- if (input.needsExtension) {
428
- postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
429
- if (gsapMutated) input.reloadPreview();
430
- return;
431
- }
432
- input.reloadPreview();
433
- }
434
-
435
- // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
436
- // outlast one GSAP server round-trip, never a real second edit.
437
- const GSAP_HISTORY_COALESCE_MS = 10_000;
438
-
439
- /**
440
- * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
441
- * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
442
- * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
443
- * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
444
- * folding both writes into one undo step. Returns the mutation status for caller reloads.
445
- */
446
- export async function foldGsapMutationIntoHistory(input: {
447
- projectId: string;
448
- paths: string[];
449
- label: string;
450
- coalesceKey?: string;
451
- recordEdit: (edit: RecordEditInput) => Promise<void>;
452
- gsapMutation: () => Promise<GsapMutationStatus>;
453
- }): Promise<GsapMutationStatus> {
454
- const uniquePaths = [...new Set(input.paths)];
455
- const before = new Map<string, string>();
456
- for (const path of uniquePaths) {
457
- before.set(path, await readFileContent(input.projectId, path));
458
- }
459
- const status = await input.gsapMutation();
460
- if (status.mutated) {
461
- const files: Record<string, { before: string; after: string }> = {};
462
- for (const path of uniquePaths) {
463
- const priorContent = before.get(path);
464
- const finalContent = await readFileContent(input.projectId, path);
465
- if (priorContent !== undefined && finalContent !== priorContent) {
466
- files[path] = { before: priorContent, after: finalContent };
467
- }
468
- }
469
- if (Object.keys(files).length > 0) {
470
- await input.recordEdit({
471
- label: input.label,
472
- kind: "timeline",
473
- coalesceKey: input.coalesceKey,
474
- coalesceMs: GSAP_HISTORY_COALESCE_MS,
475
- files,
476
- });
477
- }
478
- }
479
- return status;
480
- }
481
-
482
- /**
483
- * Shift all GSAP animation positions targeting a given element by a time delta.
484
- * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
485
- */
486
- export async function shiftGsapPositions(
487
- projectId: string,
488
- filePath: string,
489
- elementId: string,
490
- delta: number,
491
- ): Promise<GsapMutationStatus> {
492
- if (delta === 0 || !elementId) return { mutated: false };
493
- const res = await fetch(
494
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
495
- {
496
- method: "POST",
497
- headers: { "Content-Type": "application/json" },
498
- body: JSON.stringify({
499
- type: "shift-positions",
500
- targetSelector: `#${elementId}`,
501
- delta,
502
- }),
503
- },
504
- );
505
- if (!res.ok) {
506
- const err = await res.json().catch(() => null);
507
- throw new Error(readMutationError(err, "shift-positions failed"));
508
- }
509
- return readMutationStatus(await res.json().catch(() => null));
510
- }
511
-
512
- export async function scaleGsapPositions(
513
- projectId: string,
514
- filePath: string,
515
- elementId: string,
516
- oldStart: number,
517
- oldDuration: number,
518
- newStart: number,
519
- newDuration: number,
520
- ): Promise<GsapMutationStatus> {
521
- if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
522
- if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
523
- const res = await fetch(
524
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
525
- {
526
- method: "POST",
527
- headers: { "Content-Type": "application/json" },
528
- body: JSON.stringify({
529
- type: "scale-positions",
530
- targetSelector: `#${elementId}`,
531
- oldStart,
532
- oldDuration,
533
- newStart,
534
- newDuration,
535
- }),
536
- },
537
- );
538
- if (!res.ok) {
539
- const err = await res.json().catch(() => null);
540
- throw new Error(readMutationError(err, "scale-positions failed"));
541
- }
542
- return readMutationStatus(await res.json().catch(() => null));
543
- }
544
-
545
- /** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
546
- export function foldedShiftGsapMutation(input: {
547
- projectId: string;
548
- targetPath: string;
549
- domId: string;
550
- delta: number;
551
- label: string;
552
- coalesceKey?: string;
553
- recordEdit: (edit: RecordEditInput) => Promise<void>;
554
- }): () => Promise<GsapMutationStatus> {
555
- return () =>
556
- foldGsapMutationIntoHistory({
557
- projectId: input.projectId,
558
- paths: [input.targetPath],
559
- label: input.label,
560
- coalesceKey: input.coalesceKey,
561
- recordEdit: input.recordEdit,
562
- gsapMutation: () =>
563
- shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
564
- });
565
- }
566
-
567
- /** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
568
- export function foldedScaleGsapMutation(input: {
569
- projectId: string;
570
- targetPath: string;
571
- domId: string;
572
- from: { start: number; duration: number };
573
- to: { start: number; duration: number };
574
- label: string;
575
- coalesceKey?: string;
576
- recordEdit: (edit: RecordEditInput) => Promise<void>;
577
- }): () => Promise<GsapMutationStatus> {
578
- return () =>
579
- foldGsapMutationIntoHistory({
580
- projectId: input.projectId,
581
- paths: [input.targetPath],
582
- label: input.label,
583
- coalesceKey: input.coalesceKey,
584
- recordEdit: input.recordEdit,
585
- gsapMutation: () =>
586
- scaleGsapPositions(
587
- input.projectId,
588
- input.targetPath,
589
- input.domId,
590
- input.from.start,
591
- input.from.duration,
592
- input.to.start,
593
- input.to.duration,
594
- ),
595
- });
596
- }
597
-
598
- // Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
599
358
  export { applyPatchByTarget, formatTimelineAttributeNumber };
359
+
360
+ export { patchDocumentRootDuration } from "./timelineEditingGsap";
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
4
+
5
+ type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
6
+
7
+ const element = (id: string, track: number): TimelineElement => ({
8
+ id,
9
+ key: id,
10
+ tag: "div",
11
+ start: 0,
12
+ duration: 2,
13
+ track,
14
+ });
15
+
16
+ const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
17
+ { element: element("a", 0), updates: { start: 1, track: 1 } },
18
+ { element: element("b", bTrack), updates: { start: 3, track: 2 } },
19
+ ];
20
+
21
+ const movedPair = (edits: MoveArgs[0]) => [
22
+ { element: edits[0].element, start: 1, track: 1 },
23
+ { element: edits[1].element, start: 3, track: 2 },
24
+ ];
25
+
26
+ const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
27
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
28
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
29
+ handleTimelineGroupMove,
30
+ });
31
+ return handleTimelineGroupMove;
32
+ };
33
+
34
+ describe("persistTimelineMoveEditsAtomically", () => {
35
+ it("persists two vertical edits as one group with the gesture coalesce key", async () => {
36
+ const edits = twoLaneEdits(1);
37
+ const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
38
+ expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
39
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
40
+ coalesceKey: "clip-lane-move:7",
41
+ });
42
+ });
43
+
44
+ it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
45
+ const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
46
+ const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
47
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
48
+ coalesceKey: undefined,
49
+ });
50
+ });
51
+
52
+ it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
53
+ // Lane = authored data-track-index; a vertical move that never hits disk
54
+ // snaps back on the next normalize, so the lane change MUST persist.
55
+ const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
56
+ const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
57
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
58
+ [{ element: edit.element, start: 1, track: 1 }],
59
+ { coalesceKey: "clip-lane-move:7" },
60
+ );
61
+ });
62
+
63
+ it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
64
+ const edits = twoLaneEdits(2);
65
+ const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
66
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
67
+ coalesceKey: "clip-lane-move:7",
68
+ });
69
+ });
70
+
71
+ it("rejects without retrying individual members when the atomic batch fails", async () => {
72
+ const failure = new Error("batch failed");
73
+ const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
74
+ await expect(
75
+ persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
76
+ handleTimelineGroupMove,
77
+ }),
78
+ ).rejects.toBe(failure);
79
+ expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
80
+ });
81
+ });
@@ -0,0 +1,39 @@
1
+ import type { TimelineElement } from "../player";
2
+ import type {
3
+ TimelineGroupCommitOptions,
4
+ TimelineGroupMoveChange,
5
+ } from "./useTimelineGroupEditing";
6
+
7
+ interface MoveEdit {
8
+ element: TimelineElement;
9
+ updates: Pick<TimelineElement, "start" | "track">;
10
+ }
11
+
12
+ interface AtomicMoveDeps {
13
+ handleTimelineGroupMove: (
14
+ changes: TimelineGroupMoveChange[],
15
+ options?: TimelineGroupCommitOptions,
16
+ ) => Promise<void>;
17
+ }
18
+
19
+ export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
20
+
21
+ export function persistTimelineMoveEditsAtomically(
22
+ edits: MoveEdit[],
23
+ coalesceKey: string | undefined,
24
+ operation: TimelineMoveOperation,
25
+ deps: AtomicMoveDeps,
26
+ ): Promise<void> {
27
+ return deps.handleTimelineGroupMove(
28
+ edits.map(({ element, updates }) => ({
29
+ element,
30
+ start: updates.start,
31
+ // Stable track lanes: a lane is the authored data-track-index, so every
32
+ // vertical gesture (lane-reorder AND track-insert) must persist the track;
33
+ // z is paint order only and is synced separately. Plain horizontal moves
34
+ // ("timing") omit it so they stay eligible for the SDK fast path.
35
+ track: operation === "timing" ? undefined : updates.track,
36
+ })),
37
+ { coalesceKey },
38
+ );
39
+ }