@hyperframes/studio 0.8.10 → 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-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.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 +6440 -5514
  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 +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  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-C6m2nHiX.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
@@ -11,22 +11,34 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
11
11
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
12
12
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
13
13
  import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
14
+ import { runtimeAudioId } from "../lib/timelineElementHelpers";
14
15
  import { TimelineFxButton } from "./TimelineFxButton";
15
16
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
16
- import { groupAutomationLanes } from "./automationLaneData";
17
+ import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
17
18
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
18
19
  import { clipTimingStart } from "../../hooks/gsapShared";
19
- import { LayerDisclosureRow } from "./LayerDisclosureRow";
20
- import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
20
+ import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
21
+ import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
21
22
  import type { TimelineTheme } from "./timelineTheme";
22
- import {
23
- resolveLaneHeaderState,
24
- type KeyframeNavigationState,
25
- type TimelinePropertyLane,
26
- } from "./trackHeaderLaneState";
27
- import { valueReadout } from "./trackHeaderLaneValues";
28
23
  import { trackDisplaySuffix } from "./timelineTrackDisplay";
29
- import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
24
+ import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
25
+ import { useMemo } from "react";
26
+
27
+ /** Accent rail + inset marking a row as a group MEMBER, matching the level-2
28
+ * nesting its `aria-level` already reports. */
29
+ const GROUP_MEMBER_RAIL = "#3CE6AC59";
30
+ const GROUP_MEMBER_INDENT = 14;
31
+ /** A hair lighter than `gutterBackground`, so a member row reads as sitting
32
+ * INSIDE its group rather than beside it. Overlaid rather than hard-coded so
33
+ * it tracks whatever the theme's gutter is. */
34
+ const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
35
+
36
+ /** The gutter fill for a row, tinted when it belongs to a group. */
37
+ function gutterFill(base: string, isGroupMember: boolean): string {
38
+ return isGroupMember
39
+ ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
40
+ : base;
41
+ }
30
42
 
31
43
  interface TimelineTrackHeaderProps {
32
44
  /** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
@@ -58,6 +70,8 @@ interface TimelineTrackHeaderProps {
58
70
  currentTime: number;
59
71
  isTrackHidden: boolean;
60
72
  isAudioTrack: boolean;
73
+ /** This track is a member of an audio group — indents the row under its header. */
74
+ isGroupMember?: boolean;
61
75
  rovingTargetId?: string | null;
62
76
  theme: TimelineTheme;
63
77
  onToggleClipExpanded: () => void;
@@ -69,267 +83,6 @@ interface TimelineTrackHeaderProps {
69
83
  onSeek?: (time: number) => void;
70
84
  }
71
85
 
72
- // Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
73
- function PropertyGroupNavigation({
74
- navigation,
75
- label,
76
- expandedElement,
77
- onSeek,
78
- children,
79
- }: {
80
- navigation: KeyframeNavigationState;
81
- label: string;
82
- expandedElement: TimelineElement;
83
- onSeek?: (time: number) => void;
84
- children: React.ReactNode;
85
- }) {
86
- // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
87
- // target is met with a centered transparent ::before overlay instead of a
88
- // bigger box; focus-visible matches every other control in this header.
89
- const CHEVRON_BUTTON_CLASS =
90
- "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
91
- "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
92
- "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
93
- "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
94
- const seekTo = (keyframe: { percentage: number } | null) => {
95
- if (keyframe) {
96
- onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
97
- }
98
- };
99
- return (
100
- <span className="flex shrink-0 items-center gap-0.5">
101
- <button
102
- type="button"
103
- aria-label={`Previous ${label} keyframe`}
104
- disabled={!navigation.prevKeyframe}
105
- className={CHEVRON_BUTTON_CLASS}
106
- onClick={(event) => {
107
- event.stopPropagation();
108
- seekTo(navigation.prevKeyframe);
109
- }}
110
- >
111
-
112
- </button>
113
- {children}
114
- <button
115
- type="button"
116
- aria-label={`Next ${label} keyframe`}
117
- disabled={!navigation.nextKeyframe}
118
- className={CHEVRON_BUTTON_CLASS}
119
- onClick={(event) => {
120
- event.stopPropagation();
121
- seekTo(navigation.nextKeyframe);
122
- }}
123
- >
124
-
125
- </button>
126
- </span>
127
- );
128
- }
129
-
130
- function PropertyGroupHeaderRow({
131
- lanesId,
132
- lane,
133
- laneIndex,
134
- isLastLane,
135
- expandedElement,
136
- currentTime,
137
- clipPercentage,
138
- gutterBackground,
139
- columnWidth,
140
- onTogglePropertyGroupKeyframe,
141
- onSeek,
142
- rovingTargetId = null,
143
- }: {
144
- lanesId: string;
145
- lane: TimelinePropertyLane;
146
- laneIndex: number;
147
- isLastLane: boolean;
148
- expandedElement: TimelineElement;
149
- currentTime: number;
150
- clipPercentage: number;
151
- gutterBackground: string;
152
- columnWidth: number;
153
- onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
154
- onSeek?: (time: number) => void;
155
- rovingTargetId: string | null;
156
- }) {
157
- const elementId = expandedElement.key ?? expandedElement.id;
158
- const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
159
- lane,
160
- currentTime,
161
- clipPercentage,
162
- );
163
-
164
- return (
165
- <div
166
- id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
167
- data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
168
- data-timeline-element-id={elementId}
169
- tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
170
- data-property-group={lane.group}
171
- data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
172
- className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
173
- style={{
174
- top: getTimelineLaneTop(laneIndex),
175
- // The header column narrows to contentOrigin whenever that is under
176
- // LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
177
- // readout over the canvas, on top of the clips it is labelling.
178
- width: columnWidth,
179
- height: LANE_H,
180
- background: gutterBackground,
181
- }}
182
- >
183
- {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
184
- <span className="relative h-full w-3 shrink-0" aria-hidden="true">
185
- <span
186
- className="absolute left-1.5 top-0 w-px bg-white/15"
187
- style={{ height: isLastLane ? "50%" : "100%" }}
188
- />
189
- <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
190
- </span>
191
- <span className="w-[46px] shrink-0 truncate text-white" title={label}>
192
- {label}
193
- </span>
194
- <PropertyGroupNavigation
195
- navigation={navigation}
196
- label={label}
197
- expandedElement={expandedElement}
198
- onSeek={onSeek}
199
- >
200
- <button
201
- type="button"
202
- aria-pressed={!!navigation.currentKeyframe}
203
- aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
204
- title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
205
- // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
206
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
207
- onClick={(event) => {
208
- // Same as the disclosure caret and the eye: a control in the label
209
- // column owns its click, it does not also hit the track row behind it.
210
- event.stopPropagation();
211
- if (toggleTarget) {
212
- void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
213
- }
214
- }}
215
- >
216
- {navigation.currentKeyframe ? "◆" : "◇"}
217
- </button>
218
- </PropertyGroupNavigation>
219
- <span
220
- className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
221
- title={valueReadout(lane.group, values)}
222
- >
223
- {valueReadout(lane.group, values)}
224
- </span>
225
- </div>
226
- );
227
- }
228
-
229
- /**
230
- * One envelope's row in the label column.
231
- *
232
- * Named here rather than inside the lane, on the same tree connector the
233
- * keyframe rows use: an automation lane is a child of its clip exactly as a
234
- * property group is, and drawing its name over the envelope put the label on top
235
- * of the curve it describes and scrolled it away from its own row.
236
- */
237
- function AutomationLaneHeaderRow({
238
- target,
239
- label,
240
- name,
241
- param,
242
- top,
243
- isLastLane,
244
- gutterBackground,
245
- columnWidth,
246
- onRemove,
247
- }: {
248
- /** The lane the ACTIVE clip draws in this row, or null when it draws none —
249
- * the row belongs to the property, and a clip may be absent from it. */
250
- target: string | null;
251
- /** The whole thing on one line: the row's identity, its tooltip, and the
252
- * remove button's name. */
253
- label: string;
254
- /** What the effect is — "Peaking EQ 1.6 kHz". */
255
- name: string;
256
- /** Which knob the envelope drives. Empty when there is no second line to draw. */
257
- param: string;
258
- top: number;
259
- isLastLane: boolean;
260
- gutterBackground: string;
261
- columnWidth: number;
262
- onRemove?: (target: string) => void;
263
- }) {
264
- return (
265
- <div
266
- data-automation-lane-label={label}
267
- data-timeline-lane-top={top}
268
- className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
269
- style={{
270
- top,
271
- width: columnWidth,
272
- height: AUTOMATION_LANE_H,
273
- background: gutterBackground,
274
- }}
275
- >
276
- {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
277
- tick at the name's own height. */}
278
- <span className="relative h-full w-3 shrink-0" aria-hidden="true">
279
- <span
280
- className="absolute left-1.5 top-0 w-px bg-white/15"
281
- style={{ height: isLastLane ? "50%" : "100%" }}
282
- />
283
- <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
284
- </span>
285
- {/* Two lines: what the effect is, then which knob the envelope drives. On
286
- one line a band's own name was the first thing truncated in a column this
287
- narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
288
- bands apart. */}
289
- <span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
290
- <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
291
- {name}
292
- </span>
293
- {param ? (
294
- <span
295
- data-automation-lane-param=""
296
- className="truncate font-mono text-[9px] text-white/40"
297
- >
298
- {param}
299
- </span>
300
- ) : null}
301
- </span>
302
- {/* Beside the name it labels, because that is the only place an envelope is
303
- named at all: a carve writes its own lanes, and the FX panel's automate
304
- toggle can only reach a parameter it still lists — so without this an
305
- envelope could be created and never removed.
306
-
307
- Only for the clip the header is showing, since that is the only one a
308
- write can reach: a shared row's other envelopes belong to clips that
309
- are not selected, and a button that silently removed one of them (or
310
- none) would be worse than no button. */}
311
- {onRemove && target !== null && (
312
- <button
313
- type="button"
314
- aria-label={`Remove ${label} automation`}
315
- title={`Remove ${label} automation`}
316
- // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
317
- className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
318
- onPointerDown={(event) => event.stopPropagation()}
319
- onClick={(event) => {
320
- // A control in the label column owns its click; it does not also hit
321
- // the track row behind it and change the selection.
322
- event.stopPropagation();
323
- onRemove(target);
324
- }}
325
- >
326
- ×
327
- </button>
328
- )}
329
- </div>
330
- );
331
- }
332
-
333
86
  // fallow-ignore-next-line complexity
334
87
  export function TimelineTrackHeader({
335
88
  trackNumber,
@@ -345,6 +98,7 @@ export function TimelineTrackHeader({
345
98
  currentTime,
346
99
  isTrackHidden,
347
100
  isAudioTrack,
101
+ isGroupMember = false,
348
102
  theme,
349
103
  onToggleClipExpanded,
350
104
  onToggleTrackHidden,
@@ -369,26 +123,94 @@ export function TimelineTrackHeader({
369
123
  // order. `target` is the ACTIVE clip's lane in that row, which is the only one
370
124
  // the remove button can write to; null when the row belongs to its siblings.
371
125
  const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
372
- const automationRows = groupAutomationLanes(trackElements).map((group) => ({
373
- key: group.key,
374
- label: group.key,
375
- name: group.name,
376
- param: group.param,
377
- target:
378
- group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
379
- .target ?? null,
380
- }));
126
+ const groupOwner = useMemo(
127
+ () => trackElements.find((element) => element.audioGroup)?.audioGroup,
128
+ [trackElements],
129
+ );
130
+ const groupLabelForNote = useMemo(
131
+ () => trackElements.find((element) => element.audioGroupLabel)?.audioGroupLabel,
132
+ [trackElements],
133
+ );
134
+ const groupAutomationRaw = useMemo(
135
+ () => trackElements.find((element) => element.audioGroupAutomation)?.audioGroupAutomation,
136
+ [trackElements],
137
+ );
138
+ const groupFxChainRaw = useMemo(
139
+ () => trackElements.find((element) => element.audioGroupFxChain)?.audioGroupFxChain,
140
+ [trackElements],
141
+ );
142
+ // Which parameters this track's GROUP also automates. Gain stages multiply,
143
+ // and §5 asks for the explanation rather than leaving the author to wonder
144
+ // why two curves they drew sound quieter than either.
145
+ const groupAutomatedTargets = useMemo(
146
+ () =>
147
+ new Set(
148
+ groupAutomationLanes(
149
+ groupOwner
150
+ ? [
151
+ {
152
+ id: groupOwner,
153
+ tag: "audio",
154
+ start: 0,
155
+ duration: 0,
156
+ track: 0,
157
+ ...(groupAutomationRaw ? { automation: groupAutomationRaw } : {}),
158
+ ...(groupFxChainRaw ? { fxChain: groupFxChainRaw } : {}),
159
+ },
160
+ ]
161
+ : [],
162
+ ).map((lane) => lane.key),
163
+ ),
164
+ [groupAutomationRaw, groupFxChainRaw, groupOwner],
165
+ );
166
+ const revealAudioFx = usePlayerStore((s) => s.setRevealedAudioFxTarget);
167
+ /**
168
+ * Which element's rack a lane's reveal opens, in the PANEL's id space.
169
+ *
170
+ * The bare dom id, not the timeline's `sourceFile#domId` composite: the
171
+ * property panel identifies its element by `element.id`, so a composite would
172
+ * never match — the same boundary `runtimeAudioId` exists for.
173
+ */
174
+ const revealElementId = keyframeClip ? runtimeAudioId(keyframeClip) : null;
175
+ const automationRows = useMemo(
176
+ () =>
177
+ groupAutomationLanes(trackElements).map((group) => {
178
+ const active = group.entries.find(
179
+ (entry) => (entry.element.key ?? entry.element.id) === activeKey,
180
+ );
181
+ return {
182
+ key: group.key,
183
+ label: group.key,
184
+ name: group.name,
185
+ param: group.param,
186
+ target: active?.lane.target ?? null,
187
+ // Every entry in a row is the same parameter, so the first answers for the
188
+ // row when the active clip is absent from it.
189
+ isCarve: (() => {
190
+ const entry = active ?? group.entries[0];
191
+ return entry ? isCarveLane(entry.lane.target, elementFxChain(entry.element)) : false;
192
+ })(),
193
+ };
194
+ }),
195
+ [activeKey, trackElements],
196
+ );
381
197
  // Automation counts as something to disclose: gating the caret on tweens alone
382
198
  // left an audio clip's envelopes unreachable, since the track could not expand.
383
199
  const disclosable = lanes.length > 0 || automationRows.length > 0;
384
- const isKeyframeLayer = !!keyframeClip && disclosable;
385
- // Solo is per-clip/per-group, never per track (design doc §2.2) this header
386
- // acts on the track's first clip as a pragmatic stand-in for "this track",
387
- // the same simplification the mute button doesn't need to make (it patches
388
- // every clip on the track at once).
389
- const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
390
- const soloed = usePlayerStore((s) => s.soloed);
391
- const toggleSolo = usePlayerStore((s) => s.toggleSolo);
200
+ // Which HEADER LAYOUT the row wears — not the same question as `disclosable`.
201
+ // An audio track that automates something is still an audio track: it keeps
202
+ // the music glyph and the group indent and gains the `∿`. Tying layout to
203
+ // disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the
204
+ // moment an envelope appeared.
205
+ const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack;
206
+ // What the lane disclosure calls this row. A row of several clips is named
207
+ // for the TRACK, not for whichever is selected — the lanes are the track's,
208
+ // shared per property, so "Narration 2 lanes" read as if they were that one
209
+ // slice's. Shared by both layouts so the name cannot change with the layout.
210
+ const laneOwnerName =
211
+ clipCount > 1
212
+ ? `Track${trackDisplaySuffix(trackDisplayNumber)}`
213
+ : (keyframeClip?.label ?? keyframeClip?.domId ?? keyframeClip?.id ?? trackLabel);
392
214
 
393
215
  // C1: the FX entry point. A single audio clip has one chain to point at; a
394
216
  // track holding several ungrouped ones has no single chain — the design
@@ -400,6 +222,11 @@ export function TimelineTrackHeader({
400
222
  const singleAudioClip =
401
223
  isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
402
224
  const isTrackGrouped = trackElements.some((el) => el.audioGroup);
225
+ // A video track carries sound the render mixes but preview never routes
226
+ // through Web Audio, which is why §1.4 keeps groups audio-only. It still
227
+ // needs to be TOLD that, so it earns the button and a refusal.
228
+ const isVideoWithAudioTrack =
229
+ !isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
403
230
  const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
404
231
  const value = next.nodes.length ? serializeAudioFxChain(next) : null;
405
232
  if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
@@ -408,74 +235,138 @@ export function TimelineTrackHeader({
408
235
  const openClipFxRack = (clip: TimelineElement) => {
409
236
  void domEditActions?.handleTimelineElementSelect(clip);
410
237
  };
411
- const groupUngroupedClips = () => {
238
+ // DOM ids, matching the carve picker's other caller — membership is read back
239
+ // by `resolveAudioGroups`, which only ever sees the document. A clip with no
240
+ // DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
241
+ // one cannot be grouped WHOLE — and grouping the rest would quietly leave
242
+ // those clips outside the bus, past every fader, mute and effect, while the
243
+ // UI showed the track as grouped. The button is withheld instead of acting on
244
+ // a subset, which is also why the carve path's loud guard cannot catch this:
245
+ // the unresolvable ids were filtered out before the call.
246
+ const groupableClipIds = trackElements.map(runtimeAudioId);
247
+ const allGroupableClipIds = groupableClipIds.every((id): id is string => id !== null)
248
+ ? groupableClipIds
249
+ : null;
250
+ const canGroupWholeTrack = (allGroupableClipIds?.length ?? 0) >= 2;
251
+ const groupUngroupedClips = (label: string) => {
412
252
  const doc = domEditActions?.previewIframeRef.current?.contentDocument;
413
253
  if (!doc || !onGroupClips) return;
414
- const clipIds = trackElements.map((el) => el.key ?? el.id);
415
- void onGroupClips(clipIds, mintGroupId(doc));
254
+ if (!allGroupableClipIds || allGroupableClipIds.length < 2) return;
255
+ void onGroupClips(allGroupableClipIds, mintGroupId(doc), label);
416
256
  };
417
257
 
418
258
  return (
419
259
  <div
420
260
  role="rowheader"
421
261
  aria-colindex={1}
422
- className={`sticky left-0 z-[12] shrink-0 ${
423
- !isKeyframeLayer
424
- ? showTrackLabel
425
- ? "flex items-center gap-1 px-1.5 text-white/55"
426
- : "flex flex-col items-center justify-center gap-0.5"
427
- : ""
428
- }`}
262
+ className="sticky left-0 z-[12] shrink-0"
429
263
  style={{
430
264
  width: showTrackLabel ? LABEL_COL_W : contentOrigin,
431
- background: theme.gutterBackground,
265
+ background: gutterFill(theme.gutterBackground, isGroupMember),
432
266
  borderRight: `1px solid ${theme.gutterBorder}`,
267
+ // A group's member rows are `aria-level="2"`, and until this they read
268
+ // as level 2 to a screen reader while looking identical to every
269
+ // top-level row on screen. The rail is the accent-tinted left border
270
+ // B2's design called for; the inset is what actually makes the nesting
271
+ // legible. Padding rather than margin so the rail stays flush with the
272
+ // gutter's own edge.
273
+ ...(isGroupMember
274
+ ? {
275
+ borderLeft: `2px solid ${GROUP_MEMBER_RAIL}`,
276
+ paddingLeft: GROUP_MEMBER_INDENT,
277
+ }
278
+ : {}),
433
279
  }}
434
280
  >
435
- {!keyframeClip || !disclosable ? (
281
+ {!isKeyframeLayer ? (
436
282
  <>
437
- <PlainTrackHeader
438
- trackNumber={trackNumber}
439
- trackDisplayNumber={trackDisplayNumber}
440
- trackLabel={trackLabel}
441
- clipCount={clipCount}
442
- showTrackLabel={showTrackLabel}
443
- isTrackHidden={isTrackHidden}
444
- isAudioTrack={isAudioTrack}
445
- isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
446
- isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
447
- onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
448
- onToggleTrackHidden={onToggleTrackHidden}
449
- />
450
- {singleAudioClip && (
451
- <TimelineFxButton
452
- variant="chain"
453
- fxChainRaw={singleAudioClip.fxChain}
454
- trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
455
- onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
456
- onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
457
- onOpenRack={() => openClipFxRack(singleAudioClip)}
283
+ {/* The two lines own exactly TRACK_H, not the whole header.
284
+ `justify-center` on the header itself centred them in its FULL
285
+ height — which grows by AUTOMATION_LANE_H per open lane — so
286
+ opening one pushed the name and its controls down THROUGH the lane
287
+ rows below, which are absolutely positioned from the top. */}
288
+ <div
289
+ className={
290
+ showTrackLabel
291
+ ? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
292
+ : "flex flex-col items-center justify-center gap-0.5"
293
+ }
294
+ style={{ height: TRACK_H }}
295
+ >
296
+ <PlainTrackHeader
297
+ trackNumber={trackNumber}
298
+ trackDisplayNumber={trackDisplayNumber}
299
+ trackLabel={trackLabel}
300
+ clipCount={clipCount}
301
+ showTrackLabel={showTrackLabel}
302
+ isTrackHidden={isTrackHidden}
303
+ isAudioTrack={isAudioTrack}
304
+ onToggleTrackHidden={onToggleTrackHidden}
305
+ // On the control line rather than a third row of its own.
306
+ trailing={
307
+ <>
308
+ {singleAudioClip && (
309
+ <TimelineFxButton
310
+ variant="chain"
311
+ fxChainRaw={singleAudioClip.fxChain}
312
+ trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
313
+ onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
314
+ onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
315
+ // Muted, an audition is silent — so the hover lifts the mute on
316
+ // the running graph and puts it back on the way out, the same
317
+ // borrow-and-return it already does with the playhead.
318
+ auditionSpans={[singleAudioClip]}
319
+ isMuted={isTrackHidden}
320
+ onSetMutedLive={(muted) =>
321
+ onSetElementAttributeLive?.(
322
+ singleAudioClip,
323
+ "data-hidden",
324
+ muted ? "" : null,
325
+ )
326
+ }
327
+ onOpenRack={() => openClipFxRack(singleAudioClip)}
328
+ />
329
+ )}
330
+ {clipCount > 1 &&
331
+ !isTrackGrouped &&
332
+ (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
333
+ <TimelineFxButton
334
+ variant="group-pointer"
335
+ clipCount={trackElements.length}
336
+ defaultLabel={trackLabel}
337
+ // Groups are audio-only in v1 (§1.4). A video track showing no
338
+ // button at all is the silent limit §5 forbids, so it gets the
339
+ // button and a reason instead.
340
+ refusal={
341
+ isAudioTrack
342
+ ? undefined
343
+ : "Video audio can't be grouped yet — only audio clips can join a group."
344
+ }
345
+ onGroupClips={groupUngroupedClips}
346
+ />
347
+ )}
348
+ {/* The lane disclosure, on the row's own layout rather than by
349
+ swapping it for a keyframe-layer row. */}
350
+ {disclosable && (
351
+ <LaneToggleButton
352
+ name={laneOwnerName}
353
+ isExpanded={isExpanded}
354
+ lanesId={lanesId}
355
+ onToggle={onToggleClipExpanded}
356
+ />
357
+ )}
358
+ </>
359
+ }
458
360
  />
459
- )}
460
- {isAudioTrack && clipCount > 1 && !isTrackGrouped && (
461
- <TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
462
- )}
361
+ </div>
463
362
  </>
464
363
  ) : (
465
364
  <>
466
365
  <LayerDisclosureRow
467
- name={
468
- // A row holding several clips is named for the track, not for
469
- // whichever of them is selected — the lanes below it are the
470
- // track's, shared per property, and naming it "Narration 2" read
471
- // as if they all belonged to that one slice.
472
- clipCount > 1
473
- ? `Track${trackDisplaySuffix(trackDisplayNumber)}`
474
- : (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
475
- }
366
+ name={laneOwnerName}
476
367
  clipCount={clipCount}
477
368
  isExpanded={isExpanded}
478
- gutterBackground={theme.gutterBackground}
369
+ gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
479
370
  columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
480
371
  lanesId={lanesId}
481
372
  onToggleClipExpanded={onToggleClipExpanded}
@@ -489,57 +380,86 @@ export function TimelineTrackHeader({
489
380
  hidden={isTrackHidden}
490
381
  trackNumber={trackNumber}
491
382
  trackDisplayNumber={trackDisplayNumber}
492
- visible
493
- isAudioTrack={isAudioTrack}
383
+ // Audio: only while hidden — see the plain header.
384
+ visible={!isAudioTrack || isTrackHidden}
494
385
  onToggle={onToggleTrackHidden}
495
386
  />
496
387
  </LayerDisclosureRow>
497
- {/* The caret expands TWO disjoint subtrees: these label-column rows,
498
- which carry the per-lane keyframe controls, and the diamond lanes
499
- on the canvas. `lanesId` names the canvas lanes (rendered by
500
- TimelineLanes), because that is what a sighted user watches appear
501
- and what following the reference has to land on. These rows are not
502
- empty and are not the target; they are absolutely positioned inside
503
- the sticky column, which is what made a wrapper HERE compute to
504
- 0x0 and hold no diamonds. */}
505
- {isExpanded &&
506
- lanes.map((lane, laneIndex) => (
507
- <PropertyGroupHeaderRow
508
- key={lane.group}
509
- lanesId={lanesId}
510
- lane={lane}
511
- laneIndex={laneIndex}
512
- isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
513
- expandedElement={keyframeClip}
514
- currentTime={currentTime}
515
- clipPercentage={clipPercentage}
516
- gutterBackground={theme.gutterBackground}
517
- columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
518
- onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
519
- onSeek={onSeek}
520
- rovingTargetId={rovingTargetId}
521
- />
522
- ))}
523
- {/* Below the keyframe rows and stepping by its own height, which is how
524
- TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
525
- two have to agree or a name labels the wrong curve. */}
526
- {isExpanded &&
527
- automationRows.map((row, index) => (
528
- <AutomationLaneHeaderRow
529
- key={row.key}
530
- target={row.target}
531
- label={row.label}
532
- name={row.name}
533
- param={row.param}
534
- top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
535
- isLastLane={index === automationRows.length - 1}
536
- gutterBackground={theme.gutterBackground}
537
- columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
538
- onRemove={onRemoveAutomationLane}
539
- />
540
- ))}
541
388
  </>
542
389
  )}
390
+ {/* The caret expands TWO disjoint subtrees: these label-column rows,
391
+ which carry the per-lane keyframe controls, and the diamond lanes
392
+ on the canvas. `lanesId` names the canvas lanes (rendered by
393
+ TimelineLanes), because that is what a sighted user watches appear
394
+ and what following the reference has to land on. These rows are not
395
+ empty and are not the target; they are absolutely positioned inside
396
+ the sticky column, which is what made a wrapper HERE compute to
397
+ 0x0 and hold no diamonds. */}
398
+ {isExpanded &&
399
+ keyframeClip &&
400
+ lanes.map((lane, laneIndex) => (
401
+ <PropertyGroupHeaderRow
402
+ key={lane.group}
403
+ lanesId={lanesId}
404
+ lane={lane}
405
+ laneIndex={laneIndex}
406
+ isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
407
+ expandedElement={keyframeClip}
408
+ currentTime={currentTime}
409
+ clipPercentage={clipPercentage}
410
+ gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
411
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
412
+ onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
413
+ onSeek={onSeek}
414
+ rovingTargetId={rovingTargetId}
415
+ />
416
+ ))}
417
+ {/* Below the keyframe rows and stepping by its own height, which is how
418
+ TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
419
+ two have to agree or a name labels the wrong curve. */}
420
+ {isExpanded &&
421
+ automationRows.map((row, index) => {
422
+ // Bound outside the closure so it narrows: `row.target` is null on a
423
+ // row the active clip is absent from.
424
+ const revealTarget = row.target;
425
+ return (
426
+ <AutomationLaneHeaderRow
427
+ key={row.key}
428
+ target={row.target}
429
+ label={row.label}
430
+ name={row.name}
431
+ param={row.param}
432
+ alsoAutomatedBy={
433
+ groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined
434
+ }
435
+ top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
436
+ isLastLane={index === automationRows.length - 1}
437
+ gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
438
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
439
+ onRemove={onRemoveAutomationLane}
440
+ isCarve={row.isCarve}
441
+ // Only for a lane the ACTIVE clip actually draws: the rack shows one
442
+ // element, so a shared row's other envelopes belong to clips it is
443
+ // not showing and there would be nothing to reveal.
444
+ onReveal={
445
+ revealTarget && revealElementId && keyframeClip
446
+ ? () => {
447
+ // Select FIRST: the rack is the property panel's view of
448
+ // the selected element, so a reveal aimed at an unselected
449
+ // clip lands on a panel that says "Nothing selected". The
450
+ // request survives the selection — it is stored, not an
451
+ // event — so the rack consumes it as it mounts.
452
+ openClipFxRack(keyframeClip);
453
+ revealAudioFx({
454
+ elementKey: revealElementId,
455
+ automationTarget: revealTarget,
456
+ });
457
+ }
458
+ : undefined
459
+ }
460
+ />
461
+ );
462
+ })}
543
463
  </div>
544
464
  );
545
465
  }