@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
@@ -3,12 +3,15 @@
3
3
  /**
4
4
  * The visibility control's accessible contract.
5
5
  *
6
- * The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
7
- * never rendered in any suite: it returned "Muted" / "Mute", which named the
8
- * CURRENT state rather than the action, and dropped the track suffix so every
9
- * audio row shared one accessible name. Music plus VO is the ordinary case, so
10
- * that is two identical buttons. Pinned here because the label and the icon are
11
- * the whole identity of this control.
6
+ * The wording ran behind the `audio-track-mute` canary at 0%, so it had never
7
+ * rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
8
+ * state rather than the action, and dropped the track suffix so every audio row
9
+ * shared one accessible name. Pinned here because the label, the icon and the
10
+ * callback's arguments are the whole identity of this control.
11
+ *
12
+ * The mute presentation itself is gone (`remove mute and solo from tracks and
13
+ * groups`): the control is the visibility eye it always was, and an audio row
14
+ * renders it only when already hidden, which is the way back out.
12
15
  */
13
16
 
14
17
  import React, { act } from "react";
@@ -24,7 +27,7 @@ afterEach(() => {
24
27
 
25
28
  function renderButton(props: {
26
29
  hidden: boolean;
27
- isAudioTrack?: boolean;
30
+ visible?: boolean;
28
31
  trackDisplayNumber: number | null;
29
32
  }): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
30
33
  const host = document.createElement("div");
@@ -37,8 +40,7 @@ function renderButton(props: {
37
40
  hidden: props.hidden,
38
41
  trackNumber: 7,
39
42
  trackDisplayNumber: props.trackDisplayNumber,
40
- visible: true,
41
- isAudioTrack: props.isAudioTrack,
43
+ visible: props.visible ?? true,
42
44
  onToggle,
43
45
  }),
44
46
  ),
@@ -49,31 +51,7 @@ function renderButton(props: {
49
51
  const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
50
52
 
51
53
  describe("VisibilityButton", () => {
52
- it("names the action, not the state, on an audible audio track", () => {
53
- const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
54
- expect(labelOf(view.host)).toBe("Mute track 2");
55
- view.unmount();
56
- });
57
-
58
- // The half that was wrong: a muted row read "Muted", so nothing told a
59
- // screen-reader user that activating it would unmute.
60
- it("promises the un-mute when the audio track is already muted", () => {
61
- const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
62
- expect(labelOf(view.host)).toBe("Unmute track 2");
63
- view.unmount();
64
- });
65
-
66
- it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
67
- const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
68
- const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
69
- expect(labelOf(first.host)).toBe("Mute track 1");
70
- expect(labelOf(second.host)).toBe("Mute track 3");
71
- expect(labelOf(first.host)).not.toBe(labelOf(second.host));
72
- first.unmount();
73
- second.unmount();
74
- });
75
-
76
- it("still says Hide/Show on a visual track", () => {
54
+ it("names the action, not the state", () => {
77
55
  const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
78
56
  expect(labelOf(shown.host)).toBe("Hide track 2");
79
57
  shown.unmount();
@@ -82,15 +60,34 @@ describe("VisibilityButton", () => {
82
60
  hiddenRow.unmount();
83
61
  });
84
62
 
63
+ it("keeps each row's name unique, so two tracks are distinguishable", () => {
64
+ const first = renderButton({ hidden: false, trackDisplayNumber: 1 });
65
+ const second = renderButton({ hidden: false, trackDisplayNumber: 3 });
66
+ expect(labelOf(first.host)).toBe("Hide track 1");
67
+ expect(labelOf(second.host)).toBe("Hide track 3");
68
+ expect(labelOf(first.host)).not.toBe(labelOf(second.host));
69
+ first.unmount();
70
+ second.unmount();
71
+ });
72
+
85
73
  // The callback acts on the REAL track key; the display row rides along so the
86
74
  // undo-history label announces the same row this button just did, instead of
87
75
  // re-deriving it from an ordering that has no group anchors in it.
88
76
  it("toggles the real track number and passes the row it announced", () => {
89
- const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
77
+ const view = renderButton({ hidden: false, trackDisplayNumber: 2 });
90
78
  view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
91
79
  expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
92
80
  view.unmount();
93
81
  });
82
+
83
+ // `visible={false}` renders a SPACER, not nothing: every row's control
84
+ // columns have to line up whether or not this row offers the control.
85
+ it("holds the column with a spacer when withheld", () => {
86
+ const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
87
+ expect(view.host.querySelector("button")).toBeNull();
88
+ expect(view.host.querySelector("span")).toBeTruthy();
89
+ view.unmount();
90
+ });
94
91
  });
95
92
 
96
93
  describe("PlainTrackHeader", () => {
@@ -98,7 +95,6 @@ describe("PlainTrackHeader", () => {
98
95
  const host = document.createElement("div");
99
96
  document.body.append(host);
100
97
  const root = createRoot(host);
101
- const onToggleSolo = vi.fn();
102
98
  act(() =>
103
99
  root.render(
104
100
  React.createElement(PlainTrackHeader, {
@@ -110,52 +106,34 @@ describe("PlainTrackHeader", () => {
110
106
  isAudioTrack: true,
111
107
  onToggleTrackHidden: vi.fn(),
112
108
  showTrackLabel: true,
113
- isGroupMuted: false,
114
- isSoloed: false,
115
- onToggleSolo,
116
109
  ...overrides,
117
110
  }),
118
111
  ),
119
112
  );
120
- return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
113
+ return { host, unmount: () => act(() => root.unmount()) };
121
114
  }
122
115
 
123
- const soloButton = (host: HTMLElement) =>
124
- host.querySelector('button[aria-label="Hear only this"]');
125
- const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
126
-
127
- it("offers solo on an audio track and reports its pressed state", () => {
128
- const view = renderHeader({ isSoloed: true });
129
- expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
130
- view.unmount();
131
- });
132
-
133
- it("withholds solo from a visual track", () => {
134
- const view = renderHeader({ isAudioTrack: false });
135
- expect(soloButton(view.host)).toBeNull();
116
+ // A row that says what it is with a speaker does not also need the hide
117
+ // affordance sitting in the eye's slot.
118
+ it("withholds the eye from an audible audio track", () => {
119
+ const view = renderHeader();
120
+ expect(view.host.querySelector("button")).toBeNull();
136
121
  view.unmount();
137
122
  });
138
123
 
139
- // A group-muted member is silent without being hidden itself, so the strike
140
- // is the only thing that says so and the title has to explain why, since a
141
- // user who never touched THIS row's mute is looking for the reason.
142
- it("strikes the label through when the row's own mute is on", () => {
124
+ // Withholding it unconditionally withheld the only way back: `data-hidden`
125
+ // silences the clip in preview and drops it from the render, and nothing else
126
+ // writes it, so a track hidden by hand or by "Hide all" was silent with no
127
+ // control anywhere to restore it.
128
+ it("offers it back once the audio track is hidden", () => {
143
129
  const view = renderHeader({ isTrackHidden: true });
144
- expect(labelSpan(view.host)?.className).toContain("line-through");
130
+ expect(labelOf(view.host)).toBe("Show track 1");
145
131
  view.unmount();
146
132
  });
147
133
 
148
- it("strikes it through for a group mute too, and says which", () => {
149
- const view = renderHeader({ isGroupMuted: true });
150
- expect(labelSpan(view.host)?.className).toContain("line-through");
151
- expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
152
- view.unmount();
153
- });
154
-
155
- it("leaves an audible row unstruck", () => {
156
- const view = renderHeader();
157
- expect(labelSpan(view.host)?.className).not.toContain("line-through");
158
- expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
134
+ it("keeps the eye on a visual track", () => {
135
+ const view = renderHeader({ isAudioTrack: false });
136
+ expect(labelOf(view.host)).toBe("Hide track 1");
159
137
  view.unmount();
160
138
  });
161
139
  });
@@ -1,29 +1,18 @@
1
- import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
1
+ import type React from "react";
2
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
2
3
  import { Music } from "../../icons/SystemIcons";
3
- import { TimelineSoloButton } from "./TimelineSoloButton";
4
4
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
5
5
  import { TrackClipCount } from "./TrackClipCount";
6
6
  import { trackDisplaySuffix } from "./timelineTrackDisplay";
7
7
 
8
- /**
9
- * Audio tracks say "Mute", not "Hide" the eye IS mute for sound-only rows.
10
- *
11
- * Action-phrased and per-track, like the visual branch: `hidden` here is the
12
- * CURRENT state, so the name has to promise the opposite. It read "Muted" /
13
- * "Mute" until the rollout, which named the state instead of the action (a
14
- * screen-reader user could not tell that activating an already-muted row would
15
- * unmute it) and dropped `suffix`, so every audio row shared one accessible
16
- * name — music plus VO being the ordinary case. The wording matches the undo
17
- * entry `timelineTrackVisibility` writes for the same click, where `hidden` is
18
- * the INCOMING state and the two therefore read inverted.
19
- */
20
- function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
21
- if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
8
+ // Hide, plainly. The speaker variant was the mute presentation; with mute gone
9
+ // this is the visibility eye it always was, and audio rows do not render it.
10
+ function visibilityButtonLabel(hidden: boolean, suffix: string): string {
22
11
  return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
23
12
  }
24
13
 
25
- function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
26
- const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
14
+ function visibilityButtonIcon(hidden: boolean) {
15
+ const Icon = hidden ? EyeSlash : Eye;
27
16
  return <Icon size={14} weight="bold" aria-hidden="true" />;
28
17
  }
29
18
 
@@ -32,22 +21,19 @@ export function VisibilityButton({
32
21
  trackNumber,
33
22
  trackDisplayNumber,
34
23
  visible,
35
- isAudioTrack,
36
24
  onToggle,
37
25
  }: {
38
26
  hidden: boolean;
39
27
  trackNumber: number;
40
28
  trackDisplayNumber: number | null;
41
29
  visible: boolean;
42
- isAudioTrack?: boolean;
43
30
  onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
44
31
  }) {
45
32
  if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
46
33
  // Display number in the text, real key in the callback. The two must not be
47
34
  // conflated in either direction.
48
35
  const suffix = trackDisplaySuffix(trackDisplayNumber);
49
- const showAsMute = Boolean(isAudioTrack);
50
- const label = visibilityButtonLabel(showAsMute, hidden, suffix);
36
+ const label = visibilityButtonLabel(hidden, suffix);
51
37
  return (
52
38
  <button
53
39
  type="button"
@@ -59,10 +45,12 @@ export function VisibilityButton({
59
45
  onPointerDown={(event) => event.stopPropagation()}
60
46
  onClick={(event) => {
61
47
  event.stopPropagation();
48
+ // Display number alongside the real key: the undo-history label must
49
+ // announce the same row this button just did (see `onToggleTrackHidden`).
62
50
  void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
63
51
  }}
64
52
  >
65
- {visibilityButtonIcon(showAsMute, hidden)}
53
+ {visibilityButtonIcon(hidden)}
66
54
  </button>
67
55
  );
68
56
  }
@@ -77,10 +65,8 @@ export function PlainTrackHeader({
77
65
  showTrackLabel,
78
66
  isTrackHidden,
79
67
  isAudioTrack,
80
- isGroupMuted,
81
- isSoloed,
82
- onToggleSolo,
83
68
  onToggleTrackHidden,
69
+ trailing,
84
70
  }: {
85
71
  trackNumber: number;
86
72
  trackDisplayNumber: number | null;
@@ -90,37 +76,61 @@ export function PlainTrackHeader({
90
76
  isAudioTrack: boolean;
91
77
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
92
78
  showTrackLabel: boolean;
93
- isGroupMuted: boolean;
94
- isSoloed: boolean;
95
- onToggleSolo?: (options?: { add?: boolean }) => void;
79
+ /** Trailing controls that belong on the control line — the FX entry points,
80
+ * which the caller owns because only it knows the clip they act on. */
81
+ trailing?: React.ReactNode;
96
82
  }) {
97
83
  return (
98
84
  <>
99
- {isAudioTrack && (
100
- <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
101
- )}
102
- {showTrackLabel && (
103
- <span
104
- className={`min-w-0 flex-1 truncate text-[11px] ${
105
- isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
106
- }`}
107
- title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
108
- >
109
- {trackLabel}
110
- </span>
111
- )}
112
- {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
113
- <VisibilityButton
114
- hidden={isTrackHidden}
115
- trackNumber={trackNumber}
116
- trackDisplayNumber={trackDisplayNumber}
117
- visible
118
- isAudioTrack={isAudioTrack}
119
- onToggle={onToggleTrackHidden}
120
- />
121
- {isAudioTrack && onToggleSolo && (
122
- <TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
123
- )}
85
+ {/* One line: the name, then every control pushed to the right edge. The
86
+ two-line split this replaced existed to stop four controls truncating
87
+ the name — but the name already truncates on its own (`min-w-0` plus
88
+ `truncate`), and the controls are `shrink-0`, so they hold the edge
89
+ and the name gives way instead. */}
90
+ <div className="flex min-w-0 items-center gap-1">
91
+ {isAudioTrack && (
92
+ <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
93
+ )}
94
+ {/* No `flex-1`: the name takes only the width it needs, so the clip
95
+ count sits against it rather than being pushed out to meet the
96
+ controls. The slack goes to the `ml-auto` group below instead.
97
+
98
+ Wraps rather than truncating: a truncated name needs a hover to be
99
+ read at all, and a tooltip is no use to a name you are scanning a
100
+ column of. `break-words` so a long single token breaks instead of
101
+ forcing the column wider. */}
102
+ {showTrackLabel && (
103
+ <span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
104
+ )}
105
+ {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
106
+ {/* `ml-auto` is what anchors the group right: it absorbs the slack the
107
+ truncating name leaves, so the controls sit on the edge whatever the
108
+ name's length. */}
109
+ <div className="ml-auto flex shrink-0 items-center gap-1">
110
+ {/* Not on an audio track. The control is the old visibility eye, and
111
+ on audio it silences rather than hides — but a row that already says
112
+ what it is with a speaker does not also need the hide affordance
113
+ sitting in the eye's slot. `visible={false}` rather than omitting the
114
+ element, so the spacer keeps every row's control columns aligned.
115
+
116
+ EXCEPT when the audio track is ALREADY hidden. Withholding the
117
+ control unconditionally withheld the only way back: `data-hidden`
118
+ silences the clip in preview and drops it from the render, the
119
+ panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
120
+ else writes it — so a track hidden before this rule (or by "Hide
121
+ all", or by hand) was silent with no control anywhere to restore it.
122
+ Offering the eye only in that state keeps the affordance off a normal
123
+ audio row while leaving the door open from the inside. */}
124
+ <VisibilityButton
125
+ hidden={isTrackHidden}
126
+ trackNumber={trackNumber}
127
+ trackDisplayNumber={trackDisplayNumber}
128
+ visible={!isAudioTrack || isTrackHidden}
129
+ onToggle={onToggleTrackHidden}
130
+ />
131
+ {trailing}
132
+ </div>
133
+ </div>
124
134
  </>
125
135
  );
126
136
  }
@@ -4,9 +4,11 @@ import {
4
4
  automationLaneLabel,
5
5
  automationLaneLabelParts,
6
6
  elementAutomation,
7
+ elementAutomationLanes,
7
8
  groupAutomationLanes,
8
9
  laneGroupKey,
9
10
  elementFxChain,
11
+ isCarveLane,
10
12
  } from "./automationLaneData";
11
13
  import type { TimelineElement } from "../store/timelineElement";
12
14
 
@@ -268,3 +270,95 @@ describe("groupAutomationLanes", () => {
268
270
  expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
269
271
  });
270
272
  });
273
+
274
+ // A carve writes its own filter bands AND the lanes that drive them, and
275
+ // `withoutCarveLanes` replaces every one of them on each re-run — so a drag on
276
+ // one is discarded the next time the carve analyses. The rack also counts the
277
+ // carve as ONE module rather than the filters it compiles to, so a lane per
278
+ // band reads as "automation on effects I removed", which is exactly how it was
279
+ // reported.
280
+ describe("carve-generated lanes", () => {
281
+ const carved = (): TimelineElement => ({
282
+ id: "vo",
283
+ tag: "audio",
284
+ start: 0,
285
+ duration: 5,
286
+ track: 0,
287
+ fxChain: JSON.stringify({
288
+ version: 1,
289
+ nodes: [
290
+ {
291
+ type: "peaking",
292
+ id: "n1",
293
+ fromCarve: true,
294
+ params: { frequency: 160, gain: -6, q: 1.4 },
295
+ },
296
+ { type: "peaking", id: "n2", params: { frequency: 900, gain: -3, q: 1 } },
297
+ ],
298
+ }),
299
+ automation: JSON.stringify({
300
+ version: 1,
301
+ lanes: [
302
+ {
303
+ target: "fx.n1.gain",
304
+ points: [
305
+ { t: 0, v: 0 },
306
+ { t: 1, v: -4 },
307
+ ],
308
+ },
309
+ {
310
+ target: "fx.n2.gain",
311
+ points: [
312
+ { t: 0, v: 0 },
313
+ { t: 1, v: -2 },
314
+ ],
315
+ },
316
+ {
317
+ target: "volume",
318
+ points: [
319
+ { t: 0, v: 1 },
320
+ { t: 1, v: 0.5 },
321
+ ],
322
+ },
323
+ ],
324
+ }),
325
+ });
326
+
327
+ // These lanes used to be withheld. A bed whose EVERY lane is the carve's —
328
+ // which is what a plain voiceover carve produces — then showed no automation
329
+ // at all and no control to reveal any, so the carve looked like it had done
330
+ // nothing. The ducking curve is what a carve IS; it is shown, and marked
331
+ // read-only because the carve rewrites it on each analysis.
332
+ it("draws the carve's lanes alongside the author's", () => {
333
+ const targets = elementAutomationLanes(carved()).map((lane) => lane.target);
334
+ expect(targets).toContain("fx.n1.gain");
335
+ expect(targets).toContain("fx.n2.gain");
336
+ expect(targets).toContain("volume");
337
+ });
338
+
339
+ it("counts a carve band as a timeline row, so the row reserves its height", () => {
340
+ const rows = groupAutomationLanes([carved()]);
341
+ expect(rows).toHaveLength(3);
342
+ });
343
+
344
+ it("tells a carve's lane from the author's on the same element", () => {
345
+ const chain = elementFxChain(carved());
346
+ expect(isCarveLane("fx.n1.gain", chain)).toBe(true);
347
+ // n2 is a hand-built peaking band with the same parameter — only the
348
+ // `fromCarve` tag separates them.
349
+ expect(isCarveLane("fx.n2.gain", chain)).toBe(false);
350
+ expect(isCarveLane("volume", chain)).toBe(false);
351
+ });
352
+
353
+ it("treats every lane as the author's when nothing is carve-tagged", () => {
354
+ const plain = {
355
+ ...carved(),
356
+ fxChain: JSON.stringify({
357
+ version: 1,
358
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 160, gain: -6, q: 1.4 } }],
359
+ }),
360
+ };
361
+ expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
362
+ expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false);
363
+ });
364
+ });
@@ -91,7 +91,30 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
91
91
  });
92
92
  }
93
93
 
94
+ /**
95
+ * Is this lane one the CARVE wrote, rather than the author?
96
+ *
97
+ * The carve compiles to tagged nodes and rewrites their envelopes on every
98
+ * re-run (`withoutCarveLanes` replaces each one), so a drag on such a lane is
99
+ * silently discarded the next time it analyses. Read-only rather than hidden:
100
+ * the ducking curve is what a carve IS, and the whole reason to look at a
101
+ * carved bed in the timeline is to see where it makes room. Hiding them left a
102
+ * bed whose every lane was the carve's showing no automation at all and no
103
+ * control to reveal any — which reads as the carve having done nothing.
104
+ */
105
+ export function isCarveLane(target: string, chain: HfAudioFxChain | null): boolean {
106
+ return (chain?.nodes ?? []).some(
107
+ (node) => node.fromCarve && node.id && target.startsWith(`fx.${node.id}.`),
108
+ );
109
+ }
110
+
94
111
  /** Lanes in the order they are drawn, one row each. */
112
+ /**
113
+ * The lanes a TIMELINE row should draw.
114
+ *
115
+ * Every lane the element carries, the carve's included — see `isCarveLane` for
116
+ * why those are shown read-only instead of withheld.
117
+ */
95
118
  export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
96
119
  return elementAutomation(element).lanes;
97
120
  }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { groupAutomationElement } from "./groupAutomationElement";
3
+ import { groupAutomationLanes } from "./automationLaneData";
4
+
5
+ const GROUP = {
6
+ id: "voiceover",
7
+ label: "Voiceover",
8
+ anchorKey: 1.5,
9
+ automation: JSON.stringify({
10
+ version: 1,
11
+ lanes: [
12
+ {
13
+ target: "volume",
14
+ points: [
15
+ { t: 0, v: 1 },
16
+ { t: 5, v: 0.4 },
17
+ ],
18
+ },
19
+ ],
20
+ }),
21
+ };
22
+
23
+ describe("groupAutomationElement", () => {
24
+ // §1.3: "A group's automation clock is COMPOSITION time — decide it, do not
25
+ // inherit it." A clip-local span would land the group's fade at a different
26
+ // moment in preview than the render bakes it.
27
+ it("spans the whole composition from zero, so lane times are composition time", () => {
28
+ const el = groupAutomationElement(GROUP, 60);
29
+ expect(el.start).toBe(0);
30
+ expect(el.duration).toBe(60);
31
+ });
32
+
33
+ // The lane machinery filters with `isAudioTimelineElement`; a group that does
34
+ // not pass it renders nothing at all, silently.
35
+ it("is admitted by the lane machinery and yields the group's own lanes", () => {
36
+ const lanes = groupAutomationLanes([groupAutomationElement(GROUP, 60)]);
37
+ expect(lanes).toHaveLength(1);
38
+ expect(lanes[0]?.name).toBeTruthy();
39
+ });
40
+
41
+ // The write has to land on `<hf-audio-group>`, not on a member clip.
42
+ it("carries the group's DOM id, so a lane edit addresses the group element", () => {
43
+ expect(groupAutomationElement(GROUP, 60).domId).toBe("voiceover");
44
+ });
45
+
46
+ // A group with no automation draws no lanes — and must not throw doing it.
47
+ it("yields no lanes when the group automates nothing", () => {
48
+ const bare = { id: "sfx", label: "SFX", anchorKey: 2.5 };
49
+ expect(groupAutomationLanes([groupAutomationElement(bare, 60)])).toHaveLength(0);
50
+ });
51
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The group, as the automation lanes see it.
3
+ *
4
+ * Lanes are keyed by `TimelineElement` everywhere — the slot, the binder, the
5
+ * identity used for selection. A group is not one: it has no clip id, no start
6
+ * and no duration, which is §1.9's "a group is the first real audio entity in
7
+ * the system" problem showing up in the row model. Rather than give lanes a
8
+ * second, parallel path, the group borrows the shape it does not have.
9
+ *
10
+ * `start: 0` and `duration` = the composition's is not a placeholder, it is the
11
+ * clock: the design doc fixes a group's automation clock as COMPOSITION time
12
+ * (§1.3), and a missing `data-start` parses as 0, which is exactly that. So a
13
+ * lane drawn against this element lands at the same seconds the render bakes.
14
+ */
15
+
16
+ import type { TimelineElement } from "../store/playerStore";
17
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
18
+
19
+ /** The synthetic element's track number — the group row's own anchor. */
20
+ export function groupAutomationElement(
21
+ group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
22
+ compositionDuration: number,
23
+ ): TimelineElement {
24
+ return {
25
+ id: group.id,
26
+ // The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
27
+ domId: group.id,
28
+ label: group.label,
29
+ // Audio, so `isAudioTimelineElement` admits it and the lanes render at all.
30
+ tag: "audio",
31
+ start: 0,
32
+ duration: compositionDuration,
33
+ track: group.anchorKey,
34
+ ...(group.automation ? { automation: group.automation } : {}),
35
+ ...(group.fxChain ? { fxChain: group.fxChain } : {}),
36
+ };
37
+ }
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
73
73
  * two different orderings: the header's comes from the group-aware row list
74
74
  * (synthetic anchor rows, members pulled contiguous), the history's from a
75
75
  * plain ascending sort of element-bearing keys. Once a group exists those
76
- * disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
76
+ * disagree, so the same click said "Hide track 2" and recorded "Hide track 1".
77
77
  * Passing the rendered number keeps one answer instead of two derivations.
78
78
  */
79
79
  onToggleTrackHidden?: (
@@ -93,7 +93,11 @@ export interface TimelineEditCallbacks {
93
93
  /** C1's ungrouped-track FX pointer: "Group these clips" — write
94
94
  * `data-audio-group` on every one of them, atomically. Same shape B6's
95
95
  * carve auto-grouping uses. */
96
- onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
96
+ onGroupClips?: (
97
+ clipIds: readonly string[],
98
+ groupId: string,
99
+ groupLabel?: string,
100
+ ) => Promise<void>;
97
101
  /** C1's single-clip FX write: addressed by the clip itself rather than the
98
102
  * current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
99
103
  onSetElementAttributeLive?: (
@@ -58,7 +58,7 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
58
58
  selectedElementId: "active",
59
59
  selectedElementIds: new Set(),
60
60
  expandedClipIds: new Set(["active"]),
61
- expandedGroupIds: new Set(),
61
+ collapsedGroupIds: new Set(),
62
62
  expandedLaneOwnerIds: new Set(),
63
63
  groups: [],
64
64
  trackGroupOf: new Map(),
@@ -248,7 +248,7 @@ describe("resolveTimelineNavigationTarget", () => {
248
248
  selectedElementId: null,
249
249
  selectedElementIds: new Set(),
250
250
  expandedClipIds: new Set(),
251
- expandedGroupIds: new Set(),
251
+ collapsedGroupIds: new Set(),
252
252
  expandedLaneOwnerIds: new Set(),
253
253
  groups: [],
254
254
  trackGroupOf: new Map(),