@hyperframes/studio 0.7.54 → 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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.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 +40582 -37803
  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/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,122 @@
1
+ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
2
+ import type { TimelineTheme } from "./timelineTheme";
3
+ import type { TimelineRangeSelection } from "./timelineEditing";
4
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
5
+ import { EditPopover } from "./EditModal";
6
+ import {
7
+ KeyframeDiamondContextMenu,
8
+ type KeyframeDiamondContextMenuState,
9
+ } from "./KeyframeDiamondContextMenu";
10
+ import { ClipContextMenu } from "./ClipContextMenu";
11
+ import { TimelineShortcutHint } from "./TimelineShortcutHint";
12
+
13
+ interface ClipContextMenuState {
14
+ x: number;
15
+ y: number;
16
+ element: TimelineElement;
17
+ }
18
+
19
+ interface TimelineOverlaysProps {
20
+ theme: TimelineTheme;
21
+ showShortcutHint: boolean;
22
+ showPopover: boolean;
23
+ rangeSelection: TimelineRangeSelection | null;
24
+ setShowPopover: (value: boolean) => void;
25
+ setRangeSelection: (value: TimelineRangeSelection | null) => void;
26
+ kfContextMenu: KeyframeDiamondContextMenuState | null;
27
+ setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
28
+ onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
29
+ onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
30
+ onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
31
+ onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
32
+ keyframeCache: Map<string, KeyframeCacheEntry>;
33
+ clipContextMenu: ClipContextMenuState | null;
34
+ setClipContextMenu: (value: ClipContextMenuState | null) => void;
35
+ currentTime: number;
36
+ onSplitElement: TimelineEditCallbacks["onSplitElement"];
37
+ pinZoomBeforeEdit: () => void;
38
+ onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
39
+ }
40
+
41
+ // The timeline's floating overlays, rendered as siblings above the scroll area:
42
+ // the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
43
+ // and the clip context menu.
44
+ export function TimelineOverlays({
45
+ theme,
46
+ showShortcutHint,
47
+ showPopover,
48
+ rangeSelection,
49
+ setShowPopover,
50
+ setRangeSelection,
51
+ kfContextMenu,
52
+ setKfContextMenu,
53
+ onDeleteKeyframe,
54
+ onDeleteAllKeyframes,
55
+ onChangeKeyframeEase,
56
+ onMoveKeyframeToPlayhead,
57
+ keyframeCache,
58
+ clipContextMenu,
59
+ setClipContextMenu,
60
+ currentTime,
61
+ onSplitElement,
62
+ pinZoomBeforeEdit,
63
+ onDeleteElement,
64
+ }: TimelineOverlaysProps) {
65
+ return (
66
+ <>
67
+ {showShortcutHint && !showPopover && !rangeSelection && (
68
+ <TimelineShortcutHint theme={theme} />
69
+ )}
70
+
71
+ {showPopover && rangeSelection && (
72
+ <EditPopover
73
+ rangeStart={rangeSelection.start}
74
+ rangeEnd={rangeSelection.end}
75
+ anchorX={rangeSelection.anchorX}
76
+ anchorY={rangeSelection.anchorY}
77
+ onClose={() => {
78
+ setShowPopover(false);
79
+ setRangeSelection(null);
80
+ }}
81
+ />
82
+ )}
83
+
84
+ {kfContextMenu && (
85
+ <KeyframeDiamondContextMenu
86
+ state={kfContextMenu}
87
+ onClose={() => setKfContextMenu(null)}
88
+ onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
89
+ onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
90
+ onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
91
+ onMoveToPlayhead={
92
+ onMoveKeyframeToPlayhead
93
+ ? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
94
+ : undefined
95
+ }
96
+ onCopyProperties={(elId, pct) => {
97
+ const kfData = keyframeCache.get(elId);
98
+ const kf = kfData?.keyframes.find((k) => k.percentage === pct);
99
+ if (kf) {
100
+ void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
101
+ }
102
+ }}
103
+ />
104
+ )}
105
+
106
+ {clipContextMenu && (
107
+ <ClipContextMenu
108
+ x={clipContextMenu.x}
109
+ y={clipContextMenu.y}
110
+ element={clipContextMenu.element}
111
+ currentTime={currentTime}
112
+ onClose={() => setClipContextMenu(null)}
113
+ onSplit={(el, time) => onSplitElement?.(el, time)}
114
+ onDelete={(el) => {
115
+ pinZoomBeforeEdit();
116
+ onDeleteElement?.(el);
117
+ }}
118
+ />
119
+ )}
120
+ </>
121
+ );
122
+ }
@@ -0,0 +1,152 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { VideoThumbnail } from "./VideoThumbnail";
6
+
7
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
8
+ configurable: true,
9
+ value: true,
10
+ });
11
+
12
+ // Fire "intersecting" immediately on observe so the extraction effect runs.
13
+ class MockIntersectionObserver {
14
+ private cb: IntersectionObserverCallback;
15
+ constructor(cb: IntersectionObserverCallback) {
16
+ this.cb = cb;
17
+ }
18
+ observe() {
19
+ this.cb(
20
+ [{ isIntersecting: true } as IntersectionObserverEntry],
21
+ this as unknown as IntersectionObserver,
22
+ );
23
+ }
24
+ disconnect() {}
25
+ unobserve() {}
26
+ takeRecords(): IntersectionObserverEntry[] {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ class MockResizeObserver {
32
+ observe() {}
33
+ disconnect() {}
34
+ unobserve() {}
35
+ }
36
+
37
+ const originalIO = globalThis.IntersectionObserver;
38
+ const originalRO = globalThis.ResizeObserver;
39
+
40
+ let host: HTMLDivElement;
41
+ let root: Root | null = null;
42
+ let createdVideos: HTMLVideoElement[];
43
+
44
+ beforeEach(() => {
45
+ globalThis.IntersectionObserver =
46
+ MockIntersectionObserver as unknown as typeof IntersectionObserver;
47
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
48
+
49
+ createdVideos = [];
50
+ const origCreate = document.createElement.bind(document);
51
+ vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
52
+ const el = origCreate(tag);
53
+ if (tag === "video") createdVideos.push(el as HTMLVideoElement);
54
+ return el;
55
+ });
56
+
57
+ // happy-dom's <video>/<canvas> don't decode media; stub the seam the
58
+ // extractor depends on so the effect can run deterministically.
59
+ vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
60
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
61
+ drawImage: () => {},
62
+ } as unknown as CanvasRenderingContext2D);
63
+
64
+ host = document.createElement("div");
65
+ document.body.append(host);
66
+ });
67
+
68
+ afterEach(() => {
69
+ act(() => root?.unmount());
70
+ root = null;
71
+ vi.restoreAllMocks();
72
+ globalThis.IntersectionObserver = originalIO;
73
+ globalThis.ResizeObserver = originalRO;
74
+ document.body.innerHTML = "";
75
+ });
76
+
77
+ function render(videoSrc: string) {
78
+ root = createRoot(host);
79
+ act(() => {
80
+ root!.render(React.createElement(VideoThumbnail, { videoSrc, label: "", labelColor: "#fff" }));
81
+ });
82
+ }
83
+
84
+ function lastVideo(): HTMLVideoElement {
85
+ const v = createdVideos.at(-1);
86
+ expect(v).toBeDefined();
87
+ return v!;
88
+ }
89
+
90
+ describe("VideoThumbnail — tainted-canvas fallback", () => {
91
+ it("stops the extractor and drops the shimmer when toDataURL throws a SecurityError", () => {
92
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(() => {
93
+ throw new DOMException("Tainted canvases may not be exported.", "SecurityError");
94
+ });
95
+
96
+ render("https://cdn.example.com/no-cors.mp4");
97
+
98
+ // The effect ran once visible → a hidden <video> was created.
99
+ const video = lastVideo();
100
+
101
+ act(() => {
102
+ video.dispatchEvent(new Event("loadedmetadata"));
103
+ });
104
+ act(() => {
105
+ video.dispatchEvent(new Event("seeked"));
106
+ });
107
+
108
+ // No frame captured, and crucially the shimmer is gone (not spinning
109
+ // forever) — the clip falls back to its plain background.
110
+ expect(host.querySelectorAll("img").length).toBe(0);
111
+ expect(host.querySelector(".animate-pulse")).toBeNull();
112
+ });
113
+
114
+ it("drops the shimmer when a no-CORS load fires the video error event (0 frames) (#2214)", () => {
115
+ render("https://cdn.example.com/no-cors.mp4");
116
+ // Before the load resolves, the shimmer placeholder is up.
117
+ expect(host.querySelector(".animate-pulse")).not.toBeNull();
118
+
119
+ const video = lastVideo();
120
+ // crossOrigin="anonymous" against a CORS-less server fails the load outright —
121
+ // the error listener fires instead of loadedmetadata/seeked, so no frame is
122
+ // ever captured. The shimmer must stop rather than spin forever.
123
+ act(() => {
124
+ video.dispatchEvent(new Event("error"));
125
+ });
126
+
127
+ expect(host.querySelectorAll("img").length).toBe(0);
128
+ expect(host.querySelector(".animate-pulse")).toBeNull();
129
+ });
130
+
131
+ it("keeps streaming frames while the shimmer is up until a frame arrives", () => {
132
+ vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
133
+ "data:image/jpeg;base64,AAAA",
134
+ );
135
+
136
+ render("/api/projects/p/preview/assets/clip.mp4");
137
+ // Before any seek resolves, the shimmer placeholder is shown.
138
+ expect(host.querySelector(".animate-pulse")).not.toBeNull();
139
+
140
+ const video = lastVideo();
141
+ act(() => {
142
+ video.dispatchEvent(new Event("loadedmetadata"));
143
+ });
144
+ act(() => {
145
+ video.dispatchEvent(new Event("seeked"));
146
+ });
147
+
148
+ // A frame was captured, so tiles render and the shimmer clears.
149
+ expect(host.querySelectorAll("img").length).toBeGreaterThanOrEqual(1);
150
+ expect(host.querySelector(".animate-pulse")).toBeNull();
151
+ });
152
+ });
@@ -1,5 +1,6 @@
1
1
  import { memo, useRef, useState, useCallback, useEffect } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { computeThumbnailStrip, THUMBNAIL_CLIP_HEIGHT } from "./thumbnailUtils";
3
4
 
4
5
  interface VideoThumbnailProps {
5
6
  videoSrc: string;
@@ -8,7 +9,7 @@ interface VideoThumbnailProps {
8
9
  duration?: number;
9
10
  }
10
11
 
11
- const CLIP_HEIGHT = 66;
12
+ const CLIP_HEIGHT = THUMBNAIL_CLIP_HEIGHT;
12
13
  const MAX_UNIQUE_FRAMES: number = 6;
13
14
 
14
15
  /**
@@ -25,6 +26,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
25
26
  const [containerWidth, setContainerWidth] = useState(0);
26
27
  const [visible, setVisible] = useState(false);
27
28
  const [frames, setFrames] = useState<string[]>([]);
29
+ const [failed, setFailed] = useState(false);
28
30
  const [aspect, setAspect] = useState(16 / 9);
29
31
  const ioRef = useRef<IntersectionObserver | null>(null);
30
32
  const roRef = useRef<ResizeObserver | null>(null);
@@ -117,8 +119,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
117
119
 
118
120
  video.addEventListener("seeked", () => {
119
121
  if (cancelled) return;
120
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
121
- const dataUrl = canvas.toDataURL("image/jpeg", 0.6);
122
+ let dataUrl: string;
123
+ try {
124
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
125
+ dataUrl = canvas.toDataURL("image/jpeg", 0.6);
126
+ } catch {
127
+ // An external http(s) video served without CORS headers taints the
128
+ // canvas, so toDataURL throws a SecurityError. Stop the extractor
129
+ // cleanly and fall back to the no-thumbnail rendering (plain clip
130
+ // background), matching ImageThumbnail's error path — otherwise the
131
+ // shimmer placeholder would spin forever.
132
+ cancelled = true;
133
+ setFailed(true);
134
+ video.src = "";
135
+ video.load();
136
+ return;
137
+ }
122
138
  // Stream each frame immediately
123
139
  setFrames((prev) => [...prev, dataUrl]);
124
140
  idx++;
@@ -126,7 +142,12 @@ export const VideoThumbnail = memo(function VideoThumbnail({
126
142
  });
127
143
 
128
144
  video.addEventListener("error", () => {
129
- /* keep whatever frames we have */
145
+ // A no-CORS load fails outright (crossOrigin="anonymous" rejects a video
146
+ // served without CORS headers), firing this instead of the taint path in
147
+ // "seeked" — so 0 frames are ever extracted. Keep whatever frames we have,
148
+ // but mark failed so the shimmer placeholder stops spinning forever and we
149
+ // fall back to the plain clip background (#2214).
150
+ setFailed(true);
130
151
  });
131
152
 
132
153
  video.src = videoSrc;
@@ -136,13 +157,13 @@ export const VideoThumbnail = memo(function VideoThumbnail({
136
157
  cancelled = true;
137
158
  extractingRef.current = false;
138
159
  setFrames([]);
160
+ setFailed(false);
139
161
  video.src = "";
140
162
  video.load();
141
163
  };
142
164
  }, [visible, videoSrc, duration]);
143
165
 
144
- const frameW = Math.round(CLIP_HEIGHT * aspect);
145
- const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
166
+ const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect, CLIP_HEIGHT);
146
167
 
147
168
  return (
148
169
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
@@ -168,7 +189,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
168
189
  </div>
169
190
  )}
170
191
 
171
- {visible && frames.length === 0 && (
192
+ {visible && frames.length === 0 && !failed && (
172
193
  <div
173
194
  className="absolute inset-0 animate-pulse"
174
195
  style={{
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ computeThumbnailStrip,
4
+ encodePreviewPath,
5
+ resolveMediaPreviewUrl,
6
+ THUMBNAIL_CLIP_HEIGHT,
7
+ } from "./thumbnailUtils";
8
+
9
+ describe("computeThumbnailStrip", () => {
10
+ it("sizes tiles by aspect ratio at the clip height", () => {
11
+ const { frameW } = computeThumbnailStrip(500, 16 / 9);
12
+ expect(frameW).toBe(Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9)));
13
+ });
14
+
15
+ it("repeats tiles to cover the container width", () => {
16
+ const { frameW, frameCount } = computeThumbnailStrip(500, 1);
17
+ expect(frameW).toBe(THUMBNAIL_CLIP_HEIGHT);
18
+ expect(frameCount).toBe(Math.ceil(500 / THUMBNAIL_CLIP_HEIGHT));
19
+ expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
20
+ });
21
+
22
+ it("returns one tile when the container width is unknown", () => {
23
+ expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
24
+ expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
25
+ });
26
+
27
+ it("falls back to 16:9 for degenerate aspects", () => {
28
+ const expected = Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9));
29
+ expect(computeThumbnailStrip(300, 0).frameW).toBe(expected);
30
+ expect(computeThumbnailStrip(300, -2).frameW).toBe(expected);
31
+ expect(computeThumbnailStrip(300, Number.NaN).frameW).toBe(expected);
32
+ expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY).frameW).toBe(expected);
33
+ });
34
+
35
+ it("never returns a zero-width tile (avoids divide-by-zero repeat counts)", () => {
36
+ const { frameW, frameCount } = computeThumbnailStrip(300, 0.001);
37
+ expect(frameW).toBeGreaterThanOrEqual(1);
38
+ expect(Number.isFinite(frameCount)).toBe(true);
39
+ });
40
+
41
+ it("honors a custom clip height", () => {
42
+ expect(computeThumbnailStrip(300, 2, 40).frameW).toBe(80);
43
+ });
44
+ });
45
+
46
+ describe("resolveMediaPreviewUrl", () => {
47
+ it("routes composition-relative paths through the project preview endpoint", () => {
48
+ expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe(
49
+ "/api/projects/proj-1/preview/assets/image.png",
50
+ );
51
+ });
52
+
53
+ it("passes absolute http(s) URLs through untouched", () => {
54
+ expect(resolveMediaPreviewUrl("http://cdn.example.com/a.mp4", "proj-1")).toBe(
55
+ "http://cdn.example.com/a.mp4",
56
+ );
57
+ expect(resolveMediaPreviewUrl("https://cdn.example.com/a.png", "proj-1")).toBe(
58
+ "https://cdn.example.com/a.png",
59
+ );
60
+ });
61
+
62
+ it("percent-encodes spaces in filenames", () => {
63
+ expect(resolveMediaPreviewUrl("assets/my logo.png", "proj-1")).toBe(
64
+ "/api/projects/proj-1/preview/assets/my%20logo.png",
65
+ );
66
+ });
67
+
68
+ it("percent-encodes parentheses in filenames", () => {
69
+ expect(resolveMediaPreviewUrl("assets/heygen-symbol-blue-logo (2).svg", "proj-1")).toBe(
70
+ "/api/projects/proj-1/preview/assets/heygen-symbol-blue-logo%20(2).svg",
71
+ );
72
+ });
73
+
74
+ it("preserves slashes as path separators while encoding each segment", () => {
75
+ expect(resolveMediaPreviewUrl("sub dir/file (v2).mp4", "proj-2")).toBe(
76
+ "/api/projects/proj-2/preview/sub%20dir/file%20(v2).mp4",
77
+ );
78
+ });
79
+
80
+ it("percent-encodes unicode characters in filenames", () => {
81
+ expect(resolveMediaPreviewUrl("assets/café logo.png", "proj-1")).toBe(
82
+ "/api/projects/proj-1/preview/assets/caf%C3%A9%20logo.png",
83
+ );
84
+ });
85
+
86
+ it("leaves paths with no special characters unchanged", () => {
87
+ expect(resolveMediaPreviewUrl("assets/logo.svg", "proj-1")).toBe(
88
+ "/api/projects/proj-1/preview/assets/logo.svg",
89
+ );
90
+ });
91
+
92
+ it("percent-encodes a U+202F narrow no-break space (macOS screenshot artifact)", () => {
93
+ // "Screenshot … 2.16.30 PM.png" — the char between the time and PM is a
94
+ // narrow no-break space; it must encode to %E2%80%AF, not route raw (404).
95
+ expect(resolveMediaPreviewUrl("assets/Screenshot 2.16.30 PM.png", "proj-1")).toBe(
96
+ "/api/projects/proj-1/preview/assets/Screenshot%202.16.30%E2%80%AFPM.png",
97
+ );
98
+ });
99
+
100
+ it("passes data: URIs through untouched (never routes them through preview → HTTP 431)", () => {
101
+ const svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=";
102
+ expect(resolveMediaPreviewUrl(svg, "proj-1")).toBe(svg);
103
+ });
104
+
105
+ it("passes blob: URLs through untouched", () => {
106
+ const blob = "blob:http://localhost:5190/2b3c-4d5e";
107
+ expect(resolveMediaPreviewUrl(blob, "proj-1")).toBe(blob);
108
+ });
109
+ });
110
+
111
+ // The shared encoder used by every timeline media-URL builder (filmstrip, audio
112
+ // waveform, sub-composition preview) — must match the assets panel's per-segment
113
+ // encoding so those paths stop 404ing on non-ASCII filenames.
114
+ describe("encodePreviewPath", () => {
115
+ it("encodes spaces and parentheses per segment, preserving slashes", () => {
116
+ expect(encodePreviewPath("sub dir/file (v2).mp3")).toBe("sub%20dir/file%20(v2).mp3");
117
+ });
118
+
119
+ it("encodes a U+202F narrow no-break space to %E2%80%AF", () => {
120
+ expect(encodePreviewPath("assets/clip 2.mp3")).toBe("assets/clip%202.mp3");
121
+ expect(encodePreviewPath(`assets/clip${" "}2.mp3`)).toBe("assets/clip%E2%80%AF2.mp3");
122
+ });
123
+
124
+ it("leaves a plain path unchanged", () => {
125
+ expect(encodePreviewPath("assets/music.mp3")).toBe("assets/music.mp3");
126
+ });
127
+ });
@@ -0,0 +1,54 @@
1
+ /** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
2
+ export const THUMBNAIL_CLIP_HEIGHT = 66;
3
+
4
+ export interface ThumbnailStripLayout {
5
+ /** Width of a single tile, in CSS px. */
6
+ frameW: number;
7
+ /** Number of tiles needed to fill the container. */
8
+ frameCount: number;
9
+ }
10
+
11
+ /**
12
+ * Compute the film-strip tile layout for a clip thumbnail: fixed-height tiles
13
+ * sized by the media's aspect ratio, repeated to fill the clip width.
14
+ * Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
15
+ */
16
+ export function computeThumbnailStrip(
17
+ containerWidth: number,
18
+ aspect: number,
19
+ clipHeight: number = THUMBNAIL_CLIP_HEIGHT,
20
+ ): ThumbnailStripLayout {
21
+ const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
22
+ const frameW = Math.max(1, Math.round(clipHeight * safeAspect));
23
+ const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
24
+ return { frameW, frameCount };
25
+ }
26
+
27
+ /**
28
+ * Percent-encode each segment of a composition-relative media path so filenames
29
+ * containing spaces, parentheses, a U+202F narrow no-break space (the macOS
30
+ * screenshot artifact), or any other non-ASCII / URL-unsafe character yield a
31
+ * valid URL instead of a 404. Slashes are preserved as separators.
32
+ *
33
+ * Shared by every timeline media-URL builder (filmstrip thumbnails, audio
34
+ * waveform, sub-composition preview) so they encode identically to the assets
35
+ * panel — a raw segment 404s on the exact filenames the assets panel loads fine.
36
+ */
37
+ export function encodePreviewPath(relativePath: string): string {
38
+ return relativePath.split("/").map(encodeURIComponent).join("/");
39
+ }
40
+
41
+ /**
42
+ * Resolve a timeline element's media src to a URL loadable from the studio
43
+ * (parent) document. Composition-relative paths (e.g. "assets/image.png") are
44
+ * routed through the project preview endpoint with each segment encoded.
45
+ *
46
+ * Already-loadable URLs pass through untouched: absolute http(s) URLs, plus
47
+ * `data:` and `blob:` URLs. Routing a `data:`/`blob:` URL through the preview
48
+ * endpoint would percent-encode the whole thing into a multi-KB path segment
49
+ * that the server rejects with HTTP 431 (Request Header Fields Too Large).
50
+ */
51
+ export function resolveMediaPreviewUrl(src: string, projectId: string): string {
52
+ if (/^(?:https?:|data:|blob:)/i.test(src)) return src;
53
+ return `/api/projects/${projectId}/preview/${encodePreviewPath(src)}`;
54
+ }
@@ -1,12 +1,8 @@
1
1
  // fallow-ignore-file code-duplication
2
2
  // fallow-ignore-file dead-code
3
3
  import type { TimelineElement } from "../store/playerStore";
4
- import type { BlockedTimelineEditIntent, TimelineStackingReorderIntent } from "./timelineEditing";
5
- import type {
6
- TimelineGroupCommitOptions,
7
- TimelineGroupMoveChange,
8
- TimelineGroupResizeChange,
9
- } from "../../hooks/useTimelineGroupEditing";
4
+ import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
5
+ import type { BlockedTimelineEditIntent } from "./timelineEditing";
10
6
 
11
7
  /**
12
8
  * Shared callback signatures for timeline editing operations.
@@ -31,26 +27,30 @@ export interface TimelineDropCallbacks {
31
27
  export interface TimelineEditCallbacks {
32
28
  onMoveElement?: (
33
29
  element: TimelineElement,
34
- updates: Pick<TimelineElement, "start" | "track"> & {
35
- stackingReorder?: TimelineStackingReorderIntent | null;
36
- },
30
+ updates: Pick<TimelineElement, "start" | "track">,
31
+ ) => Promise<void> | void;
32
+ /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
33
+ * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
34
+ * lane change's follow-up z-reorder entry into one undo step. */
35
+ onMoveElements?: (
36
+ edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
37
+ coalesceKey?: string,
38
+ operation?: TimelineMoveOperation,
37
39
  ) => Promise<void> | void;
38
40
  onResizeElement?: (
39
41
  element: TimelineElement,
40
42
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
41
43
  ) => Promise<void> | void;
42
- onMoveElements?: (
43
- changes: TimelineGroupMoveChange[],
44
- options?: TimelineGroupCommitOptions,
45
- ) => Promise<void> | void;
46
44
  onResizeElements?: (
47
- changes: TimelineGroupResizeChange[],
48
- options?: TimelineGroupCommitOptions,
45
+ changes: Array<{
46
+ element: TimelineElement;
47
+ start: number;
48
+ duration: number;
49
+ playbackStart?: number;
50
+ }>,
51
+ options?: { coalesceKey?: string },
49
52
  ) => Promise<void> | void;
50
- onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
51
- onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
52
53
  onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
53
- onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
54
54
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
55
55
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
56
56
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -0,0 +1,39 @@
1
+ import { type ReactNode } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import type { TrackVisualStyle } from "./timelineIcons";
4
+
5
+ function ClipLintDot({ element }: { element: TimelineElement }) {
6
+ const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
7
+ if (!lint || lint.count === 0) return null;
8
+ return (
9
+ <span
10
+ className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
11
+ style={{ top: 7, right: 7 }}
12
+ title={lint.messages.join("\n")}
13
+ />
14
+ );
15
+ }
16
+
17
+ export function renderClipChildren(
18
+ element: TimelineElement,
19
+ clipStyle: TrackVisualStyle,
20
+ renderClipContent:
21
+ | ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode)
22
+ | undefined,
23
+ renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
24
+ ): ReactNode {
25
+ return (
26
+ <>
27
+ {renderClipOverlay?.(element)}
28
+ {!renderClipContent && <ClipLintDot element={element} />}
29
+ {renderClipContent && (
30
+ // borderRadius: inherit — the clip itself is overflow-visible (keyframe
31
+ // diamonds hang outside its bounds), so the thumbnail layer must clip
32
+ // itself to the clip's rounded corners or sharp corners poke out.
33
+ <div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
34
+ {renderClipContent(element, clipStyle)}
35
+ </div>
36
+ )}
37
+ </>
38
+ );
39
+ }