@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
83
83
  expect(captured.handle?.blocked).toBeNull();
84
84
  });
85
85
 
86
+ it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
87
+ const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
88
+ vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
89
+ const { options } = await mountCoordinator();
90
+ on.mockClear();
91
+ const root = createRoot(document.createElement("div"));
92
+ roots.push(root);
93
+ function Probe({ onAccepted }: { onAccepted: () => void }) {
94
+ useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
95
+ return null;
96
+ }
97
+ const latest = vi.fn();
98
+ await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
99
+ await act(async () => root.render(<Probe onAccepted={latest} />));
100
+ await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
101
+ expect(on).toHaveBeenCalledOnce();
102
+ expect(latest).toHaveBeenCalledOnce();
103
+ });
104
+
86
105
  // The file tree (useFileTree) is only ever refreshed from Studio's own file
87
106
  // operations (create/delete/rename/upload) — never on an external change.
88
107
  // Without this call, an agent removing or replacing a composition updates
@@ -143,6 +162,85 @@ describe("external file change coordinator", () => {
143
162
  expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
144
163
  });
145
164
 
165
+ it("keeps a waiting head edit's thumbnail refresh when a later write replaces it", async () => {
166
+ let release = () => {};
167
+ const inFlight = new Promise<void>((resolve) => (release = resolve));
168
+ const onAcceptedPersistedFileChange = vi.fn();
169
+ let drains = 0;
170
+ await mountCoordinator({
171
+ onAcceptedPersistedFileChange,
172
+ drainPendingChanges: async () => {
173
+ if (drains++ === 0) await inFlight;
174
+ return { status: "clean" as const };
175
+ },
176
+ });
177
+ await act(async () => handler?.({ path: "other.html", content: "a", version: "v1" }));
178
+ await act(async () => handler?.({ path: "index.html", content: "b", version: "v2" }));
179
+ await act(async () =>
180
+ handler?.({
181
+ path: "index.html",
182
+ content: "c",
183
+ version: "v3",
184
+ affectedCompositions: ["index.html"],
185
+ }),
186
+ );
187
+ await act(async () => release());
188
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
189
+ });
190
+
191
+ it("keeps a head edit's thumbnail refresh through a conflict that a later write replaces", async () => {
192
+ const onAcceptedPersistedFileChange = vi.fn();
193
+ const conflict = new StudioFileConflictError({
194
+ filePath: "index.html",
195
+ currentVersion: "v1",
196
+ currentContent: "theirs",
197
+ attemptedContent: "mine",
198
+ });
199
+ const { captured } = await mountCoordinator({
200
+ onAcceptedPersistedFileChange,
201
+ drainPendingChanges: async () => ({ status: "conflict" as const, error: conflict }),
202
+ });
203
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
204
+ await act(async () =>
205
+ handler?.({
206
+ path: "index.html",
207
+ content: "body",
208
+ version: "v2",
209
+ affectedCompositions: ["index.html"],
210
+ }),
211
+ );
212
+ await act(async () => captured.handle?.useExternalFile());
213
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
214
+ });
215
+
216
+ it("keeps a held head edit's thumbnail refresh when a later write saves cleanly", async () => {
217
+ const onAcceptedPersistedFileChange = vi.fn();
218
+ const conflict = new StudioFileConflictError({
219
+ filePath: "index.html",
220
+ currentVersion: "v1",
221
+ currentContent: "theirs",
222
+ attemptedContent: "mine",
223
+ });
224
+ let drains = 0;
225
+ await mountCoordinator({
226
+ onAcceptedPersistedFileChange,
227
+ drainPendingChanges: async () =>
228
+ drains++ === 0
229
+ ? { status: "conflict" as const, error: conflict }
230
+ : { status: "clean" as const },
231
+ });
232
+ await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
233
+ await act(async () =>
234
+ handler?.({
235
+ path: "index.html",
236
+ content: "body",
237
+ version: "v2",
238
+ affectedCompositions: ["index.html"],
239
+ }),
240
+ );
241
+ expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
242
+ });
243
+
146
244
  it("does not refresh the tree for a suppressed self-write echo", async () => {
147
245
  const refreshFileTree = vi.fn();
148
246
  await mountCoordinator({ refreshFileTree });
@@ -333,6 +431,29 @@ describe("external file change coordinator", () => {
333
431
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
334
432
  });
335
433
 
434
+ it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
435
+ const { captured } = await mountCoordinator({
436
+ drainPendingChanges: vi
437
+ .fn()
438
+ .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
439
+ .mockResolvedValueOnce({ status: "clean" as const }),
440
+ getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
441
+ deleteConflictSnapshot: vi.fn(async () => {
442
+ throw new Error("delete failed");
443
+ }),
444
+ });
445
+ await act(async () =>
446
+ handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
447
+ );
448
+ await act(async () =>
449
+ handler?.({ path: "index.html", content: "index-external", version: "i1" }),
450
+ );
451
+ expect(captured.handle?.blocked).toMatchObject({
452
+ status: "failed",
453
+ payload: { path: "scene.html", content: "scene-external", version: "s1" },
454
+ });
455
+ });
456
+
336
457
  it("restores and overwrites from a durable failed draft", async () => {
337
458
  const overwriteConflict = vi.fn(async () => undefined);
338
459
  const onAcceptedPersistedFileChange = vi.fn();
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
2
  import {
3
+ mergeFileChangeAffectedCompositions,
3
4
  readFileChangeAffectedCompositions,
4
5
  readFileChangeAffectsPreview,
5
6
  readFileChangeField,
@@ -163,7 +164,7 @@ export function useExternalFileChangeCoordinator({
163
164
  onAcceptedPersistedFileChange,
164
165
  refreshFileTree,
165
166
  }: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
166
- const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
167
+ const [blocked, setBlockedState] = useState<ExternalFileChangeBlockedState | null>(null);
167
168
  const generationRef = useRef(0);
168
169
  const mountedRef = useRef(true);
169
170
  const lastEventIdentityRef = useRef<string | null>(null);
@@ -172,6 +173,14 @@ export function useExternalFileChangeCoordinator({
172
173
  const drainingRef = useRef(false);
173
174
  const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
174
175
  blockedRef.current = blocked;
176
+ // A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
177
+ const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
178
+ setBlockedState((held) =>
179
+ next && held
180
+ ? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
181
+ : next,
182
+ );
183
+ }, []);
175
184
 
176
185
  useEffect(() => {
177
186
  mountedRef.current = true;
@@ -185,7 +194,7 @@ export function useExternalFileChangeCoordinator({
185
194
  generationRef.current += 1;
186
195
  setBlocked(null);
187
196
  lastEventIdentityRef.current = null;
188
- }, [projectId, activeCompPath]);
197
+ }, [projectId, activeCompPath, setBlocked]);
189
198
 
190
199
  useEffect(() => {
191
200
  if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
@@ -229,7 +238,7 @@ export function useExternalFileChangeCoordinator({
229
238
  return () => {
230
239
  cancelled = true;
231
240
  };
232
- }, [loadConflictSnapshot, projectId, recoveryFilePath]);
241
+ }, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
233
242
 
234
243
  const reloadAcceptedGeneration = useCallback(
235
244
  (path: string, affectsPreview = true) => {
@@ -275,14 +284,22 @@ export function useExternalFileChangeCoordinator({
275
284
  await deleteConflictSnapshot(projectId!, path);
276
285
  } catch (error) {
277
286
  if (mountedRef.current && generation === generationRef.current) {
278
- setBlocked({ ...previousBlocked, generation, error });
287
+ setBlocked({
288
+ ...previousBlocked,
289
+ generation,
290
+ error,
291
+ payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
292
+ });
279
293
  }
280
294
  return;
281
295
  }
282
296
  }
283
297
  if (!mountedRef.current || generation !== generationRef.current) return;
284
298
  setBlocked(null);
285
- onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
299
+ const owed = previousBlocked
300
+ ? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
301
+ : payload;
302
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
286
303
  reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
287
304
  return;
288
305
  }
@@ -344,6 +361,7 @@ export function useExternalFileChangeCoordinator({
344
361
  },
345
362
  [
346
363
  drainPendingChanges,
364
+ setBlocked,
347
365
  projectId,
348
366
  deleteConflictSnapshot,
349
367
  getPendingCandidate,
@@ -419,15 +437,22 @@ export function useExternalFileChangeCoordinator({
419
437
  waiting != null &&
420
438
  readFileChangeAffectsPreview(waiting) &&
421
439
  !readFileChangeAffectsPreview(payload);
422
- if (!waitingChangeOutranksThis) pendingPayloadRef.current = { payload };
440
+ if (!waitingChangeOutranksThis) {
441
+ pendingPayloadRef.current = {
442
+ payload:
443
+ waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
444
+ };
445
+ }
423
446
  void startDrainLoop();
424
447
  },
425
448
  [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
426
449
  );
427
450
 
451
+ const processChangeRef = useRef(processChange);
452
+ processChangeRef.current = processChange;
428
453
  useEffect(() => {
429
454
  // One decoder for all three transports; the rungs only choose the channel.
430
- const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
455
+ const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
431
456
  const adapter = testHotAdapter();
432
457
  if (adapter) {
433
458
  adapter.on("hf:file-change", handler);
@@ -438,7 +463,7 @@ export function useExternalFileChangeCoordinator({
438
463
  return () => import.meta.hot?.off?.("hf:file-change", handler);
439
464
  }
440
465
  return sseFileChangeChannel(handler);
441
- }, [processChange]);
466
+ }, []);
442
467
 
443
468
  const retry = useCallback(async () => {
444
469
  const current = blockedRef.current;
@@ -469,6 +494,7 @@ export function useExternalFileChangeCoordinator({
469
494
  },
470
495
  [
471
496
  deleteConflictSnapshot,
497
+ setBlocked,
472
498
  discardPendingChanges,
473
499
  onUseExternalFile,
474
500
  onAcceptedPersistedFileChange,
@@ -527,6 +553,7 @@ export function useExternalFileChangeCoordinator({
527
553
  reloadAcceptedGeneration(conflict.filePath);
528
554
  }, [
529
555
  deleteConflictSnapshot,
556
+ setBlocked,
530
557
  discardPendingChanges,
531
558
  overwriteConflict,
532
559
  projectId,
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
129
129
 
130
130
  expect(fetchMock).toHaveBeenCalledWith(
131
131
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
132
+ { credentials: "omit" },
132
133
  );
133
134
  expect(fetchMock).toHaveBeenCalledWith(
134
135
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
136
137
  );
137
138
  expect(fetchMock).toHaveBeenCalledWith(
138
139
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
140
+ { credentials: "omit" },
139
141
  );
140
142
  expect(fetchMock).toHaveBeenCalledWith(
141
143
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
142
144
  expect.objectContaining({ method: "PUT", body: "A_NEW" }),
143
145
  );
144
- expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
146
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
147
+ credentials: "omit",
148
+ });
145
149
  expect(fetchMock).toHaveBeenCalledWith(
146
150
  "/api/projects/project-b%23fragment/files/index.html?optional=1",
151
+ { credentials: "omit" },
147
152
  );
148
153
 
149
154
  await act(async () => root.unmount());
@@ -8,6 +8,7 @@ import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordina
8
8
  import { useFileTree } from "./useFileTree";
9
9
  import { useEditorSave } from "./useEditorSave";
10
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
11
+ import { studioApiFetch } from "../utils/studioApiFetch";
11
12
 
12
13
  // ── Types ──
13
14
 
@@ -133,7 +134,7 @@ export function useFileManager({
133
134
  setEditingFile({ path, content: null });
134
135
  return;
135
136
  }
136
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
+ studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`)
137
138
  .then((r) => {
138
139
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
139
140
  return r.json();
@@ -167,9 +168,12 @@ export function useFileManager({
167
168
  const requestId = ++revealRequestIdRef.current;
168
169
  const controller = new AbortController();
169
170
  revealAbortRef.current = controller;
170
- fetch(`/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`, {
171
- signal: controller.signal,
172
- })
171
+ studioApiFetch(
172
+ `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(sourceFile)}`,
173
+ {
174
+ signal: controller.signal,
175
+ },
176
+ )
173
177
  .then((r) => r.json())
174
178
  .then((data: { content?: string; version?: string }) => {
175
179
  if (requestId !== revealRequestIdRef.current) return;
@@ -200,7 +204,7 @@ export function useFileManager({
200
204
 
201
205
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
202
206
  try {
203
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
207
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/upload${qs}`, {
204
208
  method: "POST",
205
209
  body: formData,
206
210
  });
@@ -246,7 +250,7 @@ export function useFileManager({
246
250
  content =
247
251
  '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
248
252
  }
249
- const res = await fetch(
253
+ const res = await studioApiFetch(
250
254
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
251
255
  {
252
256
  method: "POST",
@@ -270,7 +274,7 @@ export function useFileManager({
270
274
  async (path: string) => {
271
275
  const pid = projectIdRef.current;
272
276
  if (!pid) return;
273
- const res = await fetch(
277
+ const res = await studioApiFetch(
274
278
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path + "/.gitkeep")}`,
275
279
  {
276
280
  method: "POST",
@@ -293,7 +297,7 @@ export function useFileManager({
293
297
  async (path: string) => {
294
298
  const pid = projectIdRef.current;
295
299
  if (!pid) return;
296
- const res = await fetch(
300
+ const res = await studioApiFetch(
297
301
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(path)}`,
298
302
  {
299
303
  method: "DELETE",
@@ -315,7 +319,7 @@ export function useFileManager({
315
319
  async (oldPath: string, newPath: string) => {
316
320
  const pid = projectIdRef.current;
317
321
  if (!pid) return;
318
- const res = await fetch(
322
+ const res = await studioApiFetch(
319
323
  `/api/projects/${encodeURIComponent(pid)}/files/${encodeURIComponent(oldPath)}`,
320
324
  {
321
325
  method: "PATCH",
@@ -342,7 +346,7 @@ export function useFileManager({
342
346
  async (path: string) => {
343
347
  const pid = projectIdRef.current;
344
348
  if (!pid) return;
345
- const res = await fetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
349
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(pid)}/duplicate-file`, {
346
350
  method: "POST",
347
351
  headers: { "Content-Type": "application/json" },
348
352
  body: JSON.stringify({ path }),
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface UseFileTreeOptions {
8
9
  projectId: string | null;
@@ -38,7 +39,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
38
39
  if (!projectId) return;
39
40
  let cancelled = false;
40
41
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
- fetch(buildProjectApiPath(projectId))
42
+ studioApiFetch(buildProjectApiPath(projectId))
42
43
  // An unresolvable project answers 404 with a JSON body, so without this
43
44
  // the error path parsed cleanly and the success branch below recorded an
44
45
  // empty tree — and an empty provenance snapshot — for a project that was
@@ -84,7 +85,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
84
85
  refreshAbortRef.current = controller;
85
86
  let data: { files?: string[]; compositions?: string[] };
86
87
  try {
87
- const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
88
+ const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal });
88
89
  data = await res.json();
89
90
  } catch (err) {
90
91
  if (err instanceof DOMException && err.name === "AbortError") return;
@@ -2,6 +2,7 @@ import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface UseFrameCaptureParams {
7
8
  projectId: string | null;
@@ -67,7 +68,10 @@ export function useFrameCapture({
67
68
  const controller = new AbortController();
68
69
  const timeout = setTimeout(() => controller.abort(), 30000);
69
70
  try {
70
- const response = await fetch(href, { cache: "no-store", signal: controller.signal });
71
+ const response = await studioApiFetch(href, {
72
+ cache: "no-store",
73
+ signal: controller.signal,
74
+ });
71
75
  clearTimeout(timeout);
72
76
  if (!response.ok) {
73
77
  let msg = `Capture failed (${response.status})`;
@@ -6,6 +6,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
6
6
  import { markStudioWriteToken } from "../utils/studioFileVersion";
7
7
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
12
 
@@ -31,7 +32,7 @@ function errorOf(value: unknown): string | null {
31
32
  }
32
33
 
33
34
  async function readVersion(projectId: string, path: string): Promise<string> {
34
- const response = await fetch(
35
+ const response = await studioApiFetch(
35
36
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
36
37
  );
37
38
  const body: unknown = await response.json().catch(() => null);
@@ -53,7 +54,7 @@ export async function requestFreezeFrame(input: {
53
54
  const expectedVersion = await readVersion(input.projectId, input.path);
54
55
  const transactionToken = `freeze:${crypto.randomUUID()}`;
55
56
  markStudioWriteToken(transactionToken);
56
- const response = await fetch(
57
+ const response = await studioApiFetch(
57
58
  buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
58
59
  {
59
60
  method: "POST",