@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
@@ -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
  }
@@ -59,6 +59,34 @@ export function armGroupDrag(
59
59
  };
60
60
  }
61
61
 
62
+ /**
63
+ * Snapshot the two endpoints owned by a segment drag.
64
+ *
65
+ * The pointer itself is the anchor: a line can be grabbed anywhere along its
66
+ * span, and the segment must not jump so either endpoint takes the pointer's
67
+ * place when the gesture starts.
68
+ */
69
+ export function armSegmentDrag(
70
+ lane: HfAutomationLane,
71
+ index: number,
72
+ anchor: { t: number; v: number },
73
+ ): GroupDragSnapshot | null {
74
+ const a = lane.points[index];
75
+ const b = lane.points[index + 1];
76
+ if (!a || !b) return null;
77
+ return {
78
+ points: lane.points.map((p) => ({ ...p })),
79
+ indices: [index, index + 1],
80
+ anchor: { ...anchor },
81
+ selection: {
82
+ t0: a.t,
83
+ t1: b.t,
84
+ v0: Math.min(a.v, b.v),
85
+ v1: Math.max(a.v, b.v),
86
+ },
87
+ };
88
+ }
89
+
62
90
  export interface GroupMoveResult {
63
91
  points: HfAutomationLane["points"];
64
92
  selection: AutomationSelectionBox;
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
35
35
  export const MIN_POINT_GAP_SEC = 0.001;
36
36
  /** Hit radius for grabbing a point, in px. */
37
37
  export const GRAB_PX = 7;
38
+ /** Distance from the drawn envelope that offers a segment drag, in px. */
39
+ export const SEGMENT_GRAB_PX = 5;
38
40
  /** Samples used to draw a segment the eye should see as curved. */
39
41
  export const DRAW_SAMPLES = 64;
40
42
  /**
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
222
224
  return best;
223
225
  }
224
226
 
227
+ /** Draw commands from one breakpoint to the next, sampled when it is curved. */
228
+ function segmentLineCommands(input: {
229
+ lane: HfAutomationLane;
230
+ range: AutomationRange;
231
+ index: number;
232
+ xOf(t: number): number;
233
+ yOf(v: number): number;
234
+ }): string[] {
235
+ const { lane, range, index, xOf, yOf } = input;
236
+ const a = lane.points[index];
237
+ const b = lane.points[index + 1];
238
+ if (!a || !b) return [];
239
+ // A via point bends the segment with no `curve` of its own, so the
240
+ // straight-line shortcut has to rule out both.
241
+ if (!a.curve && a.viaX === undefined && range.scale === "linear") {
242
+ return [`L ${xOf(b.t)} ${yOf(b.v)}`];
243
+ }
244
+ return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
245
+ const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
246
+ return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
247
+ });
248
+ }
249
+
225
250
  /**
226
251
  * The svg path for one lane's envelope.
227
252
  *
@@ -247,24 +272,38 @@ export function envelopePath(input: {
247
272
  }
248
273
  const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
249
274
  for (let i = 0; i + 1 < lane.points.length; i += 1) {
250
- const a = lane.points[i];
251
- const b = lane.points[i + 1];
252
- if (!a || !b) continue;
253
- // A via point bends the segment with no `curve` of its own, so the
254
- // straight-line shortcut has to rule out both.
255
- if (!a.curve && a.viaX === undefined && range.scale === "linear") {
256
- pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
257
- continue;
258
- }
259
- for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
260
- const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
261
- pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
262
- }
275
+ pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
263
276
  }
264
277
  pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
265
278
  return pts.join(" ");
266
279
  }
267
280
 
281
+ /**
282
+ * The visible path for one segment, without the lane's constant extensions.
283
+ *
284
+ * Used for the hover/drag affordance: only the segment under the pointer grows
285
+ * heavier, rather than making the entire envelope look selected. It samples by
286
+ * the same rule as `envelopePath`, so a curved or logarithmic segment's hover
287
+ * stroke sits exactly on the line the audio model draws.
288
+ */
289
+ export function envelopeSegmentPath(input: {
290
+ lane: HfAutomationLane;
291
+ range: AutomationRange;
292
+ index: number;
293
+ xOf(t: number): number;
294
+ yOf(v: number): number;
295
+ }): string | null {
296
+ const { lane, range, index, xOf, yOf } = input;
297
+ const a = lane.points[index];
298
+ const b = lane.points[index + 1];
299
+ if (!a || !b) return null;
300
+ const pts = [
301
+ `M ${xOf(a.t)} ${yOf(a.v)}`,
302
+ ...segmentLineCommands({ lane, range, index, xOf, yOf }),
303
+ ];
304
+ return pts.join(" ");
305
+ }
306
+
268
307
  export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
269
308
  return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
270
309
  }