@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.
- package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
- package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
- package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
- package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
- package/dist/index.d.ts +146 -40
- package/dist/index.html +2 -2
- package/dist/index.js +4021 -3591
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +9 -8
- package/src/App.tsx +2 -2
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.ts +4 -3
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +41 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +5 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +4 -125
- package/src/hooks/gsapDragPositionCommit.ts +36 -384
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +96 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -168
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +13 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +8 -6
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
- package/src/hooks/useDomEditTextCommits.ts +13 -3
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +8 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +34 -3
- package/src/hooks/useEnableKeyframes.ts +7 -4
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +86 -55
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +101 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
- package/src/hooks/useGsapScriptCommits.ts +15 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +22 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/sceneSwap.ts +2 -1
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +51 -5
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +4 -3
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /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,
|
|
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({
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 }),
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
57
|
+
const response = await studioApiFetch(
|
|
57
58
|
buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
|
|
58
59
|
{
|
|
59
60
|
method: "POST",
|