@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
@@ -1,125 +1,51 @@
1
- import type { MutableRefObject } from "react";
2
1
  import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
3
2
  import type { DomEditSelection } from "../components/editor/domEditing";
4
- import type { EditHistoryKind } from "./editHistory";
5
3
  import type { PatchOperation } from "./sourcePatcher";
6
4
  import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
7
5
  import { trackStudioEvent } from "./studioTelemetry";
8
- import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
9
6
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
10
7
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
11
8
  import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
9
+ import {
10
+ asCutoverError,
11
+ declinedCutover,
12
+ persistSdkCandidateMutation,
13
+ type CutoverDeps,
14
+ type CutoverOptions,
15
+ type CutoverResult,
16
+ } from "./sdkEditTransaction";
12
17
 
13
18
  export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
14
-
15
- export interface CutoverDeps {
16
- editHistory: {
17
- recordEdit: (entry: {
18
- label: string;
19
- kind: EditHistoryKind;
20
- coalesceKey?: string;
21
- coalesceMs?: number;
22
- files: Record<string, { before: string; after: string }>;
23
- }) => Promise<void>;
24
- };
25
- writeProjectFile: (path: string, content: string) => Promise<void>;
26
- reloadPreview: () => void;
27
- domEditSaveTimestampRef: MutableRefObject<number>;
28
- /**
29
- * Optional post-write refresh. When provided, it REPLACES the default
30
- * reloadPreview() — the GSAP path passes one that soft-reloads (preserving
31
- * the playhead) and invalidates the keyframe/gsap panel cache. Receives the
32
- * serialized document just written.
33
- */
34
- refresh?: (after: string) => void;
35
- /**
36
- * Path of the composition the SDK session was opened for. The session models
37
- * ONLY this file (serialize() emits the whole active composition), so any edit
38
- * whose targetPath differs (a sub-composition file) must take the server path
39
- * — otherwise we'd write the full active-comp serialization into that file.
40
- */
41
- compositionPath?: string | null;
42
- /**
43
- * Optional per-key task serializer (the same `gsap-file:${file}` serializer the
44
- * legacy `commitMutation` uses). When provided, every GSAP-op persist routes its
45
- * read-serialize → dispatch → serialize → write through it so two concurrent
46
- * same-file flushes can't interleave their read-modify-write and lose an edit.
47
- * Absent (e.g. in unit tests) → ops run unserialized as before.
48
- */
49
- serialize?: <T>(key: string, task: () => Promise<T>) => Promise<T>;
50
- /**
51
- * Optional reader for the on-disk content of targetPath. Timing/GSAP persists
52
- * use it to capture the EXACT prior bytes as the undo-history `before`, so undo
53
- * restores the file verbatim instead of a normalized SDK re-emit (which would
54
- * reformat the whole file). The style/delete paths already thread originalContent
55
- * in explicitly; this gives timing/GSAP parity without touching every call site.
56
- * Absent → falls back to the SDK's pre-edit serialize() (the prior behavior).
57
- */
58
- readProjectFile?: (path: string) => Promise<string>;
59
- }
60
-
61
- /**
62
- * Capture the undo-history `before` baseline for timing/GSAP persists: the exact
63
- * on-disk bytes when a reader is available (so undo restores them verbatim),
64
- * falling back to the SDK's pre-edit serialization when it isn't. Never throws —
65
- * a failed read degrades to the serialized fallback rather than aborting the edit.
66
- */
67
- async function captureOnDiskBefore(
68
- deps: CutoverDeps,
69
- targetPath: string,
70
- serializedFallback: string,
71
- ): Promise<string> {
72
- if (!deps.readProjectFile) return serializedFallback;
73
- try {
74
- return await deps.readProjectFile(targetPath);
75
- } catch {
76
- return serializedFallback;
77
- }
78
- }
19
+ export {
20
+ cutoverCommittedOrThrow,
21
+ persistSdkCandidateMutation,
22
+ persistSdkSerialize,
23
+ } from "./sdkEditTransaction";
24
+ export type {
25
+ CutoverDeps,
26
+ CutoverOptions,
27
+ CutoverResult,
28
+ PublishSdkSession,
29
+ } from "./sdkEditTransaction";
79
30
 
80
31
  /** True when targetPath isn't the composition the SDK session models. */
81
32
  function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
82
33
  return deps.compositionPath != null && targetPath !== deps.compositionPath;
83
34
  }
84
35
 
85
- interface CutoverOptions {
86
- label?: string;
87
- coalesceKey?: string;
88
- /** Coalesce window (ms); Infinity folds across a slow round-trip. */
89
- coalesceMs?: number;
90
- /** Skip the preview reload (mirrors the server path's skipRefresh). */
91
- skipRefresh?: boolean;
92
- }
93
-
94
- // ponytail: exported for setSlideshowManifest (third caller — island write bypasses
95
- // the SDK dispatch path since <script> nodes are not in the element tree).
96
- // `after` is serialized once by the caller (which also did the no-op check
97
- // against its pre-dispatch snapshot), so this never re-serializes.
98
- export async function persistSdkSerialize(
99
- after: string,
100
- targetPath: string,
101
- originalContent: string,
36
+ /**
37
+ * Reader for the animation-resolver tripwire's disk-truth check: on an
38
+ * animationId miss it re-parses the CURRENT file to distinguish a stale
39
+ * session (panel ids re-derive from disk every render; session ids date from
40
+ * the last reload) from a genuine resolver divergence.
41
+ */
42
+ function gsapReadSource(
102
43
  deps: CutoverDeps,
103
- options?: CutoverOptions,
104
- ): Promise<void> {
105
- deps.domEditSaveTimestampRef.current = Date.now();
106
- // Tag this write with the exact content (by hash) so the file-change
107
- // reload-suppression can recognize its own echo by IDENTITY, not just a 2 s
108
- // clock — an undo write (different bytes, not registered here) then always
109
- // reloads instead of being swallowed by the time window.
110
- markSelfWrite(targetPath, after);
111
- await deps.writeProjectFile(targetPath, after);
112
- await deps.editHistory.recordEdit({
113
- label: options?.label ?? "Edit layer",
114
- kind: "manual",
115
- ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
116
- ...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
117
- files: { [targetPath]: { before: originalContent, after } },
118
- });
119
- if (deps.refresh) deps.refresh(after);
120
- else if (!options?.skipRefresh) deps.reloadPreview();
44
+ targetPath: string,
45
+ ): (() => Promise<string | undefined>) | undefined {
46
+ const read = deps.readProjectFile;
47
+ return read ? () => read(targetPath) : undefined;
121
48
  }
122
-
123
49
  export async function sdkCutoverPersist(
124
50
  selection: DomEditSelection,
125
51
  ops: PatchOperation[],
@@ -128,35 +54,33 @@ export async function sdkCutoverPersist(
128
54
  sdkSession: Composition | null | undefined,
129
55
  deps: CutoverDeps,
130
56
  options?: CutoverOptions,
131
- ): Promise<boolean> {
57
+ ): Promise<CutoverResult> {
132
58
  if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops))
133
- return false;
134
- if (!sdkSession) return false;
59
+ return declinedCutover("ineligible_operation");
60
+ if (!sdkSession) return declinedCutover("session_unavailable");
135
61
  const hfId = selection.hfId;
136
- if (!hfId) return false;
62
+ if (!hfId) return declinedCutover("target_unaddressable");
137
63
  const target = sdkSession.getElement(hfId);
138
- if (!target) return false;
139
- if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
140
- if (wrongCompositionFile(deps, targetPath)) return false;
141
- try {
142
- const before = sdkSession.serialize();
143
- sdkSession.batch(() => {
144
- for (const editOp of patchOpsToSdkEditOps(hfId, ops)) {
145
- sdkSession.dispatch(editOp);
146
- }
147
- });
148
- const after = sdkSession.serialize();
149
- if (after === before) return false;
150
- await persistSdkSerialize(after, targetPath, originalContent, deps, options);
64
+ if (!target) return declinedCutover("target_not_found");
65
+ if (shouldDeclineTextCutoverForTarget(target, ops))
66
+ return declinedCutover("unsupported_text_target");
67
+ if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
68
+ const result = await persistSdkCandidateMutation(
69
+ sdkSession,
70
+ targetPath,
71
+ originalContent,
72
+ deps,
73
+ (session) => {
74
+ for (const editOp of patchOpsToSdkEditOps(hfId, ops)) session.dispatch(editOp);
75
+ },
76
+ options,
77
+ );
78
+ if (result.status === "committed") {
151
79
  trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length });
152
- return true;
153
- } catch (err) {
154
- trackStudioEvent("sdk_cutover_fallback", {
155
- hfId: selection.hfId ?? null,
156
- error: String(err),
157
- });
158
- return false;
80
+ } else if (result.status === "failed") {
81
+ trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
159
82
  }
83
+ return result;
160
84
  }
161
85
 
162
86
  export async function sdkTimingPersist(
@@ -166,7 +90,7 @@ export async function sdkTimingPersist(
166
90
  sdkSession: Composition | null | undefined,
167
91
  deps: CutoverDeps,
168
92
  options?: CutoverOptions,
169
- ): Promise<boolean> {
93
+ ): Promise<CutoverResult> {
170
94
  // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
171
95
  // the SDK can't resolve a target the server timing path is addressing.
172
96
  const timingSrc = deps.readProjectFile;
@@ -175,28 +99,36 @@ export async function sdkTimingPersist(
175
99
  hfId,
176
100
  "setTiming",
177
101
  timingSrc ? () => timingSrc(targetPath) : undefined,
102
+ { targetPath, compositionPath: deps.compositionPath },
178
103
  );
179
104
  // Dark-launch gate: without this, timing cutover runs whenever an SDK session
180
105
  // exists (it always does, for shadow/selection) — flipping the flag OFF would
181
106
  // NOT disable it. Gate here so flag-off routes back to the legacy server path.
182
- if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
183
- if (!sdkSession || !sdkSession.getElement(hfId)) return false;
184
- if (wrongCompositionFile(deps, targetPath)) return false;
107
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
108
+ if (!sdkSession) return declinedCutover("session_unavailable");
109
+ if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
110
+ if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
185
111
  try {
186
112
  const serializedBefore = sdkSession.serialize();
187
- sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate));
188
- const after = sdkSession.serialize();
189
- if (after === serializedBefore) return false;
190
- // Undo baseline = exact on-disk bytes (matching the style/delete paths), so
191
- // undoing a timing edit restores the file verbatim instead of a normalized
192
- // full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
193
- const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
194
- await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
195
- trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
196
- return true;
197
- } catch (err) {
198
- trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
199
- return false;
113
+ const result = await persistSdkCandidateMutation(
114
+ sdkSession,
115
+ targetPath,
116
+ serializedBefore,
117
+ deps,
118
+ (session) => session.setTiming(hfId, timingUpdate),
119
+ options,
120
+ serializedBefore,
121
+ );
122
+ if (result.status === "committed") {
123
+ trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
124
+ } else if (result.status === "failed") {
125
+ trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
126
+ }
127
+ return result;
128
+ } catch (error) {
129
+ const failed = { status: "failed", error: asCutoverError(error) } as const;
130
+ trackStudioEvent("sdk_cutover_failed", { hfId, error: failed.error.message });
131
+ return failed;
200
132
  }
201
133
  }
202
134
 
@@ -209,7 +141,7 @@ export async function sdkTimingBatchPersist(
209
141
  sdkSession: Composition | null | undefined,
210
142
  deps: CutoverDeps,
211
143
  options?: CutoverOptions,
212
- ): Promise<boolean> {
144
+ ): Promise<CutoverResult> {
213
145
  const timingSrc = deps.readProjectFile;
214
146
  for (const change of changes) {
215
147
  void recordResolverParity(
@@ -217,31 +149,46 @@ export async function sdkTimingBatchPersist(
217
149
  change.hfId,
218
150
  "setTiming",
219
151
  timingSrc ? () => timingSrc(targetPath) : undefined,
152
+ { targetPath, compositionPath: deps.compositionPath },
220
153
  );
221
154
  }
222
- if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
223
- if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
224
- if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
155
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
156
+ if (!sdkSession) return declinedCutover("session_unavailable");
157
+ if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
158
+ if (changes.some((change) => !sdkSession.getElement(change.hfId)))
159
+ return declinedCutover("target_not_found");
225
160
  try {
226
161
  const serializedBefore = sdkSession.serialize();
227
- sdkSession.batch(() => {
228
- for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
229
- });
230
- const after = sdkSession.serialize();
231
- if (after === serializedBefore) return false;
232
- const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
233
- await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
162
+ const result = await persistSdkCandidateMutation(
163
+ sdkSession,
164
+ targetPath,
165
+ serializedBefore,
166
+ deps,
167
+ (session) => {
168
+ for (const change of changes) session.setTiming(change.hfId, change.timingUpdate);
169
+ },
170
+ options,
171
+ serializedBefore,
172
+ );
173
+ if (result.status === "failed") {
174
+ trackStudioEvent("sdk_cutover_failed", {
175
+ hfId: changes[0]?.hfId ?? null,
176
+ error: result.error.message,
177
+ });
178
+ return result;
179
+ }
234
180
  trackStudioEvent("sdk_cutover_success", {
235
181
  hfId: changes[0]?.hfId ?? null,
236
182
  opCount: changes.length,
237
183
  });
238
- return true;
239
- } catch (err) {
240
- trackStudioEvent("sdk_cutover_fallback", {
184
+ return result;
185
+ } catch (error) {
186
+ const failed = { status: "failed", error: asCutoverError(error) } as const;
187
+ trackStudioEvent("sdk_cutover_failed", {
241
188
  hfId: changes[0]?.hfId ?? null,
242
- error: String(err),
189
+ error: failed.error.message,
243
190
  });
244
- return false;
191
+ return failed;
245
192
  }
246
193
  }
247
194
 
@@ -256,7 +203,7 @@ export function sdkGsapTweenPersist(
256
203
  sdkSession: Composition | null | undefined,
257
204
  deps: CutoverDeps,
258
205
  options?: CutoverOptions,
259
- ): Promise<boolean> {
206
+ ): Promise<CutoverResult> {
260
207
  // Resolver tripwire — runs BEFORE this function's own cutover gate (decoupled).
261
208
  // add targets an element (element-resolution parity); set/remove target an
262
209
  // animationId (animation-resolution parity). Done here, not via
@@ -269,20 +216,23 @@ export function sdkGsapTweenPersist(
269
216
  op.target,
270
217
  "addGsapTween",
271
218
  gsapSrc ? () => gsapSrc(targetPath) : undefined,
219
+ { targetPath, compositionPath: deps.compositionPath },
272
220
  );
273
221
  } else {
274
- recordAnimationResolverParity(
222
+ void recordAnimationResolverParity(
275
223
  sdkSession,
276
224
  op.animationId,
277
225
  op.kind === "set" ? "setGsapTween" : "removeGsapTween",
226
+ gsapReadSource(deps, targetPath),
227
+ { targetPath, compositionPath: deps.compositionPath },
278
228
  );
279
229
  }
280
230
  // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
281
231
  // matches the other three chokepoints' discipline.
282
- if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);
232
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(declinedCutover("feature_disabled"));
283
233
  if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
284
- return Promise.resolve(false);
285
- // dispatchGsapOpAndPersist returns false on before===after — that catches stale
234
+ return Promise.resolve(declinedCutover("target_not_found"));
235
+ // dispatchGsapOpAndPersist declines on before===after — that catches stale
286
236
  // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
287
237
  // back to the server path. This subsumes explicit existence guards for set/remove.
288
238
  return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => {
@@ -305,41 +255,48 @@ async function dispatchGsapOpAndPersist(
305
255
  options: CutoverOptions | undefined,
306
256
  dispatch: (s: Composition) => void,
307
257
  resolverTarget?: { animationId: string; opLabel: string },
308
- ): Promise<boolean> {
258
+ ): Promise<CutoverResult> {
309
259
  // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
310
260
  // the SDK can't resolve the animationId the server GSAP path is addressing.
311
261
  if (resolverTarget) {
312
- recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel);
262
+ void recordAnimationResolverParity(
263
+ sdkSession,
264
+ resolverTarget.animationId,
265
+ resolverTarget.opLabel,
266
+ gsapReadSource(deps, targetPath),
267
+ { targetPath, compositionPath: deps.compositionPath },
268
+ );
313
269
  }
314
270
  // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
315
- // flag OFF → return false → caller falls back to the legacy server path.
316
- if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
317
- if (!sdkSession) return false;
318
- if (wrongCompositionFile(deps, targetPath)) return false;
271
+ // flag OFF → explicit decline → caller falls back to the legacy server path.
272
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
273
+ if (!sdkSession) return declinedCutover("session_unavailable");
274
+ if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
319
275
  const session = sdkSession;
320
- // Route the whole read-serialize → dispatch → serialize → write through the
321
- // per-file serializer (when provided) so overlapping same-file flushes can't
322
- // interleave their read-modify-write and drop an edit, matching the legacy
323
- // commitMutation path's `gsap-file:${file}` serialization.
324
- const run = async (): Promise<boolean> => {
325
- try {
326
- const serializedBefore = session.serialize();
327
- dispatch(session);
328
- const after = session.serialize();
329
- if (after === serializedBefore) return false;
330
- // Undo baseline = exact on-disk bytes (matching the style/delete paths), so
331
- // undoing a GSAP edit restores the file verbatim instead of a normalized
332
- // full-DOM re-emit. Falls back to serializedBefore when no reader is wired.
333
- const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
334
- await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
276
+ // persistSdkCandidateMutation owns the shared per-project/file transaction
277
+ // coordinator used by both SDK and legacy GSAP writes.
278
+ try {
279
+ const serializedBefore = session.serialize();
280
+ const result = await persistSdkCandidateMutation(
281
+ session,
282
+ targetPath,
283
+ serializedBefore,
284
+ deps,
285
+ dispatch,
286
+ options,
287
+ serializedBefore,
288
+ );
289
+ if (result.status === "committed") {
335
290
  trackStudioEvent("sdk_cutover_success", { opCount: 1 });
336
- return true;
337
- } catch (err) {
338
- trackStudioEvent("sdk_cutover_fallback", { error: String(err) });
339
- return false;
291
+ } else if (result.status === "failed") {
292
+ trackStudioEvent("sdk_cutover_failed", { error: result.error.message });
340
293
  }
341
- };
342
- return deps.serialize ? deps.serialize(`gsap-file:${targetPath}`, run) : run();
294
+ return result;
295
+ } catch (error) {
296
+ const failed = { status: "failed", error: asCutoverError(error) } as const;
297
+ trackStudioEvent("sdk_cutover_failed", { error: failed.error.message });
298
+ return failed;
299
+ }
343
300
  }
344
301
 
345
302
  export function sdkGsapKeyframePersist(
@@ -350,7 +307,7 @@ export function sdkGsapKeyframePersist(
350
307
  sdkSession: Composition | null | undefined,
351
308
  deps: CutoverDeps,
352
309
  options?: CutoverOptions,
353
- ): Promise<boolean> {
310
+ ): Promise<CutoverResult> {
354
311
  return dispatchGsapOpAndPersist(
355
312
  targetPath,
356
313
  sdkSession,
@@ -368,7 +325,7 @@ export function sdkGsapRemoveKeyframePersist(
368
325
  sdkSession: Composition | null | undefined,
369
326
  deps: CutoverDeps,
370
327
  options?: CutoverOptions,
371
- ): Promise<boolean> {
328
+ ): Promise<CutoverResult> {
372
329
  return dispatchGsapOpAndPersist(
373
330
  targetPath,
374
331
  sdkSession,
@@ -387,7 +344,7 @@ export function sdkGsapRemovePropertyPersist(
387
344
  sdkSession: Composition | null | undefined,
388
345
  deps: CutoverDeps,
389
346
  options?: CutoverOptions,
390
- ): Promise<boolean> {
347
+ ): Promise<CutoverResult> {
391
348
  return dispatchGsapOpAndPersist(
392
349
  targetPath,
393
350
  sdkSession,
@@ -404,7 +361,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
404
361
  sdkSession: Composition | null | undefined,
405
362
  deps: CutoverDeps,
406
363
  options?: CutoverOptions,
407
- ): Promise<boolean> {
364
+ ): Promise<CutoverResult> {
408
365
  return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
409
366
  s.dispatch({ type: "deleteAllForSelector", selector }),
410
367
  );
@@ -416,7 +373,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
416
373
  sdkSession: Composition | null | undefined,
417
374
  deps: CutoverDeps,
418
375
  options?: CutoverOptions,
419
- ): Promise<boolean> {
376
+ ): Promise<CutoverResult> {
420
377
  return dispatchGsapOpAndPersist(
421
378
  targetPath,
422
379
  sdkSession,
@@ -434,7 +391,7 @@ export function sdkGsapConvertToKeyframesPersist(
434
391
  sdkSession: Composition | null | undefined,
435
392
  deps: CutoverDeps,
436
393
  options?: CutoverOptions,
437
- ): Promise<boolean> {
394
+ ): Promise<CutoverResult> {
438
395
  return dispatchGsapOpAndPersist(
439
396
  targetPath,
440
397
  sdkSession,
@@ -483,7 +440,7 @@ export function sdkAddWithKeyframesPersist(
483
440
  sdkSession: Composition | null | undefined,
484
441
  deps: CutoverDeps,
485
442
  options?: CutoverOptions,
486
- ): Promise<boolean> {
443
+ ): Promise<CutoverResult> {
487
444
  const payload: KeyframesPayload = {
488
445
  targetSelector,
489
446
  position,
@@ -507,7 +464,7 @@ export function sdkReplaceWithKeyframesPersist(
507
464
  sdkSession: Composition | null | undefined,
508
465
  deps: CutoverDeps,
509
466
  options?: CutoverOptions,
510
- ): Promise<boolean> {
467
+ ): Promise<CutoverResult> {
511
468
  const payload: KeyframesPayload = {
512
469
  targetSelector,
513
470
  position,
@@ -531,27 +488,32 @@ export async function sdkDeletePersist(
531
488
  targetPath: string,
532
489
  sdkSession: Composition | null | undefined,
533
490
  deps: CutoverDeps,
534
- ): Promise<boolean> {
491
+ ): Promise<CutoverResult> {
535
492
  // Resolver tripwire — runs BEFORE the cutover gate (decoupled).
536
- void recordResolverParity(sdkSession, hfId, "removeElement", () =>
537
- Promise.resolve(originalContent),
493
+ void recordResolverParity(
494
+ sdkSession,
495
+ hfId,
496
+ "removeElement",
497
+ () => Promise.resolve(originalContent),
498
+ { targetPath, compositionPath: deps.compositionPath },
538
499
  );
539
500
  // Dark-launch gate: flag OFF → legacy server delete path.
540
- if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
541
- if (!sdkSession || !sdkSession.getElement(hfId)) return false;
542
- if (wrongCompositionFile(deps, targetPath)) return false;
543
- try {
544
- const before = sdkSession.serialize();
545
- sdkSession.batch(() => sdkSession.removeElement(hfId));
546
- const after = sdkSession.serialize();
547
- if (after === before) return false;
548
- await persistSdkSerialize(after, targetPath, originalContent, deps, {
549
- label: "Delete element",
550
- });
501
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled");
502
+ if (!sdkSession) return declinedCutover("session_unavailable");
503
+ if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
504
+ if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
505
+ const result = await persistSdkCandidateMutation(
506
+ sdkSession,
507
+ targetPath,
508
+ originalContent,
509
+ deps,
510
+ (session) => session.removeElement(hfId),
511
+ { label: "Delete element" },
512
+ );
513
+ if (result.status === "committed") {
551
514
  trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 });
552
- return true;
553
- } catch (err) {
554
- trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) });
555
- return false;
515
+ } else if (result.status === "failed") {
516
+ trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message });
556
517
  }
518
+ return result;
557
519
  }