@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,217 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import type { TimelineElement } from "../../player";
3
+ import { usePlayerStore } from "../../player/store/playerStore";
4
+ import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
6
+ import { useStudioShellContext } from "../../contexts/StudioContext";
7
+ import {
8
+ useDomEditActionsContext,
9
+ useDomEditSelectionContext,
10
+ } from "../../contexts/DomEditContext";
11
+ import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
12
+ import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
13
+ import { resolveKeyframeRetime } from "../editor/keyframeRetime";
14
+ import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
15
+
16
+ export interface TimelineEditCallbackDeps {
17
+ handleTimelineElementMove: (
18
+ element: TimelineElement,
19
+ updates: Pick<TimelineElement, "start" | "track">,
20
+ ) => Promise<void> | void;
21
+ handleTimelineElementsMove: (
22
+ edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
23
+ coalesceKey?: string,
24
+ operation?: TimelineMoveOperation,
25
+ ) => Promise<void> | void;
26
+ handleTimelineElementResize: (
27
+ element: TimelineElement,
28
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
29
+ ) => Promise<void> | void;
30
+ handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
31
+ handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
32
+ handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
33
+ handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
34
+ handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
35
+ handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
36
+ }
37
+
38
+ /**
39
+ * Builds the timeline edit callback bag (move/resize/split/razor plus the
40
+ * keyframe-diamond callbacks) provided to `<Timeline>` via TimelineEditProvider.
41
+ * The keyframe callbacks resolve the dragged diamond back to its GSAP anim id +
42
+ * tween-relative percentage, reading DOM-edit selection state from context.
43
+ */
44
+ // fallow-ignore-next-line complexity
45
+ export function useTimelineEditCallbacks({
46
+ handleTimelineElementMove,
47
+ handleTimelineElementsMove,
48
+ handleTimelineElementResize,
49
+ handleTimelineGroupResize,
50
+ handleToggleTrackHidden,
51
+ handleBlockedTimelineEdit,
52
+ handleTimelineElementSplit,
53
+ handleRazorSplit,
54
+ handleRazorSplitAll,
55
+ }: TimelineEditCallbackDeps): TimelineEditCallbacks {
56
+ const { projectId, activeCompPath } = useStudioShellContext();
57
+ const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
58
+ const {
59
+ handleGsapRemoveKeyframe,
60
+ handleGsapMoveKeyframeToPlayhead,
61
+ handleGsapMoveKeyframe,
62
+ handleGsapResizeKeyframedTween,
63
+ handleGsapUpdateMeta,
64
+ handleGsapAddKeyframe,
65
+ handleGsapConvertToKeyframes,
66
+ handleGsapRemoveAllKeyframes,
67
+ buildDomSelectionForTimelineElement,
68
+ } = useDomEditActionsContext();
69
+
70
+ // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
71
+ // tween-relative percentage (shared by the delete/move keyframe callbacks): the
72
+ // diamond reports a clip-% but the script ops key on the tween-%. Prefers the
73
+ // anim in the keyframe's property group, falling back to the first keyframed one.
74
+ const resolveKeyframeTarget = useCallback(
75
+ // fallow-ignore-next-line complexity
76
+ (pct: number): { animId: string; tweenPct: number } | null => {
77
+ const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
78
+ const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
79
+ const group = kf?.propertyGroup;
80
+ const anim =
81
+ (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ??
82
+ selectedGsapAnimations.find((a) => a.keyframes);
83
+ return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null;
84
+ },
85
+ [domEditSelection?.id, selectedGsapAnimations],
86
+ );
87
+
88
+ return useMemo(
89
+ () => ({
90
+ onMoveElement: handleTimelineElementMove,
91
+ onMoveElements: handleTimelineElementsMove,
92
+ onResizeElement: handleTimelineElementResize,
93
+ onResizeElements: handleTimelineGroupResize,
94
+ onToggleTrackHidden: handleToggleTrackHidden,
95
+ onBlockedEditAttempt: handleBlockedTimelineEdit,
96
+ onSplitElement: handleTimelineElementSplit,
97
+ onRazorSplit: handleRazorSplit,
98
+ onRazorSplitAll: handleRazorSplitAll,
99
+ onDeleteAllKeyframes: () => {
100
+ // Hold the element where it is (collapse keyframes to a static set) rather
101
+ // than deleting the whole animation — deleting strands a stale GSAP base
102
+ // that the next drag adds to, flinging the element off-screen.
103
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
104
+ if (!anim) return;
105
+ handleGsapRemoveAllKeyframes(anim.id);
106
+ },
107
+ onDeleteKeyframe: (_elId: string, pct: number) => {
108
+ const target = resolveKeyframeTarget(pct);
109
+ if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct);
110
+ },
111
+ // Retime the keyframe to the playhead, preserving its value + ease.
112
+ onMoveKeyframeToPlayhead: (_elId: string, pct: number) => {
113
+ const target = resolveKeyframeTarget(pct);
114
+ if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
115
+ },
116
+ // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
117
+ // the dragged keyframe's anim + tween-%. We convert the clip-% drop to an
118
+ // absolute time (via the clip's timing basis) and let resolveKeyframeRetime
119
+ // decide: a drop inside the tween window is a plain move (re-key tween-%); a
120
+ // drop past the boundary (last keyframe past the end, first before the start)
121
+ // resizes the tween — position/duration grow so the dragged keyframe lands at
122
+ // the drop while every other keyframe keeps its absolute time (value+ease too).
123
+ // fallow-ignore-next-line complexity
124
+ onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
125
+ const target = resolveKeyframeTarget(fromClipPct);
126
+ const sel = domEditSelection;
127
+ if (!target || !sel) return;
128
+ const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
129
+ const tweenStart = anim ? resolveTweenStart(anim) : null;
130
+ if (!anim || tweenStart === null) return;
131
+ const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
132
+ const sourceFile = sel.sourceFile || activeCompPath || "index.html";
133
+ const { elements, domClipChildren } = usePlayerStore.getState();
134
+ const { elStart, elDuration } = resolveClipTimingBasis(
135
+ sel.id ?? "",
136
+ sourceFile,
137
+ elements,
138
+ domClipChildren,
139
+ );
140
+ const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
141
+ const decision = resolveKeyframeRetime({
142
+ keyframes: anim.keyframes?.keyframes ?? [],
143
+ draggedTweenPct: target.tweenPct,
144
+ tweenStart,
145
+ tweenDuration,
146
+ dropAbsTime,
147
+ });
148
+ if (decision.kind === "move" && decision.toTweenPct != null) {
149
+ handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct);
150
+ } else if (
151
+ decision.kind === "resize" &&
152
+ decision.pctRemap &&
153
+ decision.position != null &&
154
+ decision.duration != null
155
+ ) {
156
+ handleGsapResizeKeyframedTween(
157
+ target.animId,
158
+ decision.position,
159
+ decision.duration,
160
+ decision.pctRemap,
161
+ );
162
+ }
163
+ },
164
+ onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
165
+ for (const anim of selectedGsapAnimations) {
166
+ if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease });
167
+ }
168
+ },
169
+ // fallow-ignore-next-line complexity
170
+ onToggleKeyframeAtPlayhead: (el: TimelineElement) => {
171
+ const currentTime = usePlayerStore.getState().currentTime;
172
+ const pct =
173
+ el.duration > 0
174
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)))
175
+ : 0;
176
+ const anim = selectedGsapAnimations.find((a) => a.keyframes);
177
+ if (anim?.keyframes) {
178
+ const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
179
+ if (existing) {
180
+ handleGsapRemoveKeyframe(anim.id, existing.percentage);
181
+ } else {
182
+ handleGsapAddKeyframe(anim.id, pct, "x", 0);
183
+ }
184
+ } else {
185
+ const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
186
+ if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
187
+ }
188
+ },
189
+ }),
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ [
192
+ handleTimelineElementMove,
193
+ handleTimelineElementsMove,
194
+ handleTimelineElementResize,
195
+ handleTimelineGroupResize,
196
+ handleToggleTrackHidden,
197
+ handleBlockedTimelineEdit,
198
+ handleTimelineElementSplit,
199
+ handleRazorSplit,
200
+ handleRazorSplitAll,
201
+ handleGsapRemoveAllKeyframes,
202
+ resolveKeyframeTarget,
203
+ selectedGsapAnimations,
204
+ handleGsapRemoveKeyframe,
205
+ handleGsapMoveKeyframeToPlayhead,
206
+ handleGsapMoveKeyframe,
207
+ handleGsapResizeKeyframedTween,
208
+ handleGsapUpdateMeta,
209
+ handleGsapAddKeyframe,
210
+ handleGsapConvertToKeyframes,
211
+ buildDomSelectionForTimelineElement,
212
+ projectId,
213
+ activeCompPath,
214
+ domEditSelection,
215
+ ],
216
+ );
217
+ }
@@ -0,0 +1,106 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
7
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8
+ import { AssetCard } from "./AssetCard";
9
+ import { AudioRow } from "./AudioRow";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ let root: Root | null = null;
14
+
15
+ afterEach(() => {
16
+ if (root) {
17
+ act(() => root?.unmount());
18
+ root = null;
19
+ }
20
+ document.body.innerHTML = "";
21
+ usePlayerStore.getState().reset();
22
+ useAssetPreviewStore.getState().clearPreviewAsset();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ function mount(node: React.ReactElement): HTMLElement {
27
+ const host = document.createElement("div");
28
+ document.body.append(host);
29
+ root = createRoot(host);
30
+ act(() => {
31
+ root?.render(node);
32
+ });
33
+ return host;
34
+ }
35
+
36
+ function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
37
+ return { tag: "div", start: 0, duration: 5, track: 0, ...input };
38
+ }
39
+
40
+ /** Simulate a drag-free click: pointerdown + pointerup at the same point. */
41
+ function clickCard(host: HTMLElement): void {
42
+ const card = host.querySelector('[draggable="true"]');
43
+ if (!card) throw new Error("Expected a draggable card root");
44
+ const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
45
+ act(() => {
46
+ card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
47
+ card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
48
+ });
49
+ }
50
+
51
+ describe("AssetCard click behavior", () => {
52
+ const cardProps = {
53
+ projectId: "p1",
54
+ onCopy: vi.fn(),
55
+ isCopied: false,
56
+ };
57
+
58
+ it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
59
+ usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
60
+ // Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
61
+ // or it stays stuck over the canvas while the timeline reveals the clip.
62
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
63
+
64
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
65
+ clickCard(host);
66
+
67
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
68
+ expect(usePlayerStore.getState().selectedElementId).toBe("img1");
69
+ });
70
+
71
+ it("opens the preview overlay for a not-yet-added asset", () => {
72
+ const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
73
+ clickCard(host);
74
+
75
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
76
+ expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
77
+ });
78
+ });
79
+
80
+ describe("AudioRow click behavior", () => {
81
+ const rowProps = {
82
+ projectId: "p1",
83
+ onCopy: vi.fn(),
84
+ isCopied: false,
85
+ };
86
+
87
+ it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
88
+ usePlayerStore
89
+ .getState()
90
+ .setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
91
+ useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
92
+
93
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
94
+ clickCard(host);
95
+
96
+ expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
97
+ expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
98
+ });
99
+
100
+ it("opens the preview overlay for a not-yet-added audio asset", () => {
101
+ const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
102
+ clickCard(host);
103
+
104
+ expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
105
+ });
106
+ });
@@ -0,0 +1,343 @@
1
+ /**
2
+ * AssetCard and FontRow — visual asset tile / row components for the Assets panel.
3
+ * Extracted from AssetsTab.tsx to keep that file under the 600-line CI gate.
4
+ */
5
+ import { useState, useEffect, useRef, useCallback } from "react";
6
+ import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
7
+ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
8
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
9
+ import { ContextMenu } from "./AssetContextMenu";
10
+ import { usePlayerStore } from "../../player/store/playerStore";
11
+ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
12
+ import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
13
+ import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
14
+ import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
15
+
16
+ /** Drag payload writer shared by the asset tile and the font row: copy effect
17
+ * plus the timeline-asset MIME and a plain-text path fallback. */
18
+ function writeAssetDragData(e: React.DragEvent, asset: string): void {
19
+ e.dataTransfer.effectAllowed = "copy";
20
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
21
+ e.dataTransfer.setData("text/plain", asset);
22
+ }
23
+
24
+ /** Open the row/tile context menu at the pointer, shared by asset tile + font row. */
25
+ function openAssetContextMenu(
26
+ e: React.MouseEvent,
27
+ setContextMenu: (menu: { x: number; y: number }) => void,
28
+ ): void {
29
+ e.preventDefault();
30
+ setContextMenu({ x: e.clientX, y: e.clientY });
31
+ }
32
+
33
+ /**
34
+ * Lazily probe a video/audio URL for its duration via a hidden HTMLVideoElement
35
+ * (`preload="metadata"`). The manifest only covers ~/.media assets, so project
36
+ * assets in assets/ have no manifest entry — this fills the gap.
37
+ * Returns `undefined` until the probe completes; `null` if it failed.
38
+ */
39
+ function useProbedDuration(src: string, skip: boolean): number | null | undefined {
40
+ const [duration, setDuration] = useState<number | null | undefined>(undefined);
41
+ useEffect(() => {
42
+ if (skip) return;
43
+ let cancelled = false;
44
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
45
+ // The in-flight probe element, so unmount cleanup can abort its network
46
+ // fetch (clearing `src`) instead of leaving it to finish in the background.
47
+ let liveVid: HTMLVideoElement | null = null;
48
+
49
+ function teardown(vid: HTMLVideoElement) {
50
+ vid.onloadedmetadata = null;
51
+ vid.onerror = null;
52
+ vid.src = "";
53
+ }
54
+
55
+ function probe(attempt: number) {
56
+ if (cancelled) return;
57
+ const vid = document.createElement("video");
58
+ liveVid = vid;
59
+ vid.preload = "metadata";
60
+ vid.muted = true;
61
+ vid.onloadedmetadata = () => {
62
+ const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null;
63
+ teardown(vid);
64
+ if (!cancelled) setDuration(d);
65
+ };
66
+ vid.onerror = () => {
67
+ teardown(vid);
68
+ if (!cancelled) {
69
+ if (attempt < 1) retryTimer = setTimeout(() => probe(attempt + 1), 50);
70
+ else setDuration(null);
71
+ }
72
+ };
73
+ vid.src = src;
74
+ }
75
+
76
+ probe(0);
77
+ return () => {
78
+ cancelled = true;
79
+ if (retryTimer) clearTimeout(retryTimer);
80
+ if (liveVid) teardown(liveVid);
81
+ };
82
+ }, [src, skip]);
83
+ return duration;
84
+ }
85
+
86
+ export interface AssetCardProps {
87
+ projectId: string;
88
+ asset: string;
89
+ used: boolean;
90
+ duration?: number;
91
+ onCopy: (path: string) => void;
92
+ isCopied: boolean;
93
+ onDelete?: (path: string) => void;
94
+ onRename?: (oldPath: string, newPath: string) => void;
95
+ onAddAssetToTimeline?: (path: string) => void;
96
+ }
97
+
98
+ /**
99
+ * Thumbnail card for images and video assets. Renders in a 2-col grid.
100
+ *
101
+ * Click behaviour (CapCut-style):
102
+ * - Already added → selects the clip on the timeline (setSelectedElementId).
103
+ * - Not yet added → opens the asset preview overlay over the canvas.
104
+ * Drag behaviour is preserved: a pointer movement exceeding DRAG_THRESHOLD_PX
105
+ * before pointerup is treated as drag-start, not a click.
106
+ */
107
+ // fallow-ignore-next-line complexity
108
+ export function AssetCard({
109
+ projectId,
110
+ asset,
111
+ used,
112
+ duration,
113
+ onCopy,
114
+ isCopied,
115
+ onDelete,
116
+ onRename,
117
+ onAddAssetToTimeline,
118
+ }: AssetCardProps) {
119
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
120
+ const [hovered, setHovered] = useState(false);
121
+ const fullName = asset.split("/").pop() ?? asset;
122
+ const name = basename(asset);
123
+ const extension = ext(asset);
124
+ const serveUrl = resolveMediaPreviewUrl(asset, projectId);
125
+ const isVideo = VIDEO_EXT.test(asset);
126
+ const isImage = IMAGE_EXT.test(asset);
127
+ const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null);
128
+ const resolvedDuration = duration ?? probedDuration ?? undefined;
129
+ const durationLabel = formatDuration(resolvedDuration ?? 0);
130
+
131
+ // Drag-threshold click gate: track pointer-down position so we can ignore
132
+ // pointer-up events that followed a real drag gesture.
133
+ const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
134
+
135
+ const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
+ const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
137
+ const elements = usePlayerStore((s) => s.elements);
138
+ const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139
+ const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
140
+
141
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
142
+ pointerDownRef.current = { x: e.clientX, y: e.clientY };
143
+ }, []);
144
+
145
+ const handlePointerUp = useCallback(
146
+ (e: React.PointerEvent) => {
147
+ const origin = pointerDownRef.current;
148
+ pointerDownRef.current = null;
149
+ if (!origin) return;
150
+ if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
151
+ // Treat as click
152
+ if (used) {
153
+ const clip = findClipForAsset(elements, asset);
154
+ if (clip) {
155
+ // Dismiss any open preview overlay (from another asset) — the reveal
156
+ // must not leave a stale preview card floating over the canvas.
157
+ clearPreviewAsset();
158
+ const clipKey = clip.key ?? clip.id;
159
+ setSelectedElementId(clipKey);
160
+ // Scroll the timeline so the selected clip is actually visible.
161
+ requestClipReveal(clipKey);
162
+ return;
163
+ }
164
+ }
165
+ // Not added (or no matching clip found) → preview overlay
166
+ setPreviewAsset(asset, projectId);
167
+ },
168
+ [
169
+ used,
170
+ elements,
171
+ asset,
172
+ projectId,
173
+ setSelectedElementId,
174
+ requestClipReveal,
175
+ setPreviewAsset,
176
+ clearPreviewAsset,
177
+ ],
178
+ );
179
+
180
+ return (
181
+ <>
182
+ <div
183
+ draggable
184
+ onPointerDown={handlePointerDown}
185
+ onPointerUp={handlePointerUp}
186
+ onDragStart={(e) => writeAssetDragData(e, asset)}
187
+ onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
188
+ onPointerEnter={() => setHovered(true)}
189
+ onPointerLeave={() => setHovered(false)}
190
+ className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${
191
+ isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40"
192
+ }`}
193
+ >
194
+ {/* Thumbnail */}
195
+ <div className="w-full aspect-video rounded overflow-hidden bg-neutral-900 relative">
196
+ {isImage && (
197
+ <img
198
+ src={serveUrl}
199
+ alt={name}
200
+ loading="lazy"
201
+ className="w-full h-full object-cover"
202
+ onError={(e) => {
203
+ (e.target as HTMLImageElement).style.display = "none";
204
+ }}
205
+ />
206
+ )}
207
+ {isVideo && (
208
+ <>
209
+ <VideoFrameThumbnail src={serveUrl} />
210
+ {hovered && (
211
+ <video
212
+ src={serveUrl}
213
+ autoPlay
214
+ muted
215
+ loop
216
+ playsInline
217
+ className="absolute inset-0 w-full h-full object-cover"
218
+ />
219
+ )}
220
+ </>
221
+ )}
222
+ {!isImage && !isVideo && (
223
+ <div className="w-full h-full flex items-center justify-center">
224
+ <span className="text-[10px] font-medium text-neutral-600">{extension}</span>
225
+ </div>
226
+ )}
227
+
228
+ {/* "Added" badge — top-left */}
229
+ {used && (
230
+ <span className="absolute top-1 left-1 text-[9px] font-semibold leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-1">
231
+ Added
232
+ </span>
233
+ )}
234
+
235
+ {/* Duration badge — top-right, media only */}
236
+ {durationLabel && (
237
+ <span className="absolute top-1 right-1 text-[9px] font-medium leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-2 tabular-nums">
238
+ {durationLabel}
239
+ </span>
240
+ )}
241
+ </div>
242
+
243
+ {/* Filename caption */}
244
+ <span
245
+ className={`text-[10px] leading-tight text-center block w-full ${
246
+ used ? "text-panel-text-2" : "text-panel-text-4"
247
+ }`}
248
+ title={fullName}
249
+ >
250
+ {truncateMiddle(fullName, 22)}
251
+ </span>
252
+ </div>
253
+
254
+ {contextMenu && (
255
+ <ContextMenu
256
+ x={contextMenu.x}
257
+ y={contextMenu.y}
258
+ asset={asset}
259
+ onClose={() => setContextMenu(null)}
260
+ onCopy={onCopy}
261
+ onDelete={onDelete}
262
+ onRename={onRename}
263
+ onAddAtPlayhead={onAddAssetToTimeline}
264
+ />
265
+ )}
266
+ </>
267
+ );
268
+ }
269
+
270
+ export interface FontRowProps {
271
+ asset: string;
272
+ used: boolean;
273
+ onCopy: (path: string) => void;
274
+ isCopied: boolean;
275
+ onDelete?: (path: string) => void;
276
+ onRename?: (oldPath: string, newPath: string) => void;
277
+ onAddAssetToTimeline?: (path: string) => void;
278
+ }
279
+
280
+ /**
281
+ * Compact row for font assets (no meaningful thumbnail; show ext badge + name).
282
+ */
283
+ export function FontRow({
284
+ asset,
285
+ used,
286
+ onCopy,
287
+ isCopied,
288
+ onDelete,
289
+ onRename,
290
+ onAddAssetToTimeline,
291
+ }: FontRowProps) {
292
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
293
+ const name = basename(asset);
294
+ const extension = ext(asset);
295
+
296
+ return (
297
+ <>
298
+ <div
299
+ draggable
300
+ onClick={() => onCopy(asset)}
301
+ onDragStart={(e) => writeAssetDragData(e, asset)}
302
+ onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
303
+ className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${
304
+ isCopied
305
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
306
+ : "border-l-2 border-transparent hover:bg-neutral-800/50"
307
+ }`}
308
+ >
309
+ <div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
310
+ <span className="text-[9px] font-medium text-neutral-700">{extension}</span>
311
+ </div>
312
+ <div className="min-w-0 flex-1">
313
+ <span
314
+ className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
315
+ >
316
+ {name}
317
+ </span>
318
+ <div className="flex items-center gap-1.5">
319
+ <span className="text-[10px] text-neutral-600 truncate">{extension}</span>
320
+ {used && (
321
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
322
+ in use
323
+ </span>
324
+ )}
325
+ </div>
326
+ </div>
327
+ </div>
328
+
329
+ {contextMenu && (
330
+ <ContextMenu
331
+ x={contextMenu.x}
332
+ y={contextMenu.y}
333
+ asset={asset}
334
+ onClose={() => setContextMenu(null)}
335
+ onCopy={onCopy}
336
+ onDelete={onDelete}
337
+ onRename={onRename}
338
+ onAddAtPlayhead={onAddAssetToTimeline}
339
+ />
340
+ )}
341
+ </>
342
+ );
343
+ }