@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  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 +2 -0
  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 +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  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 +7 -7
  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 +5 -0
  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.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -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>
@@ -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
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The FX entry point from the timeline (C1) — a track or group header button
3
+ * that opens the same preset shelf the property panel's rack uses, without
4
+ * requiring a trip through the panel first.
5
+ *
6
+ * Rendered on group rows and on track rows holding exactly one audio clip
7
+ * (see `plans/audio-mixer-groups.md` §1.6 and the execution runbook's C1 step
8
+ * — a track with several ungrouped clips has no single chain to point at, so
9
+ * it gets the grouping pointer instead of the popover).
10
+ */
11
+
12
+ import { useRef, useState } from "react";
13
+ import { createPortal } from "react-dom";
14
+ import {
15
+ enabledAudioFxNodes,
16
+ parseAudioFxChain,
17
+ type HfAudioFxChain,
18
+ } from "@hyperframes/core/audio-fx";
19
+ import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
20
+ import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
21
+
22
+ function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
23
+ if (!raw) return { version: 1, nodes: [] };
24
+ try {
25
+ return parseAudioFxChain(raw);
26
+ } catch {
27
+ return { version: 1, nodes: [] };
28
+ }
29
+ }
30
+
31
+ interface TimelineFxButtonChainProps {
32
+ variant?: "chain";
33
+ trackKind?: HfAudioNameKind;
34
+ onOpenRack: () => void;
35
+ fxChainRaw: string | undefined;
36
+ onChainChange: (next: HfAudioFxChain) => void;
37
+ onChainPreview?: (next: HfAudioFxChain) => void;
38
+ onAuditionTransport?: (on: boolean) => void;
39
+ }
40
+
41
+ interface TimelineFxButtonGroupPointerProps {
42
+ variant: "group-pointer";
43
+ onGroupClips: () => void;
44
+ }
45
+
46
+ type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
47
+
48
+ export function TimelineFxButton(props: TimelineFxButtonProps) {
49
+ const [open, setOpen] = useState(false);
50
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
51
+ const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
52
+
53
+ const openAt = () => {
54
+ setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
55
+ setOpen(true);
56
+ };
57
+
58
+ if (props.variant === "group-pointer") {
59
+ return (
60
+ <div className="relative shrink-0">
61
+ <button
62
+ type="button"
63
+ tabIndex={-1}
64
+ ref={buttonRef}
65
+ aria-label="Effects — group these clips first"
66
+ title="Group these clips to add effects to all of them"
67
+ className="flex h-6 items-center justify-center rounded border-0 bg-transparent px-1 text-[10px] font-semibold text-white/35 hover:text-white/75"
68
+ onPointerDown={(event) => event.stopPropagation()}
69
+ onClick={(event) => {
70
+ event.stopPropagation();
71
+ openAt();
72
+ }}
73
+ >
74
+ FX
75
+ </button>
76
+ {open &&
77
+ anchorRect &&
78
+ createPortal(
79
+ <div
80
+ role="dialog"
81
+ aria-label="Group these clips to add effects"
82
+ className="z-50 w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
83
+ style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
84
+ onPointerDown={(event) => event.stopPropagation()}
85
+ >
86
+ <p>Group these clips to add effects to all of them.</p>
87
+ <button
88
+ type="button"
89
+ className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
90
+ onClick={() => {
91
+ setOpen(false);
92
+ props.onGroupClips();
93
+ }}
94
+ >
95
+ Group
96
+ </button>
97
+ </div>,
98
+ document.body,
99
+ )}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ const chain = parseFxChainOrEmpty(props.fxChainRaw);
105
+ const nodeCount = enabledAudioFxNodes(chain).length;
106
+
107
+ return (
108
+ <div className="relative shrink-0">
109
+ <button
110
+ type="button"
111
+ tabIndex={-1}
112
+ ref={buttonRef}
113
+ aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
114
+ title="Effects"
115
+ className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
116
+ open || nodeCount > 0 ? "text-[#3CE6AC]" : "text-white/35 hover:text-white/75"
117
+ }`}
118
+ onPointerDown={(event) => event.stopPropagation()}
119
+ onClick={(event) => {
120
+ event.stopPropagation();
121
+ openAt();
122
+ }}
123
+ >
124
+ FX{nodeCount > 0 ? ` ${nodeCount}` : ""}
125
+ </button>
126
+ {open &&
127
+ anchorRect &&
128
+ createPortal(
129
+ <TimelineFxPopover
130
+ anchorRect={anchorRect}
131
+ chain={chain}
132
+ trackKind={props.trackKind}
133
+ onClose={() => setOpen(false)}
134
+ onChainChange={props.onChainChange}
135
+ onChainPreview={props.onChainPreview}
136
+ onAuditionTransport={props.onAuditionTransport}
137
+ onOpenRack={props.onOpenRack}
138
+ />,
139
+ document.body,
140
+ )}
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,146 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
7
+ import { defaultTimelineTheme } from "./timelineTheme";
8
+ import { groupLevels } from "../store/groupLevels";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ let container: HTMLDivElement;
13
+ let root: Root;
14
+
15
+ beforeEach(() => {
16
+ container = document.createElement("div");
17
+ document.body.appendChild(container);
18
+ root = createRoot(container);
19
+ groupLevels.notify(new Map());
20
+ });
21
+
22
+ afterEach(() => {
23
+ act(() => root.unmount());
24
+ container.remove();
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
29
+ const onVolumeChange = vi.fn();
30
+ const onVolumeCommit = vi.fn();
31
+ act(() => {
32
+ root.render(
33
+ <TimelineGroupBusStrip
34
+ groupId="vo"
35
+ volume={1}
36
+ memberLabels={["vo-1", "vo-2"]}
37
+ onVolumeChange={onVolumeChange}
38
+ onVolumeCommit={onVolumeCommit}
39
+ theme={defaultTimelineTheme}
40
+ {...overrides}
41
+ />,
42
+ );
43
+ });
44
+ return { onVolumeChange, onVolumeCommit };
45
+ }
46
+
47
+ function slider(): HTMLInputElement {
48
+ return container.querySelector('input[type="range"]') as HTMLInputElement;
49
+ }
50
+
51
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
52
+ window.HTMLInputElement.prototype,
53
+ "value",
54
+ )?.set;
55
+
56
+ /** React tracks the DOM's own value setter to detect real changes — a plain
57
+ * `input.value = ...` assignment is invisible to it, so onChange never
58
+ * fires. Go through the native setter, same as this codebase's other
59
+ * range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
60
+ function setSliderValue(input: HTMLInputElement, value: string) {
61
+ nativeInputValueSetter?.call(input, value);
62
+ input.dispatchEvent(new Event("input", { bubbles: true }));
63
+ }
64
+
65
+ describe("TimelineGroupBusStrip", () => {
66
+ it('renders "Holds …" from the member labels, comma-joined', () => {
67
+ renderStrip({ memberLabels: ["vo-1", "vo-2"] });
68
+ expect(container.textContent).toContain("Holds vo-1, vo-2");
69
+ });
70
+
71
+ it("falls back to a neutral line when a group has no members yet", () => {
72
+ renderStrip({ memberLabels: [] });
73
+ expect(container.textContent).toContain("Holds nothing yet");
74
+ });
75
+
76
+ it("live-writes on every drag tick, but only commits once on release", () => {
77
+ const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
78
+ const input = slider();
79
+
80
+ act(() => setSliderValue(input, "1.2"));
81
+ act(() => setSliderValue(input, "1.5"));
82
+ expect(onVolumeChange).toHaveBeenCalledTimes(2);
83
+ expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
84
+ expect(onVolumeCommit).not.toHaveBeenCalled();
85
+
86
+ act(() => {
87
+ input.value = "1.5";
88
+ input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
89
+ });
90
+ expect(onVolumeCommit).toHaveBeenCalledTimes(1);
91
+ expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
92
+ });
93
+
94
+ it("clamps the volume slider to the 0..2 range", () => {
95
+ const { onVolumeChange } = renderStrip();
96
+ const input = slider();
97
+ expect(input.min).toBe("0");
98
+ expect(input.max).toBe("2");
99
+
100
+ act(() => setSliderValue(input, "2"));
101
+ expect(onVolumeChange).toHaveBeenLastCalledWith(2);
102
+ });
103
+
104
+ it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
105
+ vi.useFakeTimers();
106
+ renderStrip();
107
+ act(() => {
108
+ groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
109
+ vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
110
+ });
111
+ expect(container.textContent).not.toContain("Too loud");
112
+ });
113
+
114
+ it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
115
+ vi.useFakeTimers();
116
+ renderStrip();
117
+
118
+ act(() => {
119
+ groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
120
+ vi.advanceTimersByTime(40);
121
+ });
122
+ expect(container.textContent).toContain("Too loud");
123
+
124
+ // Clipping stops — the warning must still hold for a couple seconds.
125
+ act(() => {
126
+ groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
127
+ vi.advanceTimersByTime(40);
128
+ });
129
+ expect(container.textContent).toContain("Too loud");
130
+
131
+ act(() => {
132
+ vi.advanceTimersByTime(2001);
133
+ });
134
+ expect(container.textContent).not.toContain("Too loud");
135
+ });
136
+
137
+ it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
138
+ vi.useFakeTimers();
139
+ renderStrip({ volume: 1.5 });
140
+ act(() => {
141
+ groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
142
+ vi.advanceTimersByTime(40);
143
+ });
144
+ expect(container.textContent ?? "").not.toMatch(/dB/i);
145
+ });
146
+ });