@hyperframes/studio 0.7.55 → 0.7.56

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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40414 -37680
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,269 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../player";
6
+ import { usePlayerStore } from "../player";
7
+ import { useRazorSplit } from "./useRazorSplit";
8
+ import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
9
+ import { createEmptyEditHistory } from "../utils/editHistory";
10
+ import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
11
+ import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ const ROOT_FILE = "index.html";
16
+ const SUBCOMP_FILE = "scenes/intro.html";
17
+
18
+ // A root-level clip lives in index.html and is authored in local time already.
19
+ const rootElement: TimelineElement = {
20
+ id: "root-clip",
21
+ tag: "div",
22
+ start: 0,
23
+ duration: 10,
24
+ track: 0,
25
+ domId: "root-clip",
26
+ sourceFile: ROOT_FILE,
27
+ timingSource: "authored",
28
+ };
29
+
30
+ // An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
31
+ // host's master start), `sourceFile` is the sub-comp, and `expandedParentStart`
32
+ // is that host master start. Its authored time in the file is start - basis.
33
+ const expandedChild: TimelineElement = {
34
+ id: "child-clip",
35
+ tag: "div",
36
+ start: 2,
37
+ duration: 6,
38
+ track: 1,
39
+ domId: "child-clip",
40
+ sourceFile: SUBCOMP_FILE,
41
+ timingSource: "authored",
42
+ expandedParentStart: 2,
43
+ };
44
+
45
+ interface SplitRequest {
46
+ path: string;
47
+ splitTime: number;
48
+ elementStart: number;
49
+ elementDuration: number;
50
+ }
51
+
52
+ type SingleSplit = (element: TimelineElement, splitTime: number) => Promise<void>;
53
+ type SplitAll = (splitTime: number) => Promise<void>;
54
+
55
+ interface Harness {
56
+ splitRequests: SplitRequest[];
57
+ singleRef: { current: SingleSplit | undefined };
58
+ allRef: { current: SplitAll | undefined };
59
+ root: ReturnType<typeof mountProbe>;
60
+ }
61
+
62
+ function mountRazorSplit(): Harness {
63
+ const disk: Record<string, string> = {
64
+ [ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
65
+ [SUBCOMP_FILE]: `<div class="clip" id="child-clip" data-start="0" data-duration="6"></div>`,
66
+ };
67
+ const splitRequests: SplitRequest[] = [];
68
+
69
+ const fetchMock = createSplitFetchMock(disk, (path, body) => {
70
+ splitRequests.push({
71
+ path,
72
+ splitTime: body.splitTime,
73
+ elementStart: body.elementStart,
74
+ elementDuration: body.elementDuration,
75
+ });
76
+ });
77
+ vi.stubGlobal("fetch", fetchMock);
78
+
79
+ const singleRef: { current: SingleSplit | undefined } = { current: undefined };
80
+ const allRef: { current: SplitAll | undefined } = { current: undefined };
81
+
82
+ function Component() {
83
+ const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
84
+ projectId: "p1",
85
+ activeCompPath: ROOT_FILE,
86
+ showToast: () => {},
87
+ writeProjectFile: async (path, content) => {
88
+ disk[path] = content;
89
+ },
90
+ recordEdit: async () => {},
91
+ domEditSaveTimestampRef: { current: 0 },
92
+ reloadPreview: () => {},
93
+ });
94
+ singleRef.current = handleRazorSplit;
95
+ allRef.current = handleRazorSplitAll;
96
+ return null;
97
+ }
98
+
99
+ const root = mountProbe(Component);
100
+ return { splitRequests, singleRef, allRef, root };
101
+ }
102
+
103
+ afterEach(() => {
104
+ document.body.innerHTML = "";
105
+ usePlayerStore.setState({ elements: [] });
106
+ vi.unstubAllGlobals();
107
+ });
108
+
109
+ describe("useRazorSplit — sub-comp coordinate rebasing", () => {
110
+ let harness: Harness;
111
+ beforeEach(() => {
112
+ harness = mountRazorSplit();
113
+ });
114
+ afterEach(() => {
115
+ act(() => harness.root.unmount());
116
+ });
117
+
118
+ it("rebases an expanded sub-comp child split into the sub-comp's local time", async () => {
119
+ // Master split time T = 5; host starts at 2, so local time is 3.
120
+ await act(async () => {
121
+ await harness.singleRef.current!(expandedChild, 5);
122
+ });
123
+
124
+ expect(harness.splitRequests).toHaveLength(1);
125
+ const req = harness.splitRequests[0];
126
+ expect(req.path).toBe(SUBCOMP_FILE);
127
+ expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5
128
+ expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2
129
+ expect(req.elementDuration).toBe(6);
130
+ });
131
+
132
+ it("leaves a root-level clip's coordinates unchanged", async () => {
133
+ // Master split time T = 4; no expandedParentStart, so nothing is rebased.
134
+ await act(async () => {
135
+ await harness.singleRef.current!(rootElement, 4);
136
+ });
137
+
138
+ expect(harness.splitRequests).toHaveLength(1);
139
+ const req = harness.splitRequests[0];
140
+ expect(req.path).toBe(ROOT_FILE);
141
+ expect(req.splitTime).toBe(4);
142
+ expect(req.elementStart).toBe(0);
143
+ expect(req.elementDuration).toBe(10);
144
+ });
145
+
146
+ it("rebases each element individually in a mixed razor-split-all gesture", async () => {
147
+ usePlayerStore.setState({ elements: [rootElement, expandedChild] });
148
+
149
+ // Master split time T = 3 lies inside both clips.
150
+ await act(async () => {
151
+ await harness.allRef.current!(3);
152
+ });
153
+
154
+ expect(harness.splitRequests).toHaveLength(2);
155
+ const rootReq = harness.splitRequests.find((r) => r.path === ROOT_FILE)!;
156
+ const childReq = harness.splitRequests.find((r) => r.path === SUBCOMP_FILE)!;
157
+
158
+ // Root clip: already local — master coordinates pass through untouched.
159
+ expect(rootReq.splitTime).toBe(3);
160
+ expect(rootReq.elementStart).toBe(0);
161
+
162
+ // Expanded child: rebased by its OWN expandedParentStart, not the root's.
163
+ expect(childReq.splitTime).toBe(1); // 3 - 2
164
+ expect(childReq.elementStart).toBe(0); // 2 - 2
165
+ });
166
+ });
167
+
168
+ // ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
169
+
170
+ const memoryStorage = (): EditHistoryStorageAdapter => {
171
+ const store = new Map<string, string>();
172
+ return {
173
+ load: async (k) => store.get(k) ?? null,
174
+ save: async (k, v) => {
175
+ store.set(k, v);
176
+ },
177
+ } as unknown as EditHistoryStorageAdapter;
178
+ };
179
+
180
+ interface UndoHarness {
181
+ singleRef: { current: SingleSplit | undefined };
182
+ disk: Record<string, string>;
183
+ store: ReturnType<typeof createPersistentEditHistoryStore>;
184
+ forceReloadSdkSession: ReturnType<typeof vi.fn>;
185
+ root: ReturnType<typeof mountProbe>;
186
+ }
187
+
188
+ function mountRazorSplitWithHistory(): UndoHarness {
189
+ const disk: Record<string, string> = {
190
+ [ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
191
+ };
192
+ const store = createPersistentEditHistoryStore({
193
+ projectId: "p1",
194
+ storage: memoryStorage(),
195
+ initialState: createEmptyEditHistory(),
196
+ now: () => Date.now(),
197
+ onChange: () => {},
198
+ });
199
+ const forceReloadSdkSession = vi.fn();
200
+
201
+ const fetchMock = createSplitFetchMock(disk);
202
+ vi.stubGlobal("fetch", fetchMock);
203
+
204
+ const singleRef: { current: SingleSplit | undefined } = { current: undefined };
205
+ function Component() {
206
+ const { handleRazorSplit } = useRazorSplit({
207
+ projectId: "p1",
208
+ activeCompPath: ROOT_FILE,
209
+ showToast: () => {},
210
+ writeProjectFile: async (path, content) => {
211
+ disk[path] = content;
212
+ },
213
+ recordEdit: (input) => store.recordEdit(input),
214
+ domEditSaveTimestampRef: { current: 0 },
215
+ reloadPreview: () => {},
216
+ forceReloadSdkSession,
217
+ });
218
+ singleRef.current = handleRazorSplit;
219
+ return null;
220
+ }
221
+ const root = mountProbe(Component);
222
+ return { singleRef, disk, store, forceReloadSdkSession, root };
223
+ }
224
+
225
+ describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
226
+ let h: UndoHarness;
227
+ beforeEach(() => {
228
+ h = mountRazorSplitWithHistory();
229
+ });
230
+ afterEach(() => {
231
+ act(() => h.root.unmount());
232
+ });
233
+
234
+ const readFile = () => ({
235
+ readFile: async (p: string) => h.disk[p],
236
+ writeFile: async (p: string, c: string) => {
237
+ h.disk[p] = c;
238
+ },
239
+ });
240
+
241
+ it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
242
+ await act(async () => {
243
+ await h.singleRef.current!(rootElement, 4);
244
+ });
245
+ expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it("applies undo after a split without an external-change refusal", async () => {
249
+ await act(async () => {
250
+ await h.singleRef.current!(rootElement, 4);
251
+ });
252
+ const result = await h.store.undo(readFile());
253
+ expect(result.ok).toBe(true);
254
+ expect(result.reason).toBeUndefined();
255
+ // The file is restored to its pre-split bytes.
256
+ expect(h.disk[ROOT_FILE]).not.toContain("<!--split-->");
257
+ });
258
+
259
+ it("still trips the guard when the file is edited externally after a split", async () => {
260
+ await act(async () => {
261
+ await h.singleRef.current!(rootElement, 4);
262
+ });
263
+ // Simulate the user editing the file in their own editor after the split.
264
+ h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}<!--hand-edit-->`;
265
+ const result = await h.store.undo(readFile());
266
+ expect(result.ok).toBe(false);
267
+ expect(result.reason).toBe("content-mismatch");
268
+ });
269
+ });
@@ -0,0 +1,64 @@
1
+ import React, { act } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { vi } from "vitest";
4
+
5
+ export interface SplitBody {
6
+ splitTime: number;
7
+ elementStart: number;
8
+ elementDuration: number;
9
+ }
10
+
11
+ function decodePathFromUrl(url: string, marker: string): string {
12
+ const encoded = url.slice(url.indexOf(marker) + marker.length);
13
+ return decodeURIComponent(encoded);
14
+ }
15
+
16
+ /**
17
+ * Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
18
+ * split-element writes a `<!--split-->` marker so `changed` is true, and file reads
19
+ * echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
20
+ */
21
+ export function createSplitFetchMock(
22
+ disk: Record<string, string>,
23
+ onSplit?: (path: string, body: SplitBody) => void,
24
+ ) {
25
+ return vi.fn(async (url: string, init?: RequestInit) => {
26
+ const u = String(url);
27
+ if (u.includes("/gsap-mutations/")) {
28
+ // No GSAP script in the fixtures — mirror the server's 400 response.
29
+ return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
30
+ status: 400,
31
+ headers: { "Content-Type": "application/json" },
32
+ });
33
+ }
34
+ if (u.includes("/file-mutations/split-element/")) {
35
+ const path = decodePathFromUrl(u, "/file-mutations/split-element/");
36
+ onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
37
+ // Return content that differs from the original so `changed` is true.
38
+ const after = `${disk[path]}<!--split-->`;
39
+ disk[path] = after; // server writes the split to disk
40
+ return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
41
+ status: 200,
42
+ headers: { "Content-Type": "application/json" },
43
+ });
44
+ }
45
+ if (u.includes("/files/")) {
46
+ const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
47
+ return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
48
+ status: 200,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+ }
52
+ void init;
53
+ throw new Error(`unexpected fetch: ${u}`);
54
+ });
55
+ }
56
+
57
+ /** Mount a render-only probe component into a fresh host and return its root. */
58
+ export function mountProbe(Component: React.ComponentType): ReturnType<typeof createRoot> {
59
+ const host = document.createElement("div");
60
+ document.body.append(host);
61
+ const root = createRoot(host);
62
+ act(() => root.render(React.createElement(Component)));
63
+ return root;
64
+ }
@@ -20,6 +20,14 @@ interface UseRazorSplitOptions {
20
20
  recordEdit: (input: RecordEditInput) => Promise<void>;
21
21
  domEditSaveTimestampRef: React.MutableRefObject<number>;
22
22
  reloadPreview: () => void;
23
+ /**
24
+ * Resync the in-memory SDK session after the server-side split write (the
25
+ * split-element / split-gsap endpoints write the file directly, so the SDK's
26
+ * linkedom doc is now stale). This reload is read-only; the split endpoint owns
27
+ * the final on-disk bytes and history baseline. Every other server-side-write
28
+ * timeline path (move / resize / delete / drop / visibility) also resyncs.
29
+ */
30
+ forceReloadSdkSession?: () => void;
23
31
  isRecordingRef?: React.RefObject<boolean>;
24
32
  }
25
33
 
@@ -101,6 +109,62 @@ async function splitGsapAnimations(
101
109
  };
102
110
  }
103
111
 
112
+ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
113
+ const original = originals.get(path);
114
+ if (original === undefined) {
115
+ throw new Error(`Missing original contents for ${path}`);
116
+ }
117
+ return original;
118
+ }
119
+
120
+ async function restoreFilesToOriginal(
121
+ originals: ReadonlyMap<string, string>,
122
+ paths: Iterable<string>,
123
+ writeProjectFile: (path: string, content: string) => Promise<void>,
124
+ ): Promise<void> {
125
+ for (const path of paths) {
126
+ await writeProjectFile(path, getOriginalContent(originals, path));
127
+ }
128
+ }
129
+
130
+ async function readOriginalFiles(
131
+ pid: string,
132
+ elements: TimelineElement[],
133
+ activeCompPath: string | null,
134
+ ): Promise<Map<string, string>> {
135
+ const originals = new Map<string, string>();
136
+ for (const element of elements) {
137
+ const path = element.sourceFile || activeCompPath || "index.html";
138
+ if (!originals.has(path)) {
139
+ originals.set(path, await readFileContent(pid, path));
140
+ }
141
+ }
142
+ return originals;
143
+ }
144
+
145
+ async function splitElementsAtTime(
146
+ pid: string,
147
+ elements: TimelineElement[],
148
+ splitTime: number,
149
+ activeCompPath: string | null,
150
+ originals: ReadonlyMap<string, string>,
151
+ snapshots: Map<string, { before: string; after: string }>,
152
+ writeProjectFile: (path: string, content: string) => Promise<void>,
153
+ ): Promise<number> {
154
+ let count = 0;
155
+ for (const element of elements) {
156
+ const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
157
+ if (!result.changed) continue;
158
+ snapshots.set(result.targetPath, {
159
+ before: getOriginalContent(originals, result.targetPath),
160
+ after: result.patchedContent,
161
+ });
162
+ await writeProjectFile(result.targetPath, result.patchedContent);
163
+ count++;
164
+ }
165
+ return count;
166
+ }
167
+
104
168
  // fallow-ignore-next-line complexity
105
169
  async function executeSplit(
106
170
  pid: string,
@@ -122,13 +186,23 @@ async function executeSplit(
122
186
  const originalContent = await readFileContent(pid, targetPath);
123
187
  const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
124
188
 
189
+ // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
190
+ // `start` and the incoming `splitTime` are offset by the host's master start
191
+ // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
192
+ // authored in LOCAL time. Rebase both onto local time before the server patches
193
+ // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
194
+ // Root-level clips (no expandedParentStart) are already local, so pass through.
195
+ const basis = element.expandedParentStart;
196
+ const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
197
+ const localElementStart = basis === undefined ? element.start : element.start - basis;
198
+
125
199
  const splitResult = await splitHtmlElement(
126
200
  pid,
127
201
  targetPath,
128
202
  patchTarget,
129
- splitTime,
203
+ localSplitTime,
130
204
  newId,
131
- element.start,
205
+ localElementStart,
132
206
  element.duration,
133
207
  );
134
208
  if (!splitResult.ok) throw new Error("Failed to split clip.");
@@ -147,8 +221,8 @@ async function executeSplit(
147
221
  targetPath,
148
222
  element.domId,
149
223
  newId,
150
- splitTime,
151
- element.start,
224
+ localSplitTime,
225
+ localElementStart,
152
226
  element.duration,
153
227
  );
154
228
  if (gsapResult.content) patchedContent = gsapResult.content;
@@ -172,6 +246,7 @@ export function useRazorSplit({
172
246
  recordEdit,
173
247
  domEditSaveTimestampRef,
174
248
  reloadPreview,
249
+ forceReloadSdkSession,
175
250
  isRecordingRef,
176
251
  }: UseRazorSplitOptions) {
177
252
  const projectIdRef = useRef(projectId);
@@ -208,6 +283,9 @@ export function useRazorSplit({
208
283
  recordEdit,
209
284
  });
210
285
 
286
+ // Server writes bypass the SDK session, so reopen it before refreshing
287
+ // the preview. The split response already owns the final persisted bytes.
288
+ forceReloadSdkSession?.();
211
289
  reloadPreview();
212
290
  trackStudioRazorSplit({ mode: "single", count: 1 });
213
291
  showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
@@ -229,6 +307,7 @@ export function useRazorSplit({
229
307
  writeProjectFile,
230
308
  domEditSaveTimestampRef,
231
309
  reloadPreview,
310
+ forceReloadSdkSession,
232
311
  isRecordingRef,
233
312
  ],
234
313
  );
@@ -247,51 +326,42 @@ export function useRazorSplit({
247
326
  const splittable = selectSplittableElements(elements, splitTime);
248
327
  if (splittable.length === 0) return;
249
328
 
329
+ let originals = new Map<string, string>();
330
+ const finalSnapshots = new Map<string, { before: string; after: string }>();
250
331
  try {
251
- const originals = new Map<string, string>();
252
- for (const el of splittable) {
253
- const path = el.sourceFile || activeCompPath || "index.html";
254
- if (!originals.has(path)) {
255
- originals.set(path, await readFileContent(pid, path));
256
- }
257
- }
258
-
259
- let splitCount = 0;
260
- const finalContent = new Map<string, string>();
261
-
262
- for (const element of splittable) {
263
- const result = await executeSplit(
264
- pid,
265
- element,
266
- splitTime,
267
- activeCompPath,
268
- writeProjectFile,
269
- );
270
- if (result.changed) {
271
- finalContent.set(result.targetPath, result.patchedContent);
272
- await writeProjectFile(result.targetPath, result.patchedContent);
273
- splitCount++;
274
- }
275
- }
276
-
332
+ originals = await readOriginalFiles(pid, splittable, activeCompPath);
333
+ const splitCount = await splitElementsAtTime(
334
+ pid,
335
+ splittable,
336
+ splitTime,
337
+ activeCompPath,
338
+ originals,
339
+ finalSnapshots,
340
+ writeProjectFile,
341
+ );
277
342
  if (splitCount === 0) return;
278
343
 
279
344
  domEditSaveTimestampRef.current = Date.now();
280
345
  await recordEdit({
281
346
  label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
282
347
  kind: "timeline",
283
- files: Object.fromEntries(
284
- [...finalContent].map(([path, after]) => [
285
- path,
286
- { before: originals.get(path) ?? "", after },
287
- ]),
288
- ),
348
+ files: Object.fromEntries(finalSnapshots),
289
349
  });
290
350
 
351
+ // Resync the stale SDK doc after the batched server write (see the
352
+ // single-split path above for why this precedes the reload).
353
+ forceReloadSdkSession?.();
291
354
  reloadPreview();
292
355
  trackStudioRazorSplit({ mode: "all", count: splitCount });
293
356
  showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
294
357
  } catch (error) {
358
+ // Best-effort rollback — a failing restore write must not swallow the
359
+ // original error's toast, which is what tells the user the split failed.
360
+ try {
361
+ await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
362
+ } catch {
363
+ /* leave disk as-is; the original failure is reported below */
364
+ }
295
365
  const message = error instanceof Error ? error.message : "Failed to split clips";
296
366
  showToast(message, "error");
297
367
  }
@@ -303,6 +373,7 @@ export function useRazorSplit({
303
373
  writeProjectFile,
304
374
  domEditSaveTimestampRef,
305
375
  reloadPreview,
376
+ forceReloadSdkSession,
306
377
  isRecordingRef,
307
378
  ],
308
379
  );
@@ -3,6 +3,8 @@ import { createElement } from "react";
3
3
  import { CompositionThumbnail, VideoThumbnail } from "../player";
4
4
  import type { TimelineElement } from "../player";
5
5
  import { AudioWaveform } from "../player/components/AudioWaveform";
6
+ import { ImageThumbnail } from "../player/components/ImageThumbnail";
7
+ import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
6
8
 
7
9
  export function normalizeCompositionSrc(
8
10
  compSrc: string,
@@ -51,8 +53,16 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
51
53
  */
52
54
  function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
53
55
  const srcRelative = resolvePreviewRelative(el.src, pid);
54
- const audioUrl = srcRelative ? `/api/projects/${pid}/preview/${srcRelative}` : (el.src ?? "");
55
- const waveformUrl = srcRelative ? `/api/projects/${pid}/waveform/${srcRelative}` : undefined;
56
+ // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
57
+ // what the assets panel loads a raw segment 404s. resolvePreviewRelative
58
+ // returns the DECODED path, so it must be re-encoded here.
59
+ const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
60
+ const audioUrl = encodedRelative
61
+ ? `/api/projects/${pid}/preview/${encodedRelative}`
62
+ : (el.src ?? "");
63
+ const waveformUrl = encodedRelative
64
+ ? `/api/projects/${pid}/waveform/${encodedRelative}`
65
+ : undefined;
56
66
  const { start, end } = trimFractions(el);
57
67
  return createElement(AudioWaveform, {
58
68
  audioUrl,
@@ -100,7 +110,7 @@ export function useRenderClipContent({
100
110
  // instead of capturing the master at a time when the comp is fading in.
101
111
  if (compSrc) {
102
112
  return createElement(CompositionThumbnail, {
103
- previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
113
+ previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`,
104
114
  label: "",
105
115
  labelColor: style.label,
106
116
 
@@ -138,9 +148,17 @@ export function useRenderClipContent({
138
148
  !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
139
149
 
140
150
  if ((el.tag === "video" || el.tag === "img") && el.src) {
141
- const mediaSrc = el.src.startsWith("http")
142
- ? el.src
143
- : `/api/projects/${pid}/preview/${el.src}`;
151
+ const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
152
+ // Still images can't be decoded by VideoThumbnail's <video> extractor
153
+ // (the error event fires and the shimmer never resolves) — render the
154
+ // image itself as the strip.
155
+ if (el.tag === "img") {
156
+ return createElement(ImageThumbnail, {
157
+ imageSrc: mediaSrc,
158
+ label: "",
159
+ labelColor: style.label,
160
+ });
161
+ }
144
162
  return createElement(VideoThumbnail, {
145
163
  videoSrc: mediaSrc,
146
164
  label: "",
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
2
2
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
4
  import type { RightInspectorPanes } from "../utils/studioHelpers";
5
+ import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
6
+ import { usePlayerStore } from "../player";
5
7
 
6
8
  interface StudioContextInput {
7
9
  projectId: string;
@@ -34,8 +36,6 @@ interface StudioContextInput {
34
36
  waitForPendingDomEditSaves: () => Promise<void>;
35
37
  handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
36
38
  refreshPreviewDocumentVersion: () => void;
37
- timelineVisible: boolean;
38
- toggleTimelineVisibility: () => void;
39
39
  }
40
40
 
41
41
  // fallow-ignore-next-line complexity
@@ -61,8 +61,6 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
61
61
  waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
62
62
  handlePreviewIframeRef: input.handlePreviewIframeRef,
63
63
  refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
64
- timelineVisible: input.timelineVisible,
65
- toggleTimelineVisibility: input.toggleTimelineVisibility,
66
64
  };
67
65
  }
68
66
 
@@ -108,7 +106,7 @@ export function useInspectorState(
108
106
  }
109
107
 
110
108
  // fallow-ignore-next-line complexity
111
- export function useDragOverlay(onImportFiles: (files: FileList) => void) {
109
+ function useDragOverlay(onImportFiles: (files: FileList) => void) {
112
110
  const [active, setActive] = useState(false);
113
111
  const counterRef = useRef(0);
114
112
  const onDragOver = useCallback((e: DragEvent) => {
@@ -137,3 +135,15 @@ export function useDragOverlay(onImportFiles: (files: FileList) => void) {
137
135
  );
138
136
  return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
139
137
  }
138
+
139
+ /** Global OS file drop: imports and places at the playhead position. */
140
+ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
141
+ const onDrop = useCallback(
142
+ (files: FileList) => {
143
+ const start = usePlayerStore.getState().currentTime;
144
+ void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
145
+ },
146
+ [handleTimelineFileDrop],
147
+ );
148
+ return useDragOverlay(onDrop);
149
+ }
@@ -20,7 +20,6 @@ interface UseStudioUrlStateParams {
20
20
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
21
  rightPanelTab: RightPanelTab;
22
22
  rightCollapsed: boolean;
23
- timelineVisible: boolean;
24
23
  activeCompPathHydrated: boolean;
25
24
  domEditSelection: DomEditSelection | null;
26
25
  buildDomSelectionFromTarget: (
@@ -66,7 +65,6 @@ export function useStudioUrlState({
66
65
  previewIframeRef,
67
66
  rightPanelTab,
68
67
  rightCollapsed,
69
- timelineVisible,
70
68
  activeCompPathHydrated,
71
69
  domEditSelection,
72
70
  buildDomSelectionFromTarget,
@@ -91,12 +89,12 @@ export function useStudioUrlState({
91
89
  currentTime: stableTimeRef.current,
92
90
  rightPanelTab,
93
91
  rightCollapsed,
94
- timelineVisible,
92
+ timelineVisible: null,
95
93
  selection: hydratedSelectionRef.current
96
94
  ? toPersistedSelection(domEditSelection)
97
95
  : pendingSelectionRef.current,
98
96
  }),
99
- [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
97
+ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
100
98
  );
101
99
 
102
100
  // Resolve a URL selection to a live element and apply it. Shared by the initial