@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
@@ -446,12 +446,14 @@ export function StudioRightPanel({
446
446
 
447
447
  return (
448
448
  <>
449
+ {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
450
+ the absolutely-positioned inner hit area. */}
449
451
  <div
450
452
  role="separator"
451
453
  aria-label="Resize inspector panel"
452
454
  aria-orientation="vertical"
453
455
  tabIndex={0}
454
- className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
456
+ className="group relative w-[3px] flex-shrink-0 cursor-col-resize outline-none focus-visible:bg-studio-accent/20"
455
457
  style={{ touchAction: "none" }}
456
458
  onPointerDown={(e) => handlePanelResizeStart("right", e)}
457
459
  onPointerMove={handlePanelResizeMove}
@@ -465,10 +467,13 @@ export function StudioRightPanel({
465
467
  setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
466
468
  }}
467
469
  >
468
- <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
470
+ {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
471
+ <div className="absolute inset-y-0 -left-[2.5px] w-2" />
472
+ {/* Visible hairline */}
473
+ <div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
469
474
  </div>
470
475
  <div
471
- className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
476
+ className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
472
477
  style={{ width: rightWidth }}
473
478
  >
474
479
  {captionEditMode ? (
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
6
  import { usePlayerStore } from "../player/store/playerStore";
7
7
  import { TimelineToolbar } from "./TimelineToolbar";
8
8
 
@@ -18,7 +18,7 @@ function renderToolbar() {
18
18
  document.body.append(host);
19
19
  const root = createRoot(host);
20
20
  act(() => {
21
- root.render(<TimelineToolbar toggleTimelineVisibility={vi.fn()} />);
21
+ root.render(<TimelineToolbar />);
22
22
  });
23
23
  return { host, root };
24
24
  }
@@ -1,4 +1,5 @@
1
- import { useRef } from "react";
1
+ import { useEffect, useRef } from "react";
2
+ import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
2
3
  import {
3
4
  useEnableKeyframes,
4
5
  isPlayheadWithinTween,
@@ -9,9 +10,10 @@ import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
9
10
  import {
10
11
  getNextTimelineZoomPercent,
11
12
  getTimelineZoomPercent,
13
+ timelineZoomPercentToSlider,
14
+ timelineSliderToZoomPercent,
12
15
  } from "../player/components/timelineZoom";
13
16
  import { useTimelineZoom } from "../player/components/useTimelineZoom";
14
- import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
15
17
  import { usePlayerStore, type TimelineElement } from "../player";
16
18
  import {
17
19
  STUDIO_KEYFRAMES_ENABLED,
@@ -30,7 +32,6 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
30
32
  }
31
33
 
32
34
  interface TimelineToolbarProps {
33
- toggleTimelineVisibility: () => void;
34
35
  domEditSession?: DomEditSessionSlice;
35
36
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
36
37
  }
@@ -72,18 +73,20 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
72
73
  }
73
74
 
74
75
  // fallow-ignore-next-line complexity
75
- export function TimelineToolbar({
76
- toggleTimelineVisibility,
77
- domEditSession,
78
- onSplitElement,
79
- }: TimelineToolbarProps) {
76
+ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
80
77
  const activeTool = usePlayerStore((s) => s.activeTool);
81
78
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
79
+ const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
80
+ const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
82
81
  const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
83
82
  const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
84
83
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
85
84
  const currentTime = usePlayerStore((s) => s.currentTime);
86
85
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
86
+ // Subscribe (not getState) so the split button enables/disables the moment
87
+ // the selection changes, not only on the next playhead tick.
88
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
89
+ const elements = usePlayerStore((s) => s.elements);
87
90
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
88
91
  const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
89
92
  const {
@@ -99,28 +102,48 @@ export function TimelineToolbar({
99
102
  onAddKeyframe: onToggleKeyframe,
100
103
  });
101
104
 
105
+ // "N" toggles timeline snapping (industry convention: Resolve/FCP).
106
+ // Skip when typing in an input/contenteditable.
107
+ useEffect(() => {
108
+ // fallow-ignore-next-line complexity
109
+ const onKeyDown = (e: KeyboardEvent) => {
110
+ if (e.key !== "n" && e.key !== "N") return;
111
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
112
+ const target = e.target instanceof HTMLElement ? e.target : null;
113
+ if (target?.isContentEditable) return;
114
+ const tag = target?.tagName?.toLowerCase() ?? "";
115
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
116
+ const store = usePlayerStore.getState();
117
+ store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
118
+ };
119
+ window.addEventListener("keydown", onKeyDown);
120
+ return () => window.removeEventListener("keydown", onKeyDown);
121
+ }, []);
122
+
123
+ // CapCut-flat icon buttons: no per-button border/box chrome — a transparent
124
+ // 28px hit area with a subtle rounded hover wash, consistent 16px glyphs.
125
+ const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
126
+ const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200 active:scale-[0.98]`;
127
+ const flatActive = `${flatBtn} bg-white/[0.08] text-neutral-100 active:scale-[0.98]`;
128
+ const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
129
+
102
130
  return (
103
- <div className="border-b border-neutral-800/40 bg-neutral-950/96">
104
- <div className="flex items-center justify-between px-3 py-2">
105
- <div className="flex items-center gap-3">
106
- <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
107
- Timeline
108
- </div>
131
+ // The "TIMELINE" label is dropped for CapCut-like density — the pane's
132
+ // position (tracks right below) makes it self-evident.
133
+ <div className="border-b border-neutral-800/60">
134
+ <div className="flex items-center justify-between px-2 py-0.5">
135
+ <div className="flex items-center gap-0.5">
109
136
  {STUDIO_RAZOR_TOOL_ENABLED && (
110
- <div className="flex items-center border border-neutral-800 rounded overflow-hidden">
137
+ <>
111
138
  <Tooltip label="Selection tool (V)">
112
139
  <button
113
140
  type="button"
114
141
  onClick={() => setActiveTool("select")}
115
142
  aria-label="Selection tool"
116
143
  aria-pressed={activeTool === "select"}
117
- className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
118
- activeTool === "select"
119
- ? "bg-neutral-700 text-neutral-200"
120
- : "text-neutral-500 hover:text-neutral-300"
121
- }`}
144
+ className={activeTool === "select" ? flatActive : flatIdle}
122
145
  >
123
- <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
146
+ <svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
124
147
  <path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
125
148
  </svg>
126
149
  </button>
@@ -131,46 +154,65 @@ export function TimelineToolbar({
131
154
  onClick={() => setActiveTool("razor")}
132
155
  aria-label="Razor tool"
133
156
  aria-pressed={activeTool === "razor"}
134
- className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
135
- activeTool === "razor"
136
- ? "bg-neutral-700 text-neutral-200"
137
- : "text-neutral-500 hover:text-neutral-300"
138
- }`}
157
+ className={activeTool === "razor" ? flatActive : flatIdle}
139
158
  >
140
- <Scissors size={11} />
159
+ <Scissors size={16} />
141
160
  </button>
142
161
  </Tooltip>
143
- </div>
162
+ {/* Divider: tool-mode | editing-actions */}
163
+ <div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
164
+ </>
144
165
  )}
145
- {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
166
+ <Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
167
+ <button
168
+ type="button"
169
+ onClick={() => setTimelineSnapEnabled(!timelineSnapEnabled)}
170
+ aria-label="Toggle timeline snapping"
171
+ aria-pressed={timelineSnapEnabled}
172
+ className={timelineSnapEnabled ? flatActive : flatIdle}
173
+ >
174
+ <Magnet size={16} weight="bold" aria-hidden="true" />
175
+ </button>
176
+ </Tooltip>
177
+ {STUDIO_KEYFRAMES_ENABLED && (
178
+ // Always rendered (CapCut-style): with no keyframeable selection the
179
+ // button fades to a disabled state instead of unmounting, so the
180
+ // toolbar layout never shifts.
146
181
  <Tooltip
147
182
  label={
148
- keyframeState === "active"
149
- ? "Remove keyframe at playhead (K)"
150
- : keyframeState === "inactive"
151
- ? keyframeWillExtend
152
- ? "Add keyframe at playhead, extends animation (K)"
153
- : "Add keyframe at playhead (K)"
154
- : "Add keyframe (K)"
183
+ !onToggleKeyframe
184
+ ? "Select an animated element to add keyframes"
185
+ : keyframeState === "active"
186
+ ? "Remove keyframe at playhead (K)"
187
+ : keyframeState === "inactive"
188
+ ? keyframeWillExtend
189
+ ? "Add keyframe at playhead, extends animation (K)"
190
+ : "Add keyframe at playhead (K)"
191
+ : "Add keyframe (K)"
155
192
  }
156
193
  >
157
194
  <button
158
195
  type="button"
196
+ disabled={!onToggleKeyframe}
159
197
  onClick={onToggleKeyframe}
160
198
  aria-label={
161
199
  keyframeState === "active"
162
200
  ? "Remove keyframe at playhead"
163
201
  : "Add keyframe at playhead"
164
202
  }
165
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
166
- keyframeState === "active"
167
- ? "text-studio-accent"
168
- : keyframeState === "inactive"
169
- ? "text-neutral-400 hover:text-studio-accent"
170
- : "text-neutral-600 hover:text-neutral-400"
171
- }`}
203
+ className={
204
+ !onToggleKeyframe
205
+ ? flatDisabled
206
+ : `${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
207
+ keyframeState === "active"
208
+ ? "text-studio-accent"
209
+ : keyframeState === "inactive"
210
+ ? "text-neutral-400 hover:text-studio-accent"
211
+ : "text-neutral-600 hover:text-neutral-400"
212
+ }`
213
+ }
172
214
  >
173
- <svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
215
+ <svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
174
216
  {keyframeState === "active" ? (
175
217
  <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
176
218
  ) : (
@@ -198,13 +240,13 @@ export function TimelineToolbar({
198
240
  onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
199
241
  aria-label="Auto-record manual edits as keyframes"
200
242
  aria-pressed={autoKeyframeEnabled}
201
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
243
+ className={`${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
202
244
  autoKeyframeEnabled
203
245
  ? "text-red-400 hover:text-red-300"
204
246
  : "text-neutral-600 hover:text-neutral-400"
205
247
  }`}
206
248
  >
207
- <svg width="18" height="18" viewBox="0 0 10 10" fill="none">
249
+ <svg width="16" height="16" viewBox="0 0 10 10" fill="none">
208
250
  {/* Same diamond outline as the Add-keyframe icon, with a
209
251
  record-style dot inside: filled = auto-recording,
210
252
  hollow = manual edits won't be keyframed. */}
@@ -225,7 +267,7 @@ export function TimelineToolbar({
225
267
  (() => {
226
268
  // Render the button unconditionally (disabled when unusable):
227
269
  // mounting/unmounting mid-task shifts the neighboring controls.
228
- const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
270
+ // Mirrors the S-key gate: selected clip + playhead strictly inside it.
229
271
  const el = selectedElementId
230
272
  ? elements.find((e) => (e.key ?? e.id) === selectedElementId)
231
273
  : null;
@@ -236,7 +278,7 @@ export function TimelineToolbar({
236
278
  <Tooltip
237
279
  label={
238
280
  canSplit
239
- ? "Split clip at playhead (S)"
281
+ ? "Split at playhead (S)"
240
282
  : splittable
241
283
  ? "Move the playhead inside the clip to split"
242
284
  : "Select a clip to split"
@@ -245,64 +287,84 @@ export function TimelineToolbar({
245
287
  <button
246
288
  type="button"
247
289
  disabled={!canSplit}
248
- aria-label="Split clip at playhead"
290
+ aria-label="Split at playhead"
249
291
  onClick={() => {
250
292
  if (canSplit && el) onSplitElement(el, currentTime);
251
293
  }}
252
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
253
- canSplit
254
- ? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
255
- : "text-neutral-700 cursor-not-allowed"
256
- }`}
257
- >
258
- <Scissors size={15} />
259
- </button>
260
- </Tooltip>
261
- );
262
- })()}
263
- {beatAnalysisReady &&
264
- (() => {
265
- const canAdd = canAddBeatAt(currentTime);
266
- return (
267
- <Tooltip
268
- label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
269
- >
270
- <button
271
- type="button"
272
- disabled={!canAdd}
273
- aria-label="Add beat at playhead"
274
- onClick={() => {
275
- if (canAdd) addBeatAtCompositionTime(currentTime);
276
- }}
277
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
278
- canAdd
279
- ? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
280
- : "text-neutral-700 cursor-not-allowed"
281
- }`}
294
+ className={canSplit ? flatIdle : flatDisabled}
282
295
  >
283
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
284
- <path
285
- d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
286
- stroke="currentColor"
287
- strokeWidth="2"
288
- strokeLinecap="round"
289
- strokeLinejoin="round"
290
- />
296
+ {/* "][" split glyph: two outward-facing brackets with a center gap */}
297
+ <svg
298
+ width="16"
299
+ height="16"
300
+ viewBox="0 0 16 16"
301
+ fill="none"
302
+ stroke="currentColor"
303
+ strokeWidth="1.6"
304
+ strokeLinecap="round"
305
+ strokeLinejoin="round"
306
+ aria-hidden="true"
307
+ >
308
+ {/* Right bracket of left half: ] */}
309
+ <path d="M5 3 L7 3 L7 13 L5 13" />
310
+ {/* Left bracket of right half: [ */}
311
+ <path d="M11 3 L9 3 L9 13 L11 13" />
291
312
  </svg>
292
313
  </button>
293
314
  </Tooltip>
294
315
  );
295
316
  })()}
317
+ {(() => {
318
+ // Always rendered (CapCut-style): before beat analysis loads (or when
319
+ // the project has no analyzed music) the button fades to a disabled
320
+ // state instead of unmounting, so the toolbar layout never shifts.
321
+ const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
322
+ return (
323
+ <Tooltip
324
+ label={
325
+ !beatAnalysisReady
326
+ ? "Add a music track with beat analysis to place beats"
327
+ : canAdd
328
+ ? "Add beat at playhead"
329
+ : "A beat already exists at the playhead"
330
+ }
331
+ >
332
+ <button
333
+ type="button"
334
+ disabled={!canAdd}
335
+ aria-label="Add beat at playhead"
336
+ onClick={() => {
337
+ if (canAdd) addBeatAtCompositionTime(currentTime);
338
+ }}
339
+ className={
340
+ canAdd
341
+ ? `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-[#22c55e] active:scale-[0.98]`
342
+ : flatDisabled
343
+ }
344
+ >
345
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
346
+ <path
347
+ d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
348
+ stroke="currentColor"
349
+ strokeWidth="2"
350
+ strokeLinecap="round"
351
+ strokeLinejoin="round"
352
+ />
353
+ </svg>
354
+ </button>
355
+ </Tooltip>
356
+ );
357
+ })()}
296
358
  </div>
297
- <div className="flex items-center gap-1">
359
+ <div className="flex items-center gap-0.5">
298
360
  <Tooltip label="Fit timeline to width">
299
361
  <button
300
362
  type="button"
301
363
  onClick={() => setZoomMode("fit")}
302
- className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
364
+ className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
303
365
  zoomMode === "fit"
304
- ? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
305
- : "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
366
+ ? "bg-studio-accent/10 text-studio-accent"
367
+ : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
306
368
  }`}
307
369
  >
308
370
  Fit
@@ -311,55 +373,51 @@ export function TimelineToolbar({
311
373
  <Tooltip label="Zoom out">
312
374
  <button
313
375
  type="button"
376
+ aria-label="Zoom out"
314
377
  onClick={() => {
315
378
  setZoomMode("manual");
316
379
  setManualZoomPercent(
317
380
  getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent),
318
381
  );
319
382
  }}
320
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
383
+ className={flatIdle}
321
384
  >
322
- -
385
+ <MagnifyingGlassMinus size={16} aria-hidden="true" />
323
386
  </button>
324
387
  </Tooltip>
325
- <div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
326
- {`${displayedTimelineZoomPercent}%`}
327
- </div>
388
+ <input
389
+ type="range"
390
+ min="0"
391
+ max="100"
392
+ value={timelineZoomPercentToSlider(displayedTimelineZoomPercent)}
393
+ title={`${displayedTimelineZoomPercent}%`}
394
+ aria-label="Timeline zoom"
395
+ onChange={(e) => {
396
+ setZoomMode("manual");
397
+ setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
398
+ }}
399
+ className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
400
+ />
328
401
  <Tooltip label="Zoom in">
329
402
  <button
330
403
  type="button"
404
+ aria-label="Zoom in"
331
405
  onClick={() => {
332
406
  setZoomMode("manual");
333
407
  setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
334
408
  }}
335
- className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
409
+ className={flatIdle}
336
410
  >
337
- +
338
- </button>
339
- </Tooltip>
340
- <Tooltip label={getTimelineToggleTitle(true)}>
341
- <button
342
- type="button"
343
- onClick={toggleTimelineVisibility}
344
- className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
345
- aria-label="Hide timeline editor"
346
- >
347
- <svg
348
- width="14"
349
- height="14"
350
- viewBox="0 0 24 24"
351
- fill="none"
352
- stroke="currentColor"
353
- strokeWidth="1.8"
354
- strokeLinecap="round"
355
- strokeLinejoin="round"
356
- aria-hidden="true"
357
- >
358
- <path d="M5 7h14" />
359
- <path d="m8 11 4 4 4-4" />
360
- </svg>
411
+ <MagnifyingGlassPlus size={16} aria-hidden="true" />
361
412
  </button>
362
413
  </Tooltip>
414
+ {/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */}
415
+ <span
416
+ className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
417
+ aria-label="Timeline zoom level"
418
+ >
419
+ {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
420
+ </span>
363
421
  </div>
364
422
  </div>
365
423
  </div>
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
- import { afterEach, describe, expect, it } from "vitest";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { DomEditSelection } from "./domEditing";
6
6
  import type { OverlayRect } from "./domEditOverlayGeometry";
7
7
  import { DomEditCropHandles } from "./DomEditCropHandles";
@@ -33,7 +33,10 @@ function makeEl(id: string, clip: string): HTMLElement {
33
33
  return el;
34
34
  }
35
35
 
36
- function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) => void } {
36
+ function render(
37
+ el: HTMLElement,
38
+ onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
39
+ ): { root: Root; rerender: (next: HTMLElement) => void } {
37
40
  const host = document.createElement("div");
38
41
  document.body.append(host);
39
42
  const root = createRoot(host);
@@ -43,7 +46,7 @@ function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) =>
43
46
  <DomEditCropHandles
44
47
  selection={selectionFor(target)}
45
48
  overlayRect={overlayRect}
46
- onStyleCommit={() => undefined}
49
+ onStyleCommit={onStyleCommit}
47
50
  />,
48
51
  );
49
52
  });
@@ -83,4 +86,72 @@ describe("DomEditCropHandles clip lift/restore", () => {
83
86
  act(() => root.unmount());
84
87
  expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
85
88
  });
89
+
90
+ it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
91
+ const a = makeEl("a", "inset(10px)");
92
+ let resolveCommit: (() => void) | undefined;
93
+ const pendingCommit = new Promise<void>((resolve) => {
94
+ resolveCommit = resolve;
95
+ });
96
+ const onStyleCommit = vi.fn((property: string, value: string) => {
97
+ a.style.setProperty(property, value);
98
+ return pendingCommit;
99
+ });
100
+ const { root } = render(a, onStyleCommit);
101
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
102
+ expect(handle).toBeTruthy();
103
+
104
+ act(() =>
105
+ handle!.dispatchEvent(
106
+ new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
107
+ ),
108
+ );
109
+ act(() =>
110
+ handle!.dispatchEvent(
111
+ new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
112
+ ),
113
+ );
114
+ act(() =>
115
+ handle!.dispatchEvent(
116
+ new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
117
+ ),
118
+ );
119
+
120
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
121
+ expect(a.style.getPropertyValue("clip-path")).toBe("none");
122
+ resolveCommit?.();
123
+ await act(async () => pendingCommit);
124
+ act(() => root.unmount());
125
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
126
+ });
127
+
128
+ it("re-lifts when the crop commit rejects", async () => {
129
+ const a = makeEl("a", "inset(10px)");
130
+ const onStyleCommit = vi.fn((property: string, value: string) => {
131
+ a.style.setProperty(property, value);
132
+ return Promise.reject(new Error("persist failed"));
133
+ });
134
+ const { root } = render(a, onStyleCommit);
135
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
136
+
137
+ act(() =>
138
+ handle!.dispatchEvent(
139
+ new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
140
+ ),
141
+ );
142
+ act(() =>
143
+ handle!.dispatchEvent(
144
+ new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
145
+ ),
146
+ );
147
+ await act(async () => {
148
+ handle!.dispatchEvent(
149
+ new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
150
+ );
151
+ await Promise.resolve();
152
+ });
153
+
154
+ expect(a.style.getPropertyValue("clip-path")).toBe("none");
155
+ act(() => root.unmount());
156
+ });
86
157
  });