@hyperframes/studio 0.8.11 → 0.8.13

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
  2. package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
  3. package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
  4. package/dist/assets/index-CQMHN4nk.js +431 -0
  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 +7646 -6514
  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/domSelectionTimelineMirror.ts +12 -3
  64. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  65. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  66. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  67. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  68. package/src/hooks/timelineEditingHelpers.ts +19 -7
  69. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  70. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  71. package/src/hooks/timelineTrackVisibility.ts +6 -182
  72. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  73. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  74. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  75. package/src/hooks/useDomEditSession.test.tsx +57 -2
  76. package/src/hooks/useDomEditSession.ts +23 -24
  77. package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
  78. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  79. package/src/hooks/useLivePlayheadTime.ts +3 -1
  80. package/src/hooks/useRemoveBackground.ts +70 -0
  81. package/src/hooks/useTimelineEditing.ts +5 -19
  82. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  83. package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
  84. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  85. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  86. package/src/player/components/PlayerControls.tsx +0 -38
  87. package/src/player/components/Timeline.test.ts +40 -1
  88. package/src/player/components/Timeline.tsx +5 -2
  89. package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
  90. package/src/player/components/TimelineAutomationLane.tsx +129 -36
  91. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  92. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  93. package/src/player/components/TimelineFxButton.tsx +156 -31
  94. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  95. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  96. package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
  97. package/src/player/components/TimelineGroupRow.test.tsx +222 -0
  98. package/src/player/components/TimelineGroupRow.tsx +140 -71
  99. package/src/player/components/TimelineLanes.test.tsx +3 -3
  100. package/src/player/components/TimelineLanes.tsx +25 -23
  101. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  102. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  103. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  104. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  105. package/src/player/components/automationLaneData.test.ts +94 -0
  106. package/src/player/components/automationLaneData.ts +23 -0
  107. package/src/player/components/automationLaneDragMath.ts +28 -0
  108. package/src/player/components/automationLaneGeometry.ts +52 -13
  109. package/src/player/components/groupAutomationElement.test.ts +51 -0
  110. package/src/player/components/groupAutomationElement.ts +37 -0
  111. package/src/player/components/timelineCallbacks.ts +6 -2
  112. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  113. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  114. package/src/player/components/timelineLayout.ts +0 -1
  115. package/src/player/components/timelineViewModel.ts +30 -1
  116. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  117. package/src/player/components/useAutomationLaneGestures.ts +66 -18
  118. package/src/player/components/useAutomationSegmentDrag.ts +110 -0
  119. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  120. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  121. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  122. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  123. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  124. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  125. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  126. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  127. package/src/player/hooks/previewMessageRouter.ts +4 -11
  128. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  129. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  130. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  131. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  132. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  133. package/src/player/lib/automationStoreSync.test.ts +26 -0
  134. package/src/player/lib/automationStoreSync.ts +28 -4
  135. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  136. package/src/player/lib/timelineDOM.test.ts +115 -0
  137. package/src/player/lib/timelineDOM.ts +3 -32
  138. package/src/player/lib/timelineElementHelpers.ts +27 -0
  139. package/src/player/lib/timelineGroupInfo.ts +134 -0
  140. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  141. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  142. package/src/player/store/keyframeSlice.ts +79 -5
  143. package/src/player/store/playerStore.ts +37 -47
  144. package/src/player/store/playerStoreDevHandle.ts +22 -0
  145. package/src/player/store/playerStoreSelection.ts +41 -0
  146. package/src/player/store/timelineElement.ts +32 -0
  147. package/src/utils/hmrStableContext.ts +64 -0
  148. package/src/utils/timelineInspector.test.ts +35 -1
  149. package/src/utils/timelineInspector.ts +38 -0
  150. package/dist/assets/index-Ba9zbpT9.css +0 -1
  151. package/dist/assets/index-DoW5v5eS.js +0 -428
  152. package/src/hooks/useAudioSoloBridge.ts +0 -61
  153. package/src/hooks/useGroupLevel.ts +0 -36
  154. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  155. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  156. package/src/player/components/TimelineSoloButton.tsx +0 -32
  157. package/src/player/store/audioSoloSlice.test.ts +0 -113
  158. package/src/player/store/audioSoloSlice.ts +0 -43
  159. package/src/player/store/groupLevels.ts +0 -33
  160. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  161. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -25,6 +25,7 @@ import {
25
25
  type GroupDragSnapshot,
26
26
  type ShiftAxis,
27
27
  } from "./automationLaneDragMath";
28
+ import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
28
29
 
29
30
  /** How far a press may travel and still count as a click rather than a drag. */
30
31
  const CLICK_SLOP_PX = 3;
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
63
64
  dragIndex: number | null;
64
65
  /** Segment being bent, identified by the point that owns its curve. */
65
66
  curveIndex: number | null;
67
+ /** Segment whose two endpoints are being translated together. */
68
+ segmentDragIndex: number | null;
69
+ /** Segment close enough to the pointer to offer that translation. */
70
+ segmentHoverIndex: number | null;
66
71
  /** Value readout to show while a gesture is live. */
67
72
  hint: string | null;
68
73
  hitIndex(clientX: number, clientY: number): number | null;
69
74
  segmentIndex(clientX: number, clientY: number): number | null;
75
+ /** Clear hover feedback when the pointer leaves the lane. */
76
+ onPointerLeave(): void;
70
77
  onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
71
78
  onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
72
79
  /** Edge being stretched, for the cursor. Null when no stretch is live. */
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
227
234
  [lane, pointAt],
228
235
  );
229
236
 
230
- /** What a press starts: moving a point, or — with Alt on the line — bending it. */
237
+ const segmentDrag = useAutomationSegmentDrag({
238
+ getBox,
239
+ lane,
240
+ range,
241
+ pointAt,
242
+ xOf,
243
+ yOf,
244
+ segmentIndex,
245
+ commitPoints,
246
+ duration,
247
+ snapTimes,
248
+ readOnly,
249
+ onHint: setHint,
250
+ });
251
+
252
+ /** What a press starts: point move, segment move, or Alt segment bend. */
231
253
  const gestureAt = useCallback(
232
- (e: ReactPointerEvent<SVGSVGElement>): { curve: boolean; index: number } | null => {
254
+ (
255
+ e: ReactPointerEvent<SVGSVGElement>,
256
+ ): { kind: "point" | "segment" | "curve"; index: number } | null => {
233
257
  const index = hitIndex(e.clientX, e.clientY);
234
- if (index !== null) return { curve: false, index };
235
- if (!e.altKey) return null;
236
- const segment = segmentIndex(e.clientX, e.clientY);
237
- return segment === null ? null : { curve: true, index: segment };
258
+ if (index !== null) return { kind: "point", index };
259
+ // Alt is an explicit bend gesture and retains its span-wide hit target.
260
+ // The unmodified translation is offered only close to the drawn line.
261
+ const segment = e.altKey
262
+ ? segmentIndex(e.clientX, e.clientY)
263
+ : segmentDrag.hitIndex(e.clientX, e.clientY);
264
+ if (segment === null) return null;
265
+ return { kind: e.altKey ? "curve" : "segment", index: segment };
238
266
  },
239
- [hitIndex, segmentIndex],
267
+ [hitIndex, segmentDrag, segmentIndex],
240
268
  );
241
269
 
242
270
  const onPointerDown = useCallback(
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
274
302
  }
275
303
  e.preventDefault();
276
304
  capturePointer(e);
277
- if (gesture.curve) {
305
+ segmentDrag.clearHover();
306
+ if (gesture.kind === "curve") {
278
307
  setCurveIndex(gesture.index);
279
308
  return;
280
309
  }
310
+ if (gesture.kind === "segment") {
311
+ segmentDrag.arm(gesture.index, e.clientX, e.clientY);
312
+ return;
313
+ }
281
314
  dragOrigin.current = originOf(lane.points[gesture.index]);
282
315
  // Pressing one of a selected set drags the whole set. Pressing a point
283
316
  // outside the selection is an ordinary single-point drag, selection or no.
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
299
332
  // selecting a range read the previous selection, or none.
300
333
  rangeSelection,
301
334
  stretch,
335
+ segmentDrag,
302
336
  ],
303
337
  );
304
338
 
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
394
428
  [dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
395
429
  );
396
430
 
431
+ /** Route a live point, bend, or segment gesture after global gestures stand down. */
432
+ const moveLiveGesture = useCallback(
433
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
434
+ const from = pressAt.current;
435
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
436
+ pressTravelled.current = true;
437
+ }
438
+ if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
439
+ else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
440
+ else movePoint(e);
441
+ },
442
+ [bendSegment, curveIndex, movePoint, segmentDrag],
443
+ );
444
+
397
445
  const onPointerMove = useCallback(
398
446
  (e: ReactPointerEvent<SVGSVGElement>): void => {
399
447
  if (stretch.edge !== null) {
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
409
457
  rangeDrag.move(e);
410
458
  return;
411
459
  }
412
- if (curveIndex === null && dragIndex === null) {
460
+ if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
413
461
  stretch.updateHover(e);
462
+ segmentDrag.updateHover(e.clientX, e.clientY);
414
463
  return;
415
464
  }
416
465
  e.stopPropagation();
417
- const from = pressAt.current;
418
- if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
419
- pressTravelled.current = true;
420
- }
421
- if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
422
- else movePoint(e);
466
+ moveLiveGesture(e);
423
467
  },
424
- [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
468
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
425
469
  );
426
470
 
427
471
  const endDrag = useCallback(
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
436
480
  rangeDrag.finish();
437
481
  return;
438
482
  }
439
- if (dragIndex === null && curveIndex === null) return;
483
+ if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
440
484
  e.stopPropagation();
441
485
  const index = dragIndex;
442
486
  const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
443
487
  setDragIndex(null);
444
488
  setCurveIndex(null);
489
+ segmentDrag.finish();
445
490
  dragOrigin.current = null;
446
491
  groupDrag.current = null;
447
492
  shiftAxis.current = null;
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
459
504
  }
460
505
  commitPoints(lane.points, true);
461
506
  },
462
- [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
507
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
463
508
  );
464
509
 
465
510
  /**
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
519
564
  return {
520
565
  dragIndex,
521
566
  curveIndex,
567
+ segmentDragIndex: segmentDrag.dragIndex,
568
+ segmentHoverIndex: segmentDrag.hoverIndex,
522
569
  edgeDrag: stretch.edge,
523
570
  edgeHover: stretch.hover,
524
571
  cancelDrag,
525
572
  hint,
526
573
  hitIndex,
527
574
  segmentIndex,
575
+ onPointerLeave: segmentDrag.clearHover,
528
576
  onPointerDown,
529
577
  onPointerMove,
530
578
  endDrag,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The grab target and translation gesture for one automation segment.
3
+ *
4
+ * Kept separate from the lane's gesture router: proximity is measured against
5
+ * the sampled envelope, while movement reuses the same shape-preserving group
6
+ * math as a box selection.
7
+ */
8
+
9
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
10
+ import {
11
+ sampleAutomationLane,
12
+ type AutomationRange,
13
+ type HfAutomationLane,
14
+ } from "@hyperframes/core/audio-automation";
15
+ import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
16
+ import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
17
+
18
+ interface UseAutomationSegmentDragInput {
19
+ getBox(): DOMRect | null;
20
+ lane: HfAutomationLane;
21
+ range: AutomationRange;
22
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
23
+ xOf(t: number): number;
24
+ yOf(v: number): number;
25
+ segmentIndex(clientX: number, clientY: number): number | null;
26
+ commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
27
+ duration: number;
28
+ snapTimes: readonly number[] | undefined;
29
+ readOnly: boolean | undefined;
30
+ onHint(hint: string | null): void;
31
+ }
32
+
33
+ export function useAutomationSegmentDrag({
34
+ getBox,
35
+ lane,
36
+ range,
37
+ pointAt,
38
+ xOf,
39
+ yOf,
40
+ segmentIndex,
41
+ commitPoints,
42
+ duration,
43
+ snapTimes,
44
+ readOnly,
45
+ onHint,
46
+ }: UseAutomationSegmentDragInput) {
47
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
48
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null);
49
+ const snapshot = useRef<GroupDragSnapshot | null>(null);
50
+
51
+ /** Segment whose drawn line is close enough to grab, or null. */
52
+ const hitIndex = useCallback(
53
+ (clientX: number, clientY: number): number | null => {
54
+ const box = getBox();
55
+ if (!box) return null;
56
+ const index = segmentIndex(clientX, clientY);
57
+ if (index === null) return null;
58
+ const { t } = pointAt(clientX, clientY);
59
+ const value = sampleAutomationLane(lane, t, range.scale);
60
+ return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
61
+ },
62
+ [getBox, lane, pointAt, range.scale, segmentIndex, yOf],
63
+ );
64
+
65
+ const arm = useCallback(
66
+ (index: number, clientX: number, clientY: number): void => {
67
+ snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
68
+ setHoverIndex(null);
69
+ setDragIndex(index);
70
+ },
71
+ [lane, pointAt],
72
+ );
73
+
74
+ const move = useCallback(
75
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
76
+ const group = snapshot.current;
77
+ if (!group) return;
78
+ const moved = computeGroupMove({
79
+ group,
80
+ raw: pointAt(e.clientX, e.clientY),
81
+ shiftKey: e.shiftKey,
82
+ altKey: e.altKey,
83
+ range,
84
+ duration,
85
+ snapTimes,
86
+ xOf,
87
+ yOf,
88
+ });
89
+ onHint(moved.hint);
90
+ commitPoints(moved.points, false);
91
+ },
92
+ [commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
93
+ );
94
+
95
+ const finish = useCallback((): void => {
96
+ snapshot.current = null;
97
+ setDragIndex(null);
98
+ }, []);
99
+
100
+ const updateHover = useCallback(
101
+ (clientX: number, clientY: number): void => {
102
+ setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
103
+ },
104
+ [hitIndex, readOnly],
105
+ );
106
+
107
+ const clearHover = useCallback((): void => setHoverIndex(null), []);
108
+
109
+ return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
110
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening and closing a track's keyframe property lanes, with the telemetry that
3
+ * goes with it.
4
+ *
5
+ * Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line
6
+ * cap. Both callbacks were already the only place the disclosure state and its
7
+ * `keyframe_lane_expand` event were written together, which is what makes them a
8
+ * seam rather than a shuffle.
9
+ */
10
+
11
+ import { usePlayerStore } from "../store/playerStore";
12
+ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
13
+
14
+ export interface TimelineClipDisclosure {
15
+ /** The caret belongs to the ROW, so it opens and closes every clip on it at
16
+ * once. Toggling only the active clip left the row's state depending on which
17
+ * sibling happened to be selected: expand one, click another, and the row
18
+ * collapsed under a caret that still pointed down. */
19
+ toggleRowExpanded: (keys: readonly string[]) => void;
20
+ toggleClipExpanded: (key: string) => void;
21
+ }
22
+
23
+ export function useTimelineClipDisclosure(): TimelineClipDisclosure {
24
+ const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
25
+ const expandClips = usePlayerStore((s) => s.expandClips);
26
+ const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
27
+ const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
28
+
29
+ return {
30
+ toggleRowExpanded: (keys) => {
31
+ const willExpand = !keys.some((key) => expandedClipIds.has(key));
32
+ trackStudioKeyframeLaneExpand({ expanded: willExpand });
33
+ if (willExpand) expandClips(keys);
34
+ else for (const key of keys) setClipExpanded(key, false);
35
+ },
36
+ toggleClipExpanded: (key) => {
37
+ trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) });
38
+ toggleClipExpanded(key);
39
+ },
40
+ };
41
+ }
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
6
6
  /** The four pieces of group-disclosure state a group row's header reads and writes. */
7
7
  export function useTimelineGroupDisclosure() {
8
8
  return {
9
- expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
9
+ collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
10
10
  expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
11
11
  toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
12
12
  toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
35
35
 
36
36
  export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
37
37
  const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
38
- const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
38
+ const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
39
39
  const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
40
40
  const projectId = usePlayerStore((state) => state.timelineProjectId);
41
41
  const logicalRows = useTimelineLogicalRows({
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
45
45
  selectedElementId: input.selectedElementId,
46
46
  selectedElementIds: input.selectedElementIds,
47
47
  expandedClipIds,
48
- expandedGroupIds,
48
+ collapsedGroupIds,
49
49
  expandedLaneOwnerIds,
50
50
  groups: input.groups,
51
51
  trackGroupOf: input.trackGroupOf,
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
22
22
  const laneCounts = new Map<string, number>();
23
23
  const selectedElementIds = new Set<string>();
24
24
  const expandedClipIds = new Set<string>();
25
- const expandedGroupIds = new Set<string>();
25
+ const collapsedGroupIds = new Set<string>();
26
26
  const expandedLaneOwnerIds = new Set<string>();
27
27
  const groups: never[] = [];
28
28
  const trackGroupOf = new Map();
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
37
37
  selectedElementId: null,
38
38
  selectedElementIds,
39
39
  expandedClipIds,
40
- expandedGroupIds,
40
+ collapsedGroupIds,
41
41
  expandedLaneOwnerIds,
42
42
  groups,
43
43
  trackGroupOf,
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
14
14
  selectedElementId,
15
15
  selectedElementIds,
16
16
  expandedClipIds,
17
- expandedGroupIds,
17
+ collapsedGroupIds,
18
18
  expandedLaneOwnerIds,
19
19
  groups,
20
20
  trackGroupOf,
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
29
29
  selectedElementId,
30
30
  selectedElementIds,
31
31
  expandedClipIds,
32
- expandedGroupIds,
32
+ collapsedGroupIds,
33
33
  expandedLaneOwnerIds,
34
34
  groups,
35
35
  trackGroupOf,
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
38
38
  [
39
39
  displayTrackOrder,
40
40
  expandedClipIds,
41
- expandedGroupIds,
41
+ collapsedGroupIds,
42
42
  expandedLaneOwnerIds,
43
43
  groups,
44
44
  trackGroupOf,
@@ -1,5 +1,5 @@
1
1
  import { useMemo } from "react";
2
- import type { TimelineElement } from "../store/playerStore";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
4
4
 
5
5
  /** One resolved audio group, positioned in the row order. */
@@ -15,12 +15,26 @@ export interface TimelineTrackGroupInfo {
15
15
  anchorKey: number;
16
16
  /** Member track numbers, ascending. */
17
17
  memberTracks: number[];
18
+ /**
19
+ * Every clip under this group, in member-track order — INDEPENDENT of whether
20
+ * the group is expanded.
21
+ *
22
+ * Collapsing a group stops emitting its member rows into `tracks`, so anything
23
+ * that recovered member elements by looking them up there got an empty list in
24
+ * the default (collapsed) state — silently disabling the
25
+ * automation-lane count, and the bus strip's member labels. Membership is not
26
+ * a display concern, so it does not travel through the display list.
27
+ */
28
+ memberElements: TimelineElement[];
18
29
  /** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
19
30
  volume: number;
20
31
  /** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
21
32
  hidden: boolean;
22
33
  /** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
23
34
  fxChain?: string;
35
+ /** The group element's serialized `data-automation` — the group's OWN lanes,
36
+ * which are what its `∿` discloses (groups doc §5). */
37
+ automation?: string;
24
38
  }
25
39
 
26
40
  interface GroupMembership {
@@ -30,6 +44,7 @@ interface GroupMembership {
30
44
  volumeByGroup: Map<string, number>;
31
45
  hiddenByGroup: Map<string, boolean>;
32
46
  fxChainByGroup: Map<string, string | undefined>;
47
+ automationByGroup: Map<string, string | undefined>;
33
48
  }
34
49
 
35
50
  /** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
@@ -40,6 +55,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
40
55
  const volumeByGroup = new Map<string, number>();
41
56
  const hiddenByGroup = new Map<string, boolean>();
42
57
  const fxChainByGroup = new Map<string, string | undefined>();
58
+ const automationByGroup = new Map<string, string | undefined>();
43
59
  for (const [trackNum, elements] of rawTracks) {
44
60
  const owner = elements.find((el) => el.audioGroup);
45
61
  if (!owner?.audioGroup) continue;
@@ -49,6 +65,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
49
65
  volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
50
66
  hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
51
67
  fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
68
+ automationByGroup.set(owner.audioGroup, owner.audioGroupAutomation);
52
69
  }
53
70
  const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
54
71
  members.push(trackNum);
@@ -61,6 +78,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
61
78
  volumeByGroup,
62
79
  hiddenByGroup,
63
80
  fxChainByGroup,
81
+ automationByGroup,
64
82
  };
65
83
  }
66
84
 
@@ -69,33 +87,50 @@ function buildGroupInfo(
69
87
  groupId: string,
70
88
  fallbackTrackNum: number,
71
89
  membership: GroupMembership,
90
+ rawByTrack: ReadonlyMap<number, TimelineElement[]>,
72
91
  ): TimelineTrackGroupInfo {
73
92
  const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
74
93
  (a, b) => a - b,
75
94
  );
76
95
  const fxChain = membership.fxChainByGroup.get(groupId);
96
+ const automation = membership.automationByGroup.get(groupId);
77
97
  return {
78
98
  id: groupId,
79
99
  label: membership.labelByGroup.get(groupId) ?? groupId,
100
+ // Exactly x.5, which sub-composition child rows are now kept strictly below
101
+ // (`useExpandedTimelineElements`) — they used to be able to land here and
102
+ // collide, duplicating the group header.
80
103
  anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
81
104
  memberTracks,
105
+ memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
82
106
  volume: membership.volumeByGroup.get(groupId) ?? 1,
83
107
  hidden: membership.hiddenByGroup.get(groupId) ?? false,
84
108
  ...(fxChain ? { fxChain } : {}),
109
+ ...(automation ? { automation } : {}),
85
110
  };
86
111
  }
87
112
 
88
- /** Push a group's synthetic anchor row plus its members' rows, contiguously. */
113
+ /**
114
+ * Push a group's synthetic anchor row plus its members' rows, contiguously.
115
+ *
116
+ * A collapsed group emits its anchor and nothing else. `buildTimelineLogicalRows`
117
+ * already stops at the anchor when collapsed, so leaving the member rows in
118
+ * `tracks` left row geometry reserving a full row each for rows that then
119
+ * rendered as `null` — a header trailed by its members' worth of blank,
120
+ * unreachable dead space. Membership still lands in `trackGroupOf`: a collapsed
121
+ * member is hidden, not ungrouped.
122
+ */
89
123
  function emitGroupRows(
90
124
  info: TimelineTrackGroupInfo,
91
125
  rawByTrack: ReadonlyMap<number, TimelineElement[]>,
92
126
  trackGroupOf: Map<number, TimelineTrackGroupInfo>,
93
127
  tracks: [number, TimelineElement[]][],
128
+ expanded: boolean,
94
129
  ): void {
95
130
  tracks.push([info.anchorKey, []]);
96
131
  for (const member of info.memberTracks) {
97
132
  trackGroupOf.set(member, info);
98
- tracks.push([member, rawByTrack.get(member) ?? []]);
133
+ if (expanded) tracks.push([member, rawByTrack.get(member) ?? []]);
99
134
  }
100
135
  }
101
136
 
@@ -105,7 +140,10 @@ function emitGroupRows(
105
140
  * their position; a group's members move up to sit under its anchor even when
106
141
  * other (ungrouped) tracks were interleaved between them.
107
142
  */
108
- function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
143
+ function groupTimelineTracks(
144
+ rawTracks: [number, TimelineElement[]][],
145
+ collapsedGroupIds: ReadonlySet<string>,
146
+ ): {
109
147
  tracks: [number, TimelineElement[]][];
110
148
  groups: TimelineTrackGroupInfo[];
111
149
  trackGroupOf: Map<number, TimelineTrackGroupInfo>;
@@ -125,9 +163,9 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
125
163
  }
126
164
  if (emitted.has(groupId)) continue;
127
165
  emitted.add(groupId);
128
- const info = buildGroupInfo(groupId, trackNum, membership);
166
+ const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
129
167
  groups.push(info);
130
- emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
168
+ emitGroupRows(info, rawByTrack, trackGroupOf, tracks, !collapsedGroupIds.has(groupId));
131
169
  }
132
170
  return { tracks, groups, trackGroupOf };
133
171
  }
@@ -156,9 +194,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
156
194
  return Array.from(map.entries()).sort(([a], [b]) => a - b);
157
195
  }, [expandedElements]);
158
196
 
197
+ const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
159
198
  const { tracks, groups, trackGroupOf } = useMemo(
160
- () => groupTimelineTracks(rawTracks),
161
- [rawTracks],
199
+ () => groupTimelineTracks(rawTracks, collapsedGroupIds),
200
+ [rawTracks, collapsedGroupIds],
162
201
  );
163
202
 
164
203
  const trackStyles = useMemo(() => {
@@ -38,6 +38,125 @@ function renderTrackLayout(
38
38
  return { layout, unmount: () => act(() => root.unmount()) };
39
39
  }
40
40
 
41
+ describe("collapsed audio groups", () => {
42
+ const member = (id: string, track: number): TimelineElement => ({
43
+ id,
44
+ domId: id,
45
+ tag: "audio",
46
+ start: 0,
47
+ duration: 5,
48
+ track,
49
+ audioGroup: "voiceover",
50
+ });
51
+
52
+ /** `collapsed` seeds the collapsed set — expanded is the default state. */
53
+ function renderGrouped(collapsed = false): {
54
+ layout: ReturnType<typeof useTimelineTrackLayout>;
55
+ unmount: () => void;
56
+ } {
57
+ if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
58
+ const elements = [member("voice-1", 0), member("voice-2", 1)];
59
+ let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
60
+ function Probe() {
61
+ layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
62
+ return null;
63
+ }
64
+ const root = createRoot(document.createElement("div"));
65
+ act(() => root.render(React.createElement(Probe)));
66
+ if (!layout) throw new Error("Timeline track layout did not render");
67
+ return { layout, unmount: () => act(() => root.unmount()) };
68
+ }
69
+
70
+ // The `∿` area holds the group's own automation rows. Sized without them,
71
+ // every lane the count had just promised was clipped out of the row — which
72
+ // is what "expanding automation on a group doesn't show the automation"
73
+ // looked like from outside.
74
+ it("reserves room for the group's own automation rows, not just the strip", () => {
75
+ const automation = JSON.stringify({
76
+ version: 1,
77
+ lanes: [
78
+ {
79
+ target: "volume",
80
+ points: [
81
+ { t: 0, v: 1 },
82
+ { t: 5, v: 0.4 },
83
+ ],
84
+ },
85
+ ],
86
+ });
87
+ const elements = [
88
+ { ...member("voice-1", 0), audioGroupAutomation: automation },
89
+ { ...member("voice-2", 1), audioGroupAutomation: automation },
90
+ ];
91
+ let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
92
+ function Probe() {
93
+ layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
94
+ return null;
95
+ }
96
+ const root = createRoot(document.createElement("div"));
97
+ act(() => {
98
+ usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["voiceover"]) });
99
+ root.render(React.createElement(Probe));
100
+ });
101
+ // The group's anchor row: the first member track minus 0.5.
102
+ const anchorIndex = layout!.tracks.findIndex(([track]) => track === -0.5);
103
+ expect(anchorIndex).toBeGreaterThanOrEqual(0);
104
+ const openHeight = layout!.rowHeights[anchorIndex];
105
+ // One lane of headroom beyond the header row itself.
106
+ expect(openHeight).toBe(TRACK_H + AUTOMATION_LANE_H);
107
+ act(() => root.unmount());
108
+ });
109
+
110
+ // buildTimelineLogicalRows stops emitting member rows once a group is
111
+ // collapsed, so TimelineLanes renders null for them. Rows left in `tracks`
112
+ // still reserve height, turning that null into visible dead space — the row
113
+ // list and the logical rows have to agree.
114
+ it("emits only the anchor row while the group is collapsed", () => {
115
+ const { layout, unmount } = renderGrouped(true);
116
+ expect(layout.groups).toHaveLength(1);
117
+ expect(layout.groups[0]!.memberTracks).toEqual([0, 1]);
118
+ // The anchor (0 - 0.5) and nothing else.
119
+ expect(layout.trackOrder).toEqual([-0.5]);
120
+ expect(layout.rowGeometry.rowHeights).toHaveLength(1);
121
+ // Membership still resolves — collapsed is hidden, not ungrouped.
122
+ expect(layout.trackGroupOf.get(0)?.id).toBe("voiceover");
123
+ unmount();
124
+ });
125
+
126
+ // Membership is not a display concern. Half-lit solo, the automation-lane
127
+ // count and the bus strip's member labels all read the group's members, and
128
+ // all three silently degraded to empty when those were recovered from the
129
+ // display list — which a collapsed group does not appear in. Collapsed is the
130
+ // default, so that was every group until someone opened it.
131
+ it("carries its member elements even while collapsed", () => {
132
+ const { layout, unmount } = renderGrouped(true);
133
+ expect(layout.trackOrder).toEqual([-0.5]); // collapsed: no member rows
134
+ expect(layout.groups[0]!.memberElements.map((el) => el.id)).toEqual(["voice-1", "voice-2"]);
135
+ unmount();
136
+ });
137
+
138
+ // The reason the set is stored inverted. As an expanded-set, "absent" could
139
+ // not tell never-touched from deliberately-collapsed, so a freshly created
140
+ // group started collapsed — grouping three tracks made all three vanish
141
+ // behind a header the user had not yet learned to open.
142
+ it("is expanded by default, with nothing seeded", () => {
143
+ const { layout, unmount } = renderGrouped();
144
+ expect(usePlayerStore.getState().collapsedGroupIds.size).toBe(0);
145
+ expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
146
+ unmount();
147
+ });
148
+
149
+ it("emits the member rows once the group is expanded", () => {
150
+ // Expanded is the default now — nothing to seed.
151
+ const { layout, unmount } = renderGrouped();
152
+ expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
153
+ for (const track of layout.groups[0]!.memberTracks) {
154
+ expect(layout.rowGeometry.getRowHeight(layout.rowGeometry.getRowIndex(track))).toBe(TRACK_H);
155
+ }
156
+ unmount();
157
+ });
158
+ });
159
+
41
160
  describe("useTimelineTrackLayout", () => {
42
161
  it("counts a flat tween lane and reserves its expanded row height", () => {
43
162
  const elements: TimelineElement[] = [