@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -3,11 +3,14 @@
3
3
  // edit's undo history, and swapping the rewritten script into the live preview
4
4
  // without a full iframe reload when possible.
5
5
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
6
- import { applySoftReload } from "../utils/gsapSoftReload";
6
+ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
7
7
  import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
10
 
11
+ class GsapPreviewConvergenceError extends Error {}
12
+ class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
13
+
11
14
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
12
15
  if (targetPath.includes("\0") || targetPath.includes("..")) {
13
16
  throw new Error(`Unsafe path: ${targetPath}`);
@@ -25,6 +28,49 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
25
28
  return data.content;
26
29
  }
27
30
 
31
+ /** Verify rollback ownership support before any GSAP mutation can land. */
32
+ async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
33
+ const response = await fetch(
34
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
35
+ );
36
+ if (!response.ok) {
37
+ throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
38
+ }
39
+ const body = await response.json().catch(() => null);
40
+ if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
41
+ throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
42
+ }
43
+ }
44
+
45
+ /** Atomically restore one GSAP mutation only while its exact output still owns
46
+ * the file. The server performs compare + write synchronously, eliminating the
47
+ * client GET→PUT window that could overwrite a successor edit. */
48
+ async function rollbackOwnedMutation(
49
+ projectId: string,
50
+ targetPath: string,
51
+ expected: string,
52
+ restore: string,
53
+ ): Promise<"restored" | "conflict"> {
54
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
55
+ throw new Error(`Unsafe path: ${targetPath}`);
56
+ }
57
+ const response = await fetch(
58
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
59
+ {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ expected, restore }),
63
+ },
64
+ );
65
+ if (!response.ok) {
66
+ throw new Error(`Failed to restore ${targetPath}`);
67
+ }
68
+ const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
69
+ if (result.restored === true && result.conflict === false) return "restored";
70
+ if (result.restored === false && result.conflict === true) return "conflict";
71
+ throw new Error(`Invalid restore response for ${targetPath}`);
72
+ }
73
+
28
74
  /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
29
75
  function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
30
76
  try {
@@ -34,19 +80,7 @@ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: n
34
80
  }
35
81
  }
36
82
 
37
- /**
38
- * Optimistically push the composition's content-driven length into the player
39
- * store right after the live DOM patch, so the duration readout + seek bar
40
- * update immediately. The readout binds to store.duration (PlayerControls);
41
- * edits only patched store.elements, so the number stayed frozen (esp. on
42
- * shrink) until a manual refresh. Read from the just-patched preview DOM (raw
43
- * data-duration) so it's immune to the runtime's truncated live durations.
44
- *
45
- * Also writes the content end into the live root's `data-duration`. Timing
46
- * edits take the soft-reload path (no full iframe reload), which lets the
47
- * runtime recompute the length from the root's declared duration and post it
48
- * back — reading the STALE root would revert this optimistic set.
49
- */
83
+ /** Keep the duration readout and live root aligned with optimistically patched clips. */
50
84
  export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
51
85
  const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
52
86
  if (end > 0) {
@@ -55,14 +89,7 @@ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): vo
55
89
  }
56
90
  }
57
91
 
58
- /**
59
- * Snapshot the store duration BEFORE an optimistic duration update
60
- * (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
61
- * rollback closure for the persist-failure path. The rollback restores BOTH
62
- * the store duration and the live root's `data-duration` — otherwise a failed
63
- * write leaves the readout/seek bar and the live root advertising a duration
64
- * the saved source never got. No-op when the duration never changed.
65
- */
92
+ /** Restore both store and live-root duration when a timing persist fails. */
66
93
  export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
67
94
  const previousDuration = usePlayerStore.getState().duration;
68
95
  return () => {
@@ -77,19 +104,38 @@ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () =>
77
104
  * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
78
105
  * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
79
106
  * when the endpoint didn't return one (older server, or a multi-script comp the
80
- * soft path can't scope), in which case the caller full-reloads as before.
107
+ * soft path can't scope) the caller then full-reloads when `mutated`, or
108
+ * rebinds the runtime timing in place when nothing was rewritten (see
109
+ * syncTimingEditPreview).
81
110
  */
82
- export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
111
+ export type GsapMutationStatus = {
112
+ mutated: boolean;
113
+ scriptText: string | null;
114
+ /** Atomic whole-file ownership pair returned by the mutation endpoint. */
115
+ before?: string;
116
+ after?: string;
117
+ };
83
118
 
84
119
  function isRecord(value: unknown): value is Record<string, unknown> {
85
120
  return typeof value === "object" && value !== null;
86
121
  }
87
122
 
88
123
  function readMutationStatus(value: unknown): GsapMutationStatus {
89
- if (!isRecord(value)) return { mutated: false, scriptText: null };
124
+ if (
125
+ !isRecord(value) ||
126
+ typeof value.mutated !== "boolean" ||
127
+ typeof value.before !== "string" ||
128
+ typeof value.after !== "string" ||
129
+ value.mutated !== (value.before !== value.after) ||
130
+ ("changed" in value && value.changed !== value.mutated)
131
+ ) {
132
+ throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
133
+ }
90
134
  return {
91
- mutated: value.mutated === true || value.changed === true,
135
+ mutated: value.mutated,
92
136
  scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
137
+ before: value.before,
138
+ after: value.after,
93
139
  };
94
140
  }
95
141
 
@@ -98,6 +144,40 @@ function readMutationError(value: unknown, fallback: string): string {
98
144
  return fallback;
99
145
  }
100
146
 
147
+ async function postGsapMutation(
148
+ projectId: string,
149
+ filePath: string,
150
+ mutation: Record<string, unknown>,
151
+ fallback: string,
152
+ ): Promise<GsapMutationStatus> {
153
+ let response: Response;
154
+ try {
155
+ response = await fetch(
156
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
157
+ {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify(mutation),
161
+ },
162
+ );
163
+ } catch (error) {
164
+ throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
165
+ cause: error,
166
+ });
167
+ }
168
+ const body: unknown = await response.json().catch(() => null);
169
+ if (!response.ok) {
170
+ throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
171
+ }
172
+ return readMutationStatus(body);
173
+ }
174
+
175
+ /** Re-derive live timing windows without re-executing composition scripts.
176
+ * Works for zero-GSAP compositions; false asks the caller to full-reload. */
177
+ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
178
+ return applySoftReloadFinalization(iframe, currentTime);
179
+ }
180
+
101
181
  /**
102
182
  * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
103
183
  * soft reload over the full iframe reload that flashes every clip.
@@ -115,15 +195,34 @@ function readMutationError(value: unknown, fallback: string): string {
115
195
  * Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
116
196
  * result (no gsap runtime / rebind hook / scopable key / script element, or the
117
197
  * re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
118
- * already applied the shift; a remount would re-flash for nothing. When the server
119
- * returned no `scriptText` (older server, multi-script comp), we also full-reload.
198
+ * already applied the shift; a remount would re-flash for nothing.
199
+ *
200
+ * `mutated` is the canonical decision, regardless of whether the server echoes
201
+ * the unchanged script text:
202
+ * - `mutated: false` — nothing was rewritten because there was NOTHING TO
203
+ * REWRITE (the clip has no domId so no id-addressed tweens, the delta was
204
+ * zero, or the server confirmed a no-op). Every script is unchanged and the
205
+ * timing attributes are already live-patched, so (when `rebindWhenUnmutated`
206
+ * allows it) `rebindPreviewTiming` re-seeks + rebinds and the runtime
207
+ * re-derives the clip windows from the live DOM — no script re-execution,
208
+ * no full-reload blink. This covers comps with zero GSAP scripts too; only
209
+ * a missing iframe/runtime hook falls back to the full reload.
210
+ * - `mutated: true` with no script — the file on disk WAS rewritten but the
211
+ * server returned no script (older server, multi-script comp): the live
212
+ * script is now stale, so a rebind against it would show wrong positions →
213
+ * full-reload.
120
214
  */
121
215
  function syncTimingEditPreview(
122
216
  iframe: HTMLIFrameElement | null,
123
- outcome: Pick<GsapMutationStatus, "scriptText">,
217
+ outcome: GsapMutationStatus,
124
218
  currentTime: number,
125
219
  reloadPreview: () => void,
220
+ rebindWhenUnmutated: boolean,
126
221
  ): void {
222
+ if (!outcome.mutated && rebindWhenUnmutated) {
223
+ if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
224
+ return;
225
+ }
127
226
  if (!iframe || !outcome.scriptText) {
128
227
  reloadPreview();
129
228
  return;
@@ -140,6 +239,14 @@ async function finishTimelineTimingFallback(input: {
140
239
  reloadPreview: () => void;
141
240
  gsapMutation?: () => Promise<GsapMutationStatus>;
142
241
  onGsapError: (error: unknown) => void;
242
+ /**
243
+ * When the mutation produced no rewrite (mutated:false, no scriptText),
244
+ * rebind the runtime timing in place (no script re-execution) instead of
245
+ * full-reloading (see syncTimingEditPreview). Callers pass false when a full
246
+ * reload is the only sync that reflects everything (e.g. a multi-file group
247
+ * edit).
248
+ */
249
+ rebindWhenUnmutated: boolean;
143
250
  }): Promise<void> {
144
251
  let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
145
252
  if (input.gsapMutation) {
@@ -147,6 +254,9 @@ async function finishTimelineTimingFallback(input: {
147
254
  outcome = await input.gsapMutation();
148
255
  } catch (error) {
149
256
  input.onGsapError(error);
257
+ // Protocol and ownership conflicts mean the live iframe may no longer
258
+ // represent the bytes that won on disk. Converge explicitly by reloading.
259
+ if (error instanceof GsapPreviewConvergenceError) input.reloadPreview();
150
260
  return;
151
261
  }
152
262
  }
@@ -155,6 +265,7 @@ async function finishTimelineTimingFallback(input: {
155
265
  outcome,
156
266
  usePlayerStore.getState().currentTime,
157
267
  input.reloadPreview,
268
+ input.rebindWhenUnmutated,
158
269
  );
159
270
  }
160
271
 
@@ -162,44 +273,124 @@ async function finishTimelineTimingFallback(input: {
162
273
  // outlast one GSAP server round-trip, never a real second edit.
163
274
  const GSAP_HISTORY_COALESCE_MS = 10_000;
164
275
 
276
+ type OwnedMutationStep = {
277
+ path: string;
278
+ before: string;
279
+ after: string;
280
+ };
281
+
282
+ type OwnedMutationRunner = (
283
+ path: string,
284
+ mutation: () => Promise<GsapMutationStatus> | null,
285
+ ) => Promise<GsapMutationStatus>;
286
+
165
287
  /**
166
- * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
167
- * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
168
- * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
169
- * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
170
- * folding both writes into one undo step. Returns the mutation status for caller reloads.
171
- *
172
- * Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
173
- * the caller must skip the preview sync), but a failure in the history-FOLD step
174
- * (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
175
- * the mutation status is still returned — the server rewrite already landed on disk, so
176
- * the caller must still sync the preview or it shows stale GSAP positions.
288
+ * Restore successful mutation steps in reverse order through the server's
289
+ * atomic compare-and-restore endpoint. A conflict means a successor owns the
290
+ * file and is deliberately preserved. Conflicts and restore errors require a
291
+ * preview reload; errors are also reported through onError.
292
+ */
293
+ async function rollbackMutatedFiles(
294
+ projectId: string,
295
+ ownedSteps: readonly OwnedMutationStep[],
296
+ onError: (error: unknown) => void,
297
+ ): Promise<"restored" | "convergence-required"> {
298
+ let convergenceRequired = false;
299
+ for (let index = ownedSteps.length - 1; index >= 0; index -= 1) {
300
+ const step = ownedSteps[index];
301
+ if (!step || step.before === step.after) continue;
302
+ try {
303
+ if (
304
+ (await rollbackOwnedMutation(projectId, step.path, step.after, step.before)) === "conflict"
305
+ ) {
306
+ convergenceRequired = true;
307
+ }
308
+ } catch (rollbackError) {
309
+ convergenceRequired = true;
310
+ onError(rollbackError);
311
+ }
312
+ }
313
+ return convergenceRequired ? "convergence-required" : "restored";
314
+ }
315
+
316
+ async function rollbackAfterFailure(
317
+ projectId: string,
318
+ ownedSteps: readonly OwnedMutationStep[],
319
+ onError: (error: unknown) => void,
320
+ originalError: unknown,
321
+ ): Promise<never> {
322
+ const outcome = await rollbackMutatedFiles(projectId, ownedSteps, onError);
323
+ if (outcome === "convergence-required") {
324
+ throw new GsapPreviewConvergenceError(
325
+ "GSAP rollback could not safely restore every owned write; preview reload required",
326
+ { cause: originalError },
327
+ );
328
+ }
329
+ throw originalError;
330
+ }
331
+
332
+ /**
333
+ * Fold server-owned GSAP rewrites into the preceding timing history entry.
334
+ * Every mutation contributes the atomic before/after pair returned by its
335
+ * endpoint; the first before and last after for each contiguous file chain are
336
+ * the only bytes this transaction may record or roll back.
177
337
  */
338
+ // The ledger, reverse rollback, final ownership check, and history fold are one
339
+ // transaction; extracting phases would obscure which function owns convergence.
340
+ // fallow-ignore-next-line complexity
178
341
  async function foldGsapMutationIntoHistory(input: {
179
342
  projectId: string;
180
- paths: string[];
181
343
  label: string;
182
344
  coalesceKey?: string;
183
345
  recordEdit: (edit: RecordEditInput) => Promise<void>;
184
- gsapMutation: () => Promise<GsapMutationStatus>;
185
- onFoldError: (error: unknown) => void;
346
+ gsapMutation: (runOwnedMutation: OwnedMutationRunner) => Promise<GsapMutationStatus>;
347
+ onRollbackError: (error: unknown) => void;
186
348
  }): Promise<GsapMutationStatus> {
187
- const uniquePaths = [...new Set(input.paths)];
188
- const before = new Map<string, string>();
189
- // A `before`-snapshot failure propagates like a mutation failure: the mutation
190
- // has not run yet, so nothing landed on disk and skipping the sync is correct.
191
- for (const path of uniquePaths) {
192
- before.set(path, await readFileContent(input.projectId, path));
349
+ const ownedSteps: OwnedMutationStep[] = [];
350
+ const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
351
+ const pending = mutation();
352
+ if (!pending) return { mutated: false, scriptText: null };
353
+ const status = await pending;
354
+ if (!status.mutated) return status;
355
+ if (status.before === undefined || status.after === undefined) {
356
+ throw new GsapOwnershipProtocolError(
357
+ `GSAP mutation returned no owned before/after pair for ${path}`,
358
+ );
359
+ }
360
+ const previous = [...ownedSteps].reverse().find((step) => step.path === path);
361
+ const step = { path, before: status.before, after: status.after };
362
+ ownedSteps.push(step);
363
+ // A foreign writer landed between two same-file mutation steps. The second
364
+ // step already wrote, so keep it in ownedSteps for reverse rollback, then
365
+ // fail rather than folding foreign bytes into this gesture's history.
366
+ if (previous && previous.after !== step.before) {
367
+ throw new Error(`GSAP mutation ownership chain broke for ${path}`);
368
+ }
369
+ return status;
370
+ };
371
+ let status: GsapMutationStatus;
372
+ try {
373
+ await requireGsapOwnershipProtocol(input.projectId);
374
+ status = await input.gsapMutation(runOwnedMutation);
375
+ } catch (error) {
376
+ return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
193
377
  }
194
- const status = await input.gsapMutation();
195
378
  if (status.mutated) {
196
379
  try {
380
+ const ownershipByPath = new Map<string, { before: string; after: string }>();
381
+ for (const step of ownedSteps) {
382
+ const owned = ownershipByPath.get(step.path);
383
+ if (owned) owned.after = step.after;
384
+ else ownershipByPath.set(step.path, { before: step.before, after: step.after });
385
+ }
197
386
  const files: Record<string, { before: string; after: string }> = {};
198
- for (const path of uniquePaths) {
199
- const priorContent = before.get(path);
387
+ for (const [path, owned] of ownershipByPath) {
200
388
  const finalContent = await readFileContent(input.projectId, path);
201
- if (priorContent !== undefined && finalContent !== priorContent) {
202
- files[path] = { before: priorContent, after: finalContent };
389
+ if (finalContent !== owned.after) {
390
+ throw new Error(`GSAP mutation ownership lost for ${path}`);
391
+ }
392
+ if (owned.before !== owned.after) {
393
+ files[path] = owned;
203
394
  }
204
395
  }
205
396
  if (Object.keys(files).length > 0) {
@@ -212,7 +403,7 @@ async function foldGsapMutationIntoHistory(input: {
212
403
  });
213
404
  }
214
405
  } catch (error) {
215
- input.onFoldError(error);
406
+ return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
216
407
  }
217
408
  }
218
409
  return status;
@@ -230,23 +421,16 @@ export async function shiftGsapPositions(
230
421
  delta: number,
231
422
  ): Promise<GsapMutationStatus> {
232
423
  if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
233
- const res = await fetch(
234
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
424
+ return postGsapMutation(
425
+ projectId,
426
+ filePath,
235
427
  {
236
- method: "POST",
237
- headers: { "Content-Type": "application/json" },
238
- body: JSON.stringify({
239
- type: "shift-positions",
240
- targetSelector: `#${elementId}`,
241
- delta,
242
- }),
428
+ type: "shift-positions",
429
+ targetSelector: `#${elementId}`,
430
+ delta,
243
431
  },
432
+ "shift-positions failed",
244
433
  );
245
- if (!res.ok) {
246
- const err = await res.json().catch(() => null);
247
- throw new Error(readMutationError(err, "shift-positions failed"));
248
- }
249
- return readMutationStatus(await res.json().catch(() => null));
250
434
  }
251
435
 
252
436
  export async function scaleGsapPositions(
@@ -262,26 +446,19 @@ export async function scaleGsapPositions(
262
446
  return { mutated: false, scriptText: null };
263
447
  if (oldStart === newStart && oldDuration === newDuration)
264
448
  return { mutated: false, scriptText: null };
265
- const res = await fetch(
266
- `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
449
+ return postGsapMutation(
450
+ projectId,
451
+ filePath,
267
452
  {
268
- method: "POST",
269
- headers: { "Content-Type": "application/json" },
270
- body: JSON.stringify({
271
- type: "scale-positions",
272
- targetSelector: `#${elementId}`,
273
- oldStart,
274
- oldDuration,
275
- newStart,
276
- newDuration,
277
- }),
453
+ type: "scale-positions",
454
+ targetSelector: `#${elementId}`,
455
+ oldStart,
456
+ oldDuration,
457
+ newStart,
458
+ newDuration,
278
459
  },
460
+ "scale-positions failed",
279
461
  );
280
- if (!res.ok) {
281
- const err = await res.json().catch(() => null);
282
- throw new Error(readMutationError(err, "scale-positions failed"));
283
- }
284
- return readMutationStatus(await res.json().catch(() => null));
285
462
  }
286
463
 
287
464
  /** Timing delta a single-clip edit applies to its GSAP tweens. */
@@ -297,8 +474,11 @@ export type SingleClipGsapEdit =
297
474
  * Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
298
475
  * server shift/scale mutation, folds the rewrite into the timing edit's history
299
476
  * entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
300
- * the rewritten script full reload when the mutation is skipped, failed, or
301
- * returned no script.
477
+ * the rewritten script. When there was nothing to rewrite (no domId e.g. a
478
+ * selector-addressed caption clip — zero delta, or a server no-op) it rebinds
479
+ * the runtime timing in place instead of full-reloading; full reload remains
480
+ * for genuine rewrites without a returned script and for comps the soft path
481
+ * can't handle (see syncTimingEditPreview).
302
482
  */
303
483
  export function finishClipTimingFallback(input: {
304
484
  iframe: HTMLIFrameElement | null;
@@ -338,15 +518,16 @@ export function finishClipTimingFallback(input: {
338
518
  ? () =>
339
519
  foldGsapMutationIntoHistory({
340
520
  projectId,
341
- paths: [targetPath],
342
521
  label: input.label,
343
522
  coalesceKey: input.coalesceKey,
344
523
  recordEdit: input.recordEdit,
345
- gsapMutation: () => runMutation(projectId, domId),
346
- onFoldError: onGsapError,
524
+ gsapMutation: (runOwnedMutation) =>
525
+ runOwnedMutation(targetPath, () => runMutation(projectId, domId)),
526
+ onRollbackError: onGsapError,
347
527
  })
348
528
  : undefined,
349
529
  onGsapError,
530
+ rebindWhenUnmutated: true,
350
531
  });
351
532
  }
352
533
 
@@ -386,18 +567,17 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
386
567
  gsapMutation: () =>
387
568
  foldGsapMutationIntoHistory({
388
569
  projectId: input.projectId,
389
- paths: input.changes.map((change) => input.resolveChangePath(change.element)),
390
570
  label: input.label,
391
571
  coalesceKey: input.coalesceKey,
392
572
  recordEdit: input.recordEdit,
393
- gsapMutation: async () => {
573
+ gsapMutation: async (runOwnedMutation) => {
394
574
  let mutated = false;
395
575
  let scriptText: GsapMutationStatus["scriptText"] = null;
396
576
  for (const change of input.changes) {
397
577
  const changePath = input.resolveChangePath(change.element);
398
- const pending = input.mutateChange(change, changePath);
399
- if (!pending) continue;
400
- const status = await pending;
578
+ const status = await runOwnedMutation(changePath, () =>
579
+ input.mutateChange(change, changePath),
580
+ );
401
581
  mutated = mutated || status.mutated;
402
582
  // The LAST mutation against the active comp carries the cumulative
403
583
  // rewritten script for that file.
@@ -405,8 +585,13 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
405
585
  }
406
586
  return { mutated, scriptText: otherFileChanged ? null : scriptText };
407
587
  },
408
- onFoldError: onGsapError,
588
+ onRollbackError: onGsapError,
409
589
  }),
410
590
  onGsapError,
591
+ // A batch where nothing needed rewriting (every change was zero-delta or
592
+ // no-domId, e.g. closing a gap over caption clips) still needs the runtime
593
+ // to re-derive clip windows — the in-place timing rebind covers that. But
594
+ // when another file changed, only a full reload reflects every file.
595
+ rebindWhenUnmutated: !otherFileChanged,
411
596
  });
412
597
  }
@@ -44,7 +44,9 @@ describe("toggleTimelineTrackHidden", () => {
44
44
  if (iframe.contentDocument) {
45
45
  iframe.contentDocument.body.innerHTML = `
46
46
  <div id="hero"></div>
47
- <div id="subtitle"></div>
47
+ <div data-composition-id="scene" data-composition-file="scene.html">
48
+ <div id="subtitle"></div>
49
+ </div>
48
50
  `;
49
51
  }
50
52
 
@@ -199,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
199
201
  usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
200
202
  ).toBeUndefined();
201
203
  });
204
+
205
+ it("hides several elements in ONE atomic write when given an array of keys", async () => {
206
+ const files = new Map([
207
+ [
208
+ "index.html",
209
+ `<div id="hero" data-start="0" data-duration="2"></div>
210
+ <div id="caption" data-start="1" data-duration="2"></div>
211
+ <div id="badge" data-start="2" data-duration="2"></div>`,
212
+ ],
213
+ ]);
214
+ stubProjectFiles(files);
215
+
216
+ const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
217
+ const caption = element({
218
+ id: "caption",
219
+ key: "index.html:#caption",
220
+ domId: "caption",
221
+ track: 1,
222
+ });
223
+ const badge = element({ id: "badge", key: "index.html:#badge", domId: "badge", track: 2 });
224
+
225
+ const writes: Array<{ path: string; content: string }> = [];
226
+ const recordEdit = vi.fn();
227
+
228
+ await toggleTimelineElementHidden({
229
+ projectId: "project-1",
230
+ activeCompPath: "index.html",
231
+ timelineElements: [hero, caption, badge],
232
+ elementKey: ["index.html:#hero", "index.html:#caption"],
233
+ hidden: true,
234
+ previewIframe: null,
235
+ writeProjectFile: async (path, content) => {
236
+ writes.push({ path, content });
237
+ },
238
+ recordEdit,
239
+ domEditSaveTimestampRef: { current: 0 },
240
+ pendingTimelineEditPathRef: { current: new Set() },
241
+ });
242
+
243
+ // One write carrying BOTH hides — per-element writes would clobber each
244
+ // other (each starts from the original file content).
245
+ expect(writes).toHaveLength(1);
246
+ expect(writes[0]?.content).toContain(
247
+ 'id="hero" data-start="0" data-duration="2" data-hidden=""',
248
+ );
249
+ expect(writes[0]?.content).toContain(
250
+ 'id="caption" data-start="1" data-duration="2" data-hidden=""',
251
+ );
252
+ expect(writes[0]?.content).toContain('id="badge" data-start="2" data-duration="2"></div>');
253
+ expect(recordEdit).toHaveBeenCalledTimes(1);
254
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
255
+ });
202
256
  });