@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +3 -5
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +8 -8
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +8 -1
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
6
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
+ import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
9
10
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
11
  import { SpeedMenu } from "./SpeedMenu";
11
12
  import { VolumeControl } from "./VolumeControl";
@@ -155,12 +156,47 @@ const FullscreenButton = memo(function FullscreenButton({
155
156
 
156
157
  /* ── Main component ──────────────────────────────────────────────── */
157
158
 
159
+ /**
160
+ * "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
161
+ * attribute and never reaches the export — so the state has to say so out loud,
162
+ * or a soloed session reads as a broken mix.
163
+ */
164
+ const SoloBanner = memo(function SoloBanner({
165
+ previewIframeRef,
166
+ }: {
167
+ previewIframeRef: { current: HTMLIFrameElement | null };
168
+ }) {
169
+ const bannerText = useSoloBannerText(previewIframeRef);
170
+ const clearSolo = usePlayerStore.getState().clearSolo;
171
+ if (bannerText === null) return null;
172
+ return (
173
+ <div
174
+ role="status"
175
+ className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
176
+ >
177
+ <span>
178
+ Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
179
+ export is not affected
180
+ </span>
181
+ <button
182
+ type="button"
183
+ onClick={() => clearSolo()}
184
+ className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
185
+ >
186
+ Clear
187
+ </button>
188
+ </div>
189
+ );
190
+ });
191
+
158
192
  interface PlayerControlsProps {
159
193
  onTogglePlay: () => void;
160
194
  onSeek: (time: number) => void;
161
195
  disabled?: boolean;
162
196
  isFullscreen?: boolean;
163
197
  onToggleFullscreen?: () => void;
198
+ /** Needed to read the soloed clips' labels out of the preview document. */
199
+ previewIframeRef?: { current: HTMLIFrameElement | null };
164
200
  }
165
201
 
166
202
  export const PlayerControls = memo(function PlayerControls({
@@ -169,6 +205,7 @@ export const PlayerControls = memo(function PlayerControls({
169
205
  disabled = false,
170
206
  isFullscreen = false,
171
207
  onToggleFullscreen,
208
+ previewIframeRef,
172
209
  }: PlayerControlsProps) {
173
210
  const isPlaying = usePlayerStore((s) => s.isPlaying);
174
211
  const duration = usePlayerStore((s) => s.duration);
@@ -220,73 +257,80 @@ export const PlayerControls = memo(function PlayerControls({
220
257
  });
221
258
 
222
259
  return (
223
- <div
224
- className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
225
- aria-disabled={disabled || undefined}
226
- style={{
227
- paddingBottom: "env(safe-area-inset-bottom)",
228
- }}
229
- >
230
- <Tooltip
231
- label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
260
+ <div>
261
+ {previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
262
+ <div
263
+ className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
264
+ aria-disabled={disabled || undefined}
265
+ style={{
266
+ paddingBottom: "env(safe-area-inset-bottom)",
267
+ }}
232
268
  >
233
- <button
234
- type="button"
235
- onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
236
- disabled={disabled}
237
- className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
269
+ <Tooltip
270
+ label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
238
271
  >
239
- <span ref={timeDisplayRef}>{formatTime(0)}</span>
240
- {timeDisplayMode === "time" ? (
241
- <>
242
- <span className="mx-0.5 text-neutral-700">/</span>
243
- <span className="text-neutral-600">{formatTime(duration)}</span>
244
- </>
245
- ) : null}
246
- </button>
247
- </Tooltip>
272
+ <button
273
+ type="button"
274
+ onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
275
+ disabled={disabled}
276
+ className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
277
+ >
278
+ <span ref={timeDisplayRef}>{formatTime(0)}</span>
279
+ {timeDisplayMode === "time" ? (
280
+ <>
281
+ <span className="mx-0.5 text-neutral-700">/</span>
282
+ <span className="text-neutral-600">{formatTime(duration)}</span>
283
+ </>
284
+ ) : null}
285
+ </button>
286
+ </Tooltip>
248
287
 
249
- <Tooltip label={isPlaying ? "Pause" : "Play"}>
250
- <button
251
- type="button"
252
- aria-label={isPlaying ? "Pause" : "Play"}
253
- onClick={() => {
254
- trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
255
- onTogglePlay();
256
- }}
257
- disabled={controlsDisabled}
258
- className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
259
- >
260
- <PlayPauseMorphIcon playing={isPlaying} />
261
- </button>
262
- </Tooltip>
288
+ <Tooltip label={isPlaying ? "Pause" : "Play"}>
289
+ <button
290
+ type="button"
291
+ aria-label={isPlaying ? "Pause" : "Play"}
292
+ onClick={() => {
293
+ trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
294
+ onTogglePlay();
295
+ }}
296
+ disabled={controlsDisabled}
297
+ className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
298
+ >
299
+ <PlayPauseMorphIcon playing={isPlaying} />
300
+ </button>
301
+ </Tooltip>
263
302
 
264
- <div className="flex min-w-0 items-center justify-self-end">
265
- <VolumeControl
266
- audioMuted={audioMuted}
267
- audioVolume={audioVolume}
268
- disabled={controlsDisabled}
269
- setAudioMuted={setAudioMuted}
270
- setAudioVolume={setAudioVolume}
271
- />
272
- <SpeedMenu
273
- playbackRate={playbackRate}
274
- setPlaybackRate={setPlaybackRate}
275
- disabled={disabled}
276
- />
277
- <LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
278
- {onToggleFullscreen && (
279
- <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
280
- )}
281
- <ShortcutsPanel
282
- disabled={disabled}
283
- duration={duration}
284
- inPoint={inPoint}
285
- outPoint={outPoint}
286
- setInPoint={setInPoint}
287
- setOutPoint={setOutPoint}
288
- onSeek={onSeek}
289
- />
303
+ <div className="flex min-w-0 items-center justify-self-end">
304
+ <VolumeControl
305
+ audioMuted={audioMuted}
306
+ audioVolume={audioVolume}
307
+ disabled={controlsDisabled}
308
+ setAudioMuted={setAudioMuted}
309
+ setAudioVolume={setAudioVolume}
310
+ />
311
+ <SpeedMenu
312
+ playbackRate={playbackRate}
313
+ setPlaybackRate={setPlaybackRate}
314
+ disabled={disabled}
315
+ />
316
+ <LoopButton
317
+ loopEnabled={loopEnabled}
318
+ disabled={disabled}
319
+ setLoopEnabled={setLoopEnabled}
320
+ />
321
+ {onToggleFullscreen && (
322
+ <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
323
+ )}
324
+ <ShortcutsPanel
325
+ disabled={disabled}
326
+ duration={duration}
327
+ inPoint={inPoint}
328
+ outPoint={outPoint}
329
+ setInPoint={setInPoint}
330
+ setOutPoint={setOutPoint}
331
+ onSeek={onSeek}
332
+ />
333
+ </div>
290
334
  </div>
291
335
  </div>
292
336
  );
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useId, memo } from "react";
1
+ import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
2
2
  import { formatTime, frameToSeconds } from "../lib/time";
3
3
  import { Tooltip } from "../../components/ui";
4
4
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
@@ -19,7 +19,7 @@ const SHORTCUT_SECTIONS = [
19
19
  ],
20
20
  },
21
21
  {
22
- title: "Keyframes",
22
+ title: "Keyframes (when an element is selected)",
23
23
  hints: [
24
24
  { key: "K", label: "Add keyframe at playhead" },
25
25
  { key: "Del", label: "Delete selected keyframe" },
@@ -37,9 +37,10 @@ const SHORTCUT_SECTIONS = [
37
37
  { key: "⌘V", label: "Paste element" },
38
38
  { key: "⌘X", label: "Cut element" },
39
39
  { key: "S", label: "Split clip at playhead" },
40
+ { key: "⇧Click", label: "Razor tool: split all tracks" },
40
41
  { key: "⌘G", label: "Group elements" },
41
42
  { key: "⌘⇧G", label: "Ungroup" },
42
- { key: "Del", label: "Delete selected element" },
43
+ { key: "Del", label: "Delete selected element (no keyframe selected)" },
43
44
  ],
44
45
  },
45
46
  {
@@ -112,6 +113,20 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
112
113
  const shortcutsPanelId = useId();
113
114
  const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
114
115
  const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
116
+ const panelBodyRef = useRef<HTMLDivElement>(null);
117
+ const triggerRef = useRef<HTMLButtonElement>(null);
118
+
119
+ // Move focus into the panel on open so keyboard users can scroll and read
120
+ // it; hand focus back to the trigger on close.
121
+ // eslint-disable-next-line no-restricted-syntax
122
+ useEffect(() => {
123
+ if (!showShortcuts) return;
124
+ const trigger = triggerRef.current;
125
+ panelBodyRef.current?.focus();
126
+ return () => {
127
+ trigger?.focus();
128
+ };
129
+ }, [showShortcuts]);
115
130
 
116
131
  const commitJumpFrame = useCallback(() => {
117
132
  if (disabled) return;
@@ -141,6 +156,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
141
156
  <div ref={shortcutsPanelRef} className="relative flex-shrink-0">
142
157
  <Tooltip label="Shortcuts and tools">
143
158
  <button
159
+ ref={triggerRef}
144
160
  type="button"
145
161
  onClick={() => setShowShortcuts((v) => !v)}
146
162
  className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
@@ -169,11 +185,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
169
185
  {showShortcuts && (
170
186
  <div
171
187
  id={shortcutsPanelId}
188
+ ref={panelBodyRef}
189
+ tabIndex={-1}
172
190
  role="dialog"
191
+ aria-label="Keyboard shortcuts and tools"
173
192
  // Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
174
193
  // not trapped and the rest of the editor stays operable, so claiming
175
194
  // modality would make assistive tech treat the whole app as inert.
176
- className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
195
+ className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-none"
177
196
  style={{
178
197
  background: "#161618",
179
198
  border: "1px solid rgba(255,255,255,0.08)",
@@ -1,6 +1,7 @@
1
- import { useState, useRef, useEffect, memo } from "react";
1
+ import { useState, useCallback, memo } from "react";
2
2
  import { trackStudioEvent } from "../../utils/studioTelemetry";
3
3
  import { Tooltip } from "../../components/ui";
4
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
5
 
5
6
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
6
7
 
@@ -16,23 +17,10 @@ export const SpeedMenu = memo(function SpeedMenu({
16
17
  disabled,
17
18
  }: SpeedMenuProps) {
18
19
  const [showSpeedMenu, setShowSpeedMenu] = useState(false);
19
- const speedMenuContainerRef = useRef<HTMLDivElement>(null);
20
-
21
- useEffect(() => {
22
- if (!showSpeedMenu) return;
23
- const handleMouseDown = (e: MouseEvent) => {
24
- if (
25
- speedMenuContainerRef.current &&
26
- !speedMenuContainerRef.current.contains(e.target as Node)
27
- ) {
28
- setShowSpeedMenu(false);
29
- }
30
- };
31
- document.addEventListener("mousedown", handleMouseDown);
32
- return () => {
33
- document.removeEventListener("mousedown", handleMouseDown);
34
- };
35
- }, [showSpeedMenu]);
20
+ const closeMenu = useCallback(() => setShowSpeedMenu(false), []);
21
+ // Ref on the container (trigger + menu) so trigger clicks toggle instead of
22
+ // close-then-reopen; Escape also dismisses.
23
+ const speedMenuContainerRef = useContextMenuDismiss(closeMenu);
36
24
 
37
25
  return (
38
26
  <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
@@ -41,6 +29,9 @@ export const SpeedMenu = memo(function SpeedMenu({
41
29
  type="button"
42
30
  onClick={() => setShowSpeedMenu((v) => !v)}
43
31
  disabled={disabled}
32
+ aria-haspopup="menu"
33
+ aria-expanded={showSpeedMenu}
34
+ aria-label="Playback speed"
44
35
  className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
45
36
  >
46
37
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -48,33 +39,34 @@ export const SpeedMenu = memo(function SpeedMenu({
48
39
  </Tooltip>
49
40
  {showSpeedMenu && (
50
41
  <div
42
+ role="menu"
43
+ aria-label="Playback speed options"
51
44
  className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
52
45
  style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
53
46
  >
54
- {SPEED_OPTIONS.map((rate) => (
55
- <button
56
- key={rate}
57
- onClick={() => {
58
- trackStudioEvent("playback", { action: "speed_change", rate });
59
- setPlaybackRate(rate);
60
- setShowSpeedMenu(false);
61
- }}
62
- className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
63
- style={{
64
- color: rate === playbackRate ? "#FAFAFA" : "#71717A",
65
- background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
66
- }}
67
- onMouseEnter={(e) => {
68
- if (rate !== playbackRate)
69
- e.currentTarget.style.background = "rgba(255,255,255,0.04)";
70
- }}
71
- onMouseLeave={(e) => {
72
- if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
73
- }}
74
- >
75
- {rate}x
76
- </button>
77
- ))}
47
+ {SPEED_OPTIONS.map((rate) => {
48
+ const isCurrent = rate === playbackRate;
49
+ return (
50
+ <button
51
+ key={rate}
52
+ type="button"
53
+ role="menuitemradio"
54
+ aria-checked={isCurrent}
55
+ onClick={() => {
56
+ trackStudioEvent("playback", { action: "speed_change", rate });
57
+ setPlaybackRate(rate);
58
+ setShowSpeedMenu(false);
59
+ }}
60
+ className={`block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors outline-none focus-visible:bg-white/[0.04] ${
61
+ isCurrent
62
+ ? "text-neutral-50 bg-white/[0.06]"
63
+ : "text-neutral-500 hover:bg-white/[0.04]"
64
+ }`}
65
+ >
66
+ {rate}x
67
+ </button>
68
+ );
69
+ })}
78
70
  </div>
79
71
  )}
80
72
  </div>
@@ -392,7 +392,7 @@ describe("Timeline provider boundary", () => {
392
392
  const row = button.parentElement?.parentElement;
393
393
  // Row children: [TimelineTrackHeader (sticky column), time-mapped content].
394
394
  const trackContent = row?.children.item(1);
395
- expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
395
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
396
396
  expect(trackContent).toBeInstanceOf(HTMLElement);
397
397
  if (!(trackContent instanceof HTMLElement)) {
398
398
  throw new Error("Expected track content element");
@@ -556,11 +556,11 @@ describe("Timeline provider boundary", () => {
556
556
  // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
557
557
  // lives in the left column. clip-2 has no keyframes so it never shows one.
558
558
  const collapseButton = host.querySelector<HTMLButtonElement>(
559
- 'button[aria-label="Collapse clip-1 keyframes"]',
559
+ 'button[aria-label="Hide clip-1 lanes"]',
560
560
  );
561
561
  expect(collapseButton).not.toBeNull();
562
- expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
563
- expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
562
+ expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull();
563
+ expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();
564
564
 
565
565
  const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
566
566
  const row = clip?.parentElement?.parentElement;
@@ -571,7 +571,7 @@ describe("Timeline provider boundary", () => {
571
571
  expectTrackExpansion(row, [], TRACK_H);
572
572
 
573
573
  const expandButton = host.querySelector<HTMLButtonElement>(
574
- 'button[aria-label="Expand clip-1 keyframes"]',
574
+ 'button[aria-label="Show clip-1 lanes"]',
575
575
  );
576
576
  expect(expandButton).not.toBeNull();
577
577
  act(() => expandButton?.click());
@@ -603,8 +603,8 @@ describe("Timeline provider boundary", () => {
603
603
  const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
604
604
  ?.parentElement;
605
605
  // A row of several clips is named for the track, so the caret is too.
606
- const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]');
607
- expect(caret()?.getAttribute("aria-label")).toBe("Expand Track 1 keyframes");
606
+ const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
607
+ expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");
608
608
 
609
609
  act(() => caret()?.click());
610
610
  // One shared volume row, and BOTH clips hold it open.
@@ -647,7 +647,7 @@ describe("Timeline provider boundary", () => {
647
647
  });
648
648
  const root = createRoot(host);
649
649
  act(() => root.render(React.createElement(Timeline)));
650
- act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]')?.click());
650
+ act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
651
651
 
652
652
  const before = [...host.querySelectorAll(".hf-automation-lane")];
653
653
  expect(before).toHaveLength(2);
@@ -1,6 +1,5 @@
1
1
  import { useRef, useMemo, useCallback, useState, memo } from "react";
2
- import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
- import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
2
+ import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
4
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
4
  import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
6
5
  import { defaultTimelineTheme } from "./timelineTheme";
@@ -39,7 +38,6 @@ import {
39
38
  import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
40
39
  import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
41
40
  import { useTimelineTicks } from "./useTimelineTicks";
42
- import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
43
41
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
44
42
  import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
45
43
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
@@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
109
107
  useMusicBeatAnalysis();
110
108
  const rawElements = usePlayerStore((s) => s.elements);
111
109
  const expandedElements = useExpandedTimelineElements();
112
- const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
113
- const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
114
- const beatEdits = usePlayerStore((s) => s.beatEdits);
115
- const adjustedBeatAnalysis = useMemo(
116
- () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
117
- [beatAnalysis, musicElement, beatEdits],
118
- );
110
+ const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
119
111
  const duration = usePlayerStore((s) => s.duration);
120
112
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
121
113
  const timelineReady = usePlayerStore((s) => s.timelineReady);
@@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
158
150
  laneCounts,
159
151
  rowGeometry,
160
152
  rowGeometryRef,
153
+ groups,
154
+ trackGroupOf,
161
155
  } = useTimelineTrackLayout(
162
156
  expandedElements,
163
157
  gsapAnimations,
@@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
289
283
  laneCounts,
290
284
  selectedElementId,
291
285
  selectedElementIds,
286
+ groups,
287
+ trackGroupOf,
292
288
  gsapAnimations,
293
289
  elements: expandedElements,
294
290
  pixelsPerSecond: pps,
@@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
509
505
  trackOrder={trackOrder}
510
506
  tracks={tracks}
511
507
  trackStyles={trackStyles}
508
+ groups={groups}
512
509
  laneCounts={laneCounts}
513
510
  selectedElementId={selectedElementId}
514
511
  selectedElementIds={selectedElementIds}
@@ -0,0 +1,81 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
6
+ import { TimelineFxButton } from "./TimelineFxButton.js";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
11
+ return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
12
+ }
13
+
14
+ function mount(node: React.ReactElement) {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ act(() => createRoot(host).render(node));
18
+ return host;
19
+ }
20
+
21
+ afterEach(() => {
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ describe("TimelineFxButton", () => {
26
+ it("reads FX with no count when the chain is empty", () => {
27
+ const host = mount(
28
+ <TimelineFxButton
29
+ variant="chain"
30
+ fxChainRaw={undefined}
31
+ onChainChange={vi.fn()}
32
+ onOpenRack={vi.fn()}
33
+ />,
34
+ );
35
+ expect(byTextButton(host, "FX")?.textContent).toBe("FX");
36
+ });
37
+
38
+ it("counts only enabled nodes", () => {
39
+ const chain = {
40
+ version: 1 as const,
41
+ nodes: [
42
+ { type: "peaking", params: {}, enabled: true },
43
+ { type: "gain", params: {}, enabled: false },
44
+ ],
45
+ };
46
+ const host = mount(
47
+ <TimelineFxButton
48
+ variant="chain"
49
+ fxChainRaw={serializeAudioFxChain(chain)}
50
+ onChainChange={vi.fn()}
51
+ onOpenRack={vi.fn()}
52
+ />,
53
+ );
54
+ expect(byTextButton(host, "FX 1")).toBeDefined();
55
+ });
56
+
57
+ it("opens the popover on click, anchored off the button", () => {
58
+ const host = mount(
59
+ <TimelineFxButton
60
+ variant="chain"
61
+ fxChainRaw={undefined}
62
+ onChainChange={vi.fn()}
63
+ onOpenRack={vi.fn()}
64
+ />,
65
+ );
66
+ expect(host.querySelector('[role="dialog"]')).toBeNull();
67
+ act(() => byTextButton(host, "FX")?.click());
68
+ expect(document.querySelector('[role="dialog"]')).toBeTruthy();
69
+ });
70
+
71
+ it("group-pointer variant offers Group instead of a popover", () => {
72
+ const onGroupClips = vi.fn();
73
+ const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
74
+ act(() => byTextButton(host, "FX")?.click());
75
+ const groupButton = document.body.querySelectorAll("button");
76
+ const group = Array.from(groupButton).find((b) => b.textContent === "Group");
77
+ expect(group).toBeDefined();
78
+ act(() => group?.click());
79
+ expect(onGroupClips).toHaveBeenCalledTimes(1);
80
+ });
81
+ });