@hyperframes/studio 0.8.11 → 0.8.12

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 (154) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6431 -5515
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  90. package/src/player/components/TimelineFxButton.tsx +156 -31
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-DoW5v5eS.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -1,6 +1,3 @@
1
- import type { TimelineElement } from "../store/playerStore";
2
- import { usePlayerStore } from "../store/playerStore";
3
- import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
4
1
  import {
5
2
  HF_AUDIO_FX_ATTR,
6
3
  serializeAudioFxChain,
@@ -11,28 +8,43 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
11
8
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
12
9
  import { TimelineTrackRow } from "./TimelineTrackRow";
13
10
  import { TimelineGroupHeader } from "./TimelineGroupHeader";
14
- import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
15
11
  import { groupAutomationLanes } from "./automationLaneData";
16
- import { LABEL_COL_W } from "./timelineLayout";
17
- import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
12
+ import { groupAutomationElement } from "./groupAutomationElement";
13
+ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
14
+ import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
15
+ import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
16
+ import type { UseAutomationLanesResult } from "./useAutomationLanes";
17
+ import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
18
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
18
19
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
19
20
 
21
+ /** Accent rail on a group-owned lane — the same green the member rail uses, so
22
+ * "this belongs to the group" reads the same in both places (groups doc §5). */
23
+ const GROUP_LANE_ACCENT = "#3CE6AC";
24
+
20
25
  interface TimelineGroupRowProps {
21
26
  index: number;
22
27
  rowKey: number;
23
28
  group: TimelineTrackGroupInfo;
24
29
  logicalRow: TimelineLogicalRow;
25
- tracks: readonly (readonly [number, readonly TimelineElement[]])[];
26
30
  top: number;
27
31
  height: number;
28
32
  virtualized: boolean;
29
33
  contentOrigin: number;
30
34
  theme: TimelineTheme;
31
35
  rovingTargetId?: string | null;
32
- expandedGroupIds: ReadonlySet<string>;
36
+ collapsedGroupIds: ReadonlySet<string>;
33
37
  expandedLaneOwnerIds: ReadonlySet<string>;
34
38
  toggleGroupExpanded: (id: string) => void;
35
39
  toggleLaneOwnerExpanded: (id: string) => void;
40
+ lanes: UseAutomationLanesResult;
41
+ pps: number;
42
+ currentTime: number;
43
+ /** A group's lanes are in composition time (§1.3), so this is their span. */
44
+ compositionDuration: number;
45
+ beatTimes?: readonly number[];
46
+ contentGutter: number;
47
+ trackContentWidth: number;
36
48
  }
37
49
 
38
50
  /** A group's own row: the accessible shell (shared with track rows) plus the group header. */
@@ -41,31 +53,45 @@ export function TimelineGroupRow({
41
53
  rowKey,
42
54
  group,
43
55
  logicalRow,
44
- tracks,
45
56
  top,
46
57
  height,
47
58
  virtualized,
48
59
  contentOrigin,
49
60
  theme,
50
61
  rovingTargetId = null,
51
- expandedGroupIds,
62
+ collapsedGroupIds,
52
63
  expandedLaneOwnerIds,
53
64
  toggleGroupExpanded,
54
65
  toggleLaneOwnerExpanded,
66
+ lanes,
67
+ pps,
68
+ currentTime,
69
+ compositionDuration,
70
+ beatTimes,
71
+ contentGutter,
72
+ trackContentWidth,
55
73
  }: TimelineGroupRowProps) {
56
- const memberElements = group.memberTracks.flatMap(
57
- (track) => tracks.find(([t]) => t === track)?.[1] ?? [],
58
- );
59
- const memberLabels = group.memberTracks.map((track, i) => {
60
- const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
61
- return owner?.label ?? owner?.id ?? `track ${i + 1}`;
62
- });
74
+ // From the group, NOT from `tracks`: a collapsed group emits no member rows
75
+ // into the display list, and every one of these reads silently degraded to
76
+ // empty in that (default) state — the lane count
77
+ // read 0, and the bus strip fell back to "track 1", "track 2".
78
+ const memberElements = group.memberElements;
79
+ // The group wearing a clip's shape so the lane machinery can render it — see
80
+ // `groupAutomationElement` for why that beats a second, parallel lane path.
81
+ const groupElement = groupAutomationElement(group, compositionDuration);
82
+ // The binder writes through the dom-edit selection, so a group lane is
83
+ // editable exactly when the group is the selected element — which clicking
84
+ // its name in the header does.
85
+ const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
86
+ const isGroupSelected = domSelection?.id === group.id;
63
87
  const isLaneOpen = expandedLaneOwnerIds.has(group.id);
64
- const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
88
+ // Optional, like every sibling row: Timeline renders outside the edit
89
+ // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
90
+ // hook took the whole timeline down with it the moment a group existed —
91
+ // not just this row.
92
+ const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
93
+ useTimelineEditContextOptional();
65
94
  const domEditActions = useDomEditActionsContextOptional();
66
- const soloed = usePlayerStore((s) => s.soloed);
67
- const toggleSolo = usePlayerStore((s) => s.toggleSolo);
68
- const memberIds = memberElements.map((el) => el.key ?? el.id);
69
95
  const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
70
96
  const value = next.nodes.length ? serializeAudioFxChain(next) : null;
71
97
  if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
@@ -95,51 +121,84 @@ export function TimelineGroupRow({
95
121
  borderColor={theme.rowBorder}
96
122
  rovingTargetId={rovingTargetId}
97
123
  >
98
- <TimelineGroupHeader
99
- label={group.label}
100
- memberCount={group.memberTracks.length}
101
- isExpanded={expandedGroupIds.has(group.id)}
102
- onToggleExpanded={() => toggleGroupExpanded(group.id)}
103
- laneCount={groupAutomationLanes(memberElements).length}
104
- isLaneOpen={isLaneOpen}
105
- onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
106
- hidden={group.hidden}
107
- onToggleHidden={() =>
108
- onSetAudioGroupAttributeQuiet?.(
109
- group.id,
110
- "data-hidden",
111
- group.hidden ? null : "",
112
- group.hidden ? "Unmute group" : `Mute group ${group.label}`,
113
- )
114
- }
115
- isSoloed={soloed.has(group.id)}
116
- isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
117
- onToggleSolo={(options) => toggleSolo(group.id, options)}
118
- fxChain={group.fxChain}
119
- onFxChainChange={(next) => writeGroupFxChain(next, false)}
120
- onFxChainPreview={(next) => writeGroupFxChain(next, true)}
121
- onOpenFxRack={openGroupFxRack}
122
- columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
123
- theme={theme}
124
- />
125
- {isLaneOpen && (
126
- <TimelineGroupBusStrip
127
- groupId={group.id}
128
- volume={group.volume}
129
- memberLabels={memberLabels}
130
- onVolumeChange={(value) =>
131
- onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
132
- }
133
- onVolumeCommit={(value) =>
134
- onSetAudioGroupAttributeQuiet?.(
135
- group.id,
136
- "data-volume",
137
- String(value),
138
- "Set group volume",
139
- )
140
- }
124
+ {/* Header and its lane labels in ONE sticky column — the shape
125
+ `TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
126
+ lane rows absolutely positioned beneath it, the whole thing pinned.
127
+ The labels used to be SIBLINGS of the header, so `absolute left-0`
128
+ resolved against the ROW, and the row is what scrolls horizontally —
129
+ they slid away with the canvas. Sticky lives here rather than on the
130
+ header, which keeps its own box inside; a zero-width sticky wrapper
131
+ around the labels alone does not work either, because as a flex item
132
+ after the header it starts at x = columnWidth, i.e. inside the lanes. */}
133
+ <div
134
+ className="sticky left-0 z-[12] shrink-0"
135
+ style={{ width: contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin }}
136
+ >
137
+ <TimelineGroupHeader
138
+ label={group.label}
139
+ memberCount={group.memberTracks.length}
140
+ isExpanded={!collapsedGroupIds.has(group.id)}
141
+ onToggleExpanded={() => toggleGroupExpanded(group.id)}
142
+ // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
143
+ // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
144
+ // counting the members' here made the group advertise curves it does
145
+ // not own and cannot show.
146
+ laneCount={groupAutomationLanes([groupElement]).length}
147
+ isLaneOpen={isLaneOpen}
148
+ onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
149
+ fxChain={group.fxChain}
150
+ onFxChainChange={(next) => writeGroupFxChain(next, false)}
151
+ onFxChainPreview={(next) => writeGroupFxChain(next, true)}
152
+ auditionSpans={memberElements}
153
+ onOpenFxRack={openGroupFxRack}
154
+ // Same width as every other row's header. The group row needs a real
155
+ // label column, but it gets one by turning `labelMode` on for the whole
156
+ // timeline (see Timeline.tsx) rather than by overhanging alone — an
157
+ // overhanging header paints opaquely across the rest of its row and
158
+ // stays pinned there through horizontal scroll.
159
+ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
141
160
  theme={theme}
142
161
  />
162
+ {/* The group's OWN curves, under the strip. Selected-gated exactly like a
163
+ clip's: the binder writes through the dom-edit selection, so a lane is
164
+ editable once the group is selected — which clicking its name does. */}
165
+ {/* The label column for those lanes, on the accent rail — inside the
166
+ sticky column above, so they pin with the header. */}
167
+ {isLaneOpen && (
168
+ <TimelineGroupLaneLabels
169
+ groupElement={groupElement}
170
+ groupLabel={group.label}
171
+ top={TRACK_H}
172
+ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
173
+ gutterBackground={theme.gutterBackground}
174
+ accentColor={GROUP_LANE_ACCENT}
175
+ />
176
+ )}
177
+ </div>
178
+ {isLaneOpen && (
179
+ // The same offset content cell a track row wraps its lanes in — the
180
+ // slot positions absolutely, so mounted straight on the row it resolved
181
+ // against the row instead and drew the envelope across the label gutter
182
+ // from x=0.
183
+ <div
184
+ role="gridcell"
185
+ aria-colindex={2}
186
+ style={{ width: trackContentWidth, marginLeft: contentGutter }}
187
+ className="relative"
188
+ >
189
+ <TimelineAutomationLaneSlot
190
+ elements={[groupElement]}
191
+ isSelected={() => isGroupSelected}
192
+ lanes={lanes}
193
+ pps={pps}
194
+ // Below the strip, which sits directly under the header row.
195
+ laneCount={0}
196
+ topOffset={TRACK_H}
197
+ accentColor={GROUP_LANE_ACCENT}
198
+ currentTime={currentTime}
199
+ beatTimes={beatTimes}
200
+ />
201
+ </div>
143
202
  )}
144
203
  </TimelineTrackRow>
145
204
  );
@@ -118,7 +118,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
118
118
  selectedElementId: null,
119
119
  selectedElementIds: next.selectedElementIds ?? new Set(),
120
120
  expandedClipIds: new Set(next.expandedClipIds ?? []),
121
- expandedGroupIds: new Set(),
121
+ collapsedGroupIds: new Set(),
122
122
  expandedLaneOwnerIds: new Set(),
123
123
  groups: [],
124
124
  trackGroupOf: new Map(),
@@ -203,8 +203,8 @@ describe("TimelineLanes track numbering", () => {
203
203
  const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
204
204
  act(() => second?.click());
205
205
 
206
- // Third argument is the display row the button announced, so the undo entry
207
- // records the same row the user just read off it.
206
+ // Both, and they are different numbers: the real key acts, the display row
207
+ // is what the undo-history label must announce (see `onToggleTrackHidden`).
208
208
  expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
209
209
  act(() => view.root.unmount());
210
210
  });
@@ -1,4 +1,4 @@
1
- import { Fragment, useId } from "react";
1
+ import { Fragment, useId, useMemo } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -9,6 +9,7 @@ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelecti
9
9
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
10
10
  import { TimelineGroupRow } from "./TimelineGroupRow";
11
11
  import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
12
+ import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure";
12
13
  import {
13
14
  isTrackRowExpanded,
14
15
  resolveTrackKeyframeClip,
@@ -22,7 +23,6 @@ import { usePlayerStore } from "../store/playerStore";
22
23
  import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
23
24
  import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
24
25
  import type { TimelineLanesProps } from "./timelineLaneProps";
25
- import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
26
26
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
27
27
  import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
28
28
  import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
@@ -100,29 +100,24 @@ export function TimelineLanes({
100
100
  // from resolving into a second timeline that renders the same logical rows.
101
101
  const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
102
102
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
103
- const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
103
+ const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
104
104
  useTimelineGroupDisclosure();
105
105
  const automationLanes = useAutomationLanes();
106
+ // A group's automation clock is COMPOSITION time (groups doc §1.3), so its
107
+ // synthetic lane element spans the whole composition rather than a clip.
108
+ const compositionDuration = usePlayerStore((s) => s.duration);
106
109
  useAutomationSelectionKeyboard({ lanes: automationLanes });
107
- const expandClips = usePlayerStore((s) => s.expandClips);
108
- const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
109
- const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
110
110
  const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
111
- // The caret belongs to the ROW, so it opens and closes every clip on it at
112
- // once. Toggling only the active clip left the row's state depending on which
113
- // sibling happened to be selected: expand one, click another, and the row
114
- // collapsed under a caret that still pointed down.
115
- const toggleRowExpandedTracked = (keys: readonly string[]) => {
116
- const willExpand = !keys.some((key) => expandedClipIds.has(key));
117
- trackStudioKeyframeLaneExpand({ expanded: willExpand });
118
- if (willExpand) expandClips(keys);
119
- else for (const key of keys) setClipExpanded(key, false);
120
- };
121
- const toggleClipExpandedTracked = (key: string) => {
122
- const willExpand = !expandedClipIds.has(key);
123
- trackStudioKeyframeLaneExpand({ expanded: willExpand });
124
- toggleClipExpanded(key);
125
- };
111
+ // Which tracks are group MEMBERS, so their headers can render the level-2
112
+ // nesting their `aria-level` already reports.
113
+ const groupMemberTracks = useMemo(
114
+ () => new Set(groups.flatMap((group) => group.memberTracks)),
115
+ [groups],
116
+ );
117
+ const {
118
+ toggleRowExpanded: toggleRowExpandedTracked,
119
+ toggleClipExpanded: toggleClipExpandedTracked,
120
+ } = useTimelineClipDisclosure();
126
121
  const actorWindows = useTimelineMultiDragActorWindows(
127
122
  multiDragPreview,
128
123
  rowsVirtualized,
@@ -163,17 +158,23 @@ export function TimelineLanes({
163
158
  rowKey={rowKey}
164
159
  group={group}
165
160
  logicalRow={groupLogicalRow}
166
- tracks={tracks}
167
161
  top={rowGeometry.getRowTop(row)}
168
162
  height={rowGeometry.getRowHeight(row)}
169
163
  virtualized={rowsVirtualized}
170
164
  contentOrigin={contentOrigin}
171
165
  theme={theme}
172
166
  rovingTargetId={keyboard.rovingTargetId}
173
- expandedGroupIds={expandedGroupIds}
167
+ collapsedGroupIds={collapsedGroupIds}
174
168
  expandedLaneOwnerIds={expandedLaneOwnerIds}
175
169
  toggleGroupExpanded={toggleGroupExpanded}
176
170
  toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
171
+ lanes={automationLanes}
172
+ pps={pps}
173
+ currentTime={currentTime}
174
+ compositionDuration={compositionDuration}
175
+ beatTimes={beatAnalysis?.beatTimes}
176
+ contentGutter={contentGutter}
177
+ trackContentWidth={trackContentWidth}
177
178
  />
178
179
  );
179
180
  }
@@ -290,6 +291,7 @@ export function TimelineLanes({
290
291
  currentTime={currentTime}
291
292
  isTrackHidden={isTrackHidden}
292
293
  isAudioTrack={isAudioTrack}
294
+ isGroupMember={groupMemberTracks.has(trackNum)}
293
295
  theme={theme}
294
296
  onToggleClipExpanded={() => {
295
297
  const keys = els.map(getTimelineElementIdentity);