@hyperframes/studio 0.7.107 → 0.7.109

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,193 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
6
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
7
+ import { PAD_X } from "./automationLaneGeometry";
8
+ import { getTimelineLaneTop } from "./timelineLayout";
9
+ import { elementAutomation, elementAutomationLanes, elementFxChain } from "./automationLaneData";
10
+ import type { AutomationLaneBinding, UseAutomationLanesResult } from "./useAutomationLanes";
11
+ import type { TimelineElement } from "../store/timelineElement";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ const element: TimelineElement = {
16
+ id: "bgm",
17
+ key: "bgm",
18
+ tag: "audio",
19
+ start: 0,
20
+ duration: 6,
21
+ track: 0,
22
+ };
23
+
24
+ function mountSlot(binding: Partial<AutomationLaneBinding>) {
25
+ const onRangeClear = vi.fn();
26
+ const lanes: UseAutomationLanesResult = {
27
+ bind: () => ({
28
+ automation: { version: 1, lanes: [] },
29
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
30
+ chain: null,
31
+ onPreview: vi.fn(),
32
+ onCommit: vi.fn(),
33
+ onSelect: vi.fn(),
34
+ readOnly: false,
35
+ commitTargetKey: "bgm",
36
+ selection: null,
37
+ onRangeSelect: vi.fn(),
38
+ onRangeClear,
39
+ ...binding,
40
+ }),
41
+ };
42
+ const host = document.createElement("div");
43
+ document.body.append(host);
44
+ act(() => {
45
+ createRoot(host).render(
46
+ <TimelineAutomationLaneSlot
47
+ elements={[element]}
48
+ isSelected={() => false}
49
+ lanes={lanes}
50
+ pps={100}
51
+ laneCount={0}
52
+ accentColor="#0af"
53
+ currentTime={0}
54
+ />,
55
+ );
56
+ });
57
+ return { onRangeClear };
58
+ }
59
+
60
+ /** Two narration slices sharing a row, each with its own chain. */
61
+ const chainOf = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
62
+ const lanesOf = (...targets: string[]) =>
63
+ JSON.stringify({
64
+ version: 1,
65
+ lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
66
+ });
67
+
68
+ const narration1: TimelineElement = {
69
+ ...element,
70
+ id: "narration-1",
71
+ key: "narration-1",
72
+ start: 0,
73
+ duration: 4,
74
+ fxChain: chainOf([
75
+ { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
76
+ { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
77
+ ]),
78
+ automation: lanesOf("fx.n2.q"),
79
+ };
80
+ const narration2: TimelineElement = {
81
+ ...element,
82
+ id: "narration-2",
83
+ key: "narration-2",
84
+ start: 4,
85
+ duration: 4,
86
+ fxChain: chainOf([{ type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } }]),
87
+ automation: lanesOf("fx.n1.q", "volume"),
88
+ };
89
+
90
+ /** Reads what a clip really carries, the way the live binding does. */
91
+ const readingBind = (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => ({
92
+ automation: elementAutomation(element),
93
+ lanes: elementAutomationLanes(element),
94
+ chain: elementFxChain(element),
95
+ onPreview: vi.fn(),
96
+ onCommit: vi.fn(),
97
+ onSelect: vi.fn(),
98
+ readOnly: !isSelected,
99
+ commitTargetKey: null,
100
+ selection: null,
101
+ onRangeSelect: vi.fn(),
102
+ onRangeClear: vi.fn(),
103
+ });
104
+
105
+ function renderRow(elements: readonly TimelineElement[], selectedKey?: string): HTMLElement {
106
+ const host = document.createElement("div");
107
+ document.body.append(host);
108
+ act(() => {
109
+ createRoot(host).render(
110
+ <TimelineAutomationLaneSlot
111
+ elements={elements}
112
+ isSelected={(el) => el.key === selectedKey}
113
+ lanes={{ bind: readingBind }}
114
+ pps={100}
115
+ laneCount={0}
116
+ accentColor="#0af"
117
+ currentTime={0}
118
+ />,
119
+ );
120
+ });
121
+ return host;
122
+ }
123
+
124
+ /** Every drawn envelope as `row @ left`, which is the whole claim under test. */
125
+ function mountRow(elements: readonly TimelineElement[], selectedKey?: string) {
126
+ return [...renderRow(elements, selectedKey).querySelectorAll<HTMLElement>(".hf-automation-lane")]
127
+ .map((lane) => `${lane.style.top} @ ${lane.querySelector("svg")?.style.left}`)
128
+ .sort();
129
+ }
130
+
131
+ const ROW_0 = `${getTimelineLaneTop(0)}px`;
132
+ const ROW_1 = `${getTimelineLaneTop(0) + AUTOMATION_LANE_H}px`;
133
+
134
+ describe("TimelineAutomationLaneSlot shared rows", () => {
135
+ it("draws two clips' envelopes for one property in the same row", () => {
136
+ // One lane track, two envelopes — same row, each over its own span. The
137
+ // 1 kHz peaking Q is `fx.n2.q` on one clip and `fx.n1.q` on the other.
138
+ expect(mountRow([narration1, narration2])).toEqual(
139
+ [
140
+ `${ROW_0} @ ${0 - PAD_X}px`,
141
+ `${ROW_0} @ ${400 - PAD_X}px`,
142
+ `${ROW_1} @ ${400 - PAD_X}px`,
143
+ ].sort(),
144
+ );
145
+ });
146
+
147
+ it("leaves a clip's stretch empty in a row it does not automate", () => {
148
+ // Only narration-2 has a volume envelope, so row 1 carries one curve and
149
+ // narration-1's half of it stays blank rather than drawing a flat line.
150
+ expect(mountRow([narration1, narration2]).filter((row) => row.startsWith(ROW_1))).toEqual([
151
+ `${ROW_1} @ ${400 - PAD_X}px`,
152
+ ]);
153
+ });
154
+
155
+ it("lets each clip's envelope be reached, not just the one drawn last", () => {
156
+ // Every clip mounts a band spanning the WHOLE row (the row separator needs
157
+ // the full width), so three bands sit on top of each other here. If a band
158
+ // took the pointer, the last one rendered would swallow hover and drag over
159
+ // its siblings' envelopes — handles appeared only on the final clip, and
160
+ // only on rows that a single clip happened to own.
161
+ const host = renderRow([narration1, narration2]);
162
+ const bands = [...host.querySelectorAll<HTMLElement>(".hf-automation-lane")];
163
+ expect(bands.length).toBeGreaterThan(1);
164
+ for (const band of bands) {
165
+ expect(band.className).toContain("pointer-events-none");
166
+ expect(band.querySelector("svg")?.getAttribute("class")).toContain("pointer-events-auto");
167
+ }
168
+ });
169
+
170
+ it("keeps the same rows whichever clip is selected", () => {
171
+ // The bug this replaces: the row listed only the selected clip's lanes, so
172
+ // clicking a sibling swapped which envelopes existed.
173
+ expect(mountRow([narration1, narration2], "narration-1")).toEqual(
174
+ mountRow([narration1, narration2], "narration-2"),
175
+ );
176
+ });
177
+ });
178
+
179
+ describe("TimelineAutomationLaneSlot stale-selection guard", () => {
180
+ it("clears the selection when its lane's target no longer exists", () => {
181
+ const { onRangeClear } = mountSlot({
182
+ selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2, v0: 0, v1: 1 },
183
+ });
184
+ expect(onRangeClear).toHaveBeenCalledTimes(1);
185
+ });
186
+
187
+ it("leaves an in-scope selection alone", () => {
188
+ const { onRangeClear } = mountSlot({
189
+ selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2, v0: 0, v1: 1 },
190
+ });
191
+ expect(onRangeClear).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -232,10 +232,23 @@ describe("TimelineLanes track numbering", () => {
232
232
  describe("TimelineLanes disclosure target", () => {
233
233
  const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
234
234
 
235
- function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
235
+ /**
236
+ * `aria-controls` is an ID LIST, and the caret needs one: it reveals the
237
+ * active clip's keyframe lanes AND the track's automation lanes, which cannot
238
+ * be one element — one belongs to a clip, the other to the row.
239
+ */
240
+ function ariaControlsIds(host: HTMLElement): string[] {
236
241
  const caret = host.querySelector("button[aria-controls]");
237
- const id = caret?.getAttribute("aria-controls");
238
- return id ? host.querySelector<HTMLElement>(`#${id}`) : null;
242
+ return (caret?.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean);
243
+ }
244
+
245
+ function ariaControlsTargets(host: HTMLElement): (HTMLElement | null)[] {
246
+ return ariaControlsIds(host).map((id) => host.querySelector<HTMLElement>(`#${id}`));
247
+ }
248
+
249
+ /** The first region named, which is the keyframe lanes. */
250
+ function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
251
+ return ariaControlsTargets(host)[0] ?? null;
239
252
  }
240
253
 
241
254
  // aria-controls used to name a div in the sticky label column: it computed to
@@ -246,6 +259,9 @@ describe("TimelineLanes disclosure target", () => {
246
259
 
247
260
  expect(target).not.toBeNull();
248
261
  expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
262
+ // Every region it names has to exist, or the caret points at nothing.
263
+ expect(ariaControlsTargets(view.host).length).toBeGreaterThan(1);
264
+ expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true);
249
265
  act(() => view.root.unmount());
250
266
  });
251
267
 
@@ -255,6 +271,9 @@ describe("TimelineLanes disclosure target", () => {
255
271
 
256
272
  expect(target).not.toBeNull();
257
273
  expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
274
+ // Including the automation region, which is mounted empty while collapsed
275
+ // for exactly this reason.
276
+ expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true);
258
277
  act(() => view.root.unmount());
259
278
  });
260
279
 
@@ -266,8 +285,8 @@ describe("TimelineLanes disclosure target", () => {
266
285
  const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
267
286
 
268
287
  const idsFor = (host: HTMLElement) =>
269
- Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) =>
270
- caret.getAttribute("aria-controls"),
288
+ Array.from(host.querySelectorAll("button[aria-controls]")).flatMap((caret) =>
289
+ (caret.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean),
271
290
  );
272
291
  const firstIds = idsFor(first.host);
273
292
  const secondIds = idsFor(second.host);
@@ -3,25 +3,30 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
5
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
6
+ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
7
+ import { useAutomationLanes } from "./useAutomationLanes";
8
+ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
6
9
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
7
- import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
10
+ import {
11
+ isTrackRowExpanded,
12
+ resolveTrackKeyframeClip,
13
+ trackShowsBeatStrip,
14
+ } from "./useTimelineTrackLayout";
8
15
  import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
9
16
  import { clipTimingStart } from "../../hooks/gsapShared";
10
- import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
11
- import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
12
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
17
+ import { getTimelineEditCapabilities } from "./timelineEditing";
18
+ import { CLIP_Y, TRACK_H } from "./timelineLayout";
19
+ import { usePlayerStore } from "../store/playerStore";
13
20
  import {
14
21
  isMultiDragActive,
15
22
  isMultiDragPassenger,
16
23
  multiDragDeltaSeconds,
17
- type MultiDragPreviewInput,
18
24
  multiDragPassengerOffsetPx,
19
25
  } from "./timelineMultiDragPreview";
20
- import type { TimelineLaneBaseProps } from "./timelineLaneProps";
21
- import type { TimelineEditCallbacks } from "./timelineCallbacks";
26
+ import type { TimelineLanesProps } from "./timelineLaneProps";
22
27
  import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
23
- import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
24
28
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
29
+ import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
25
30
  import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
26
31
  import { TimelineTrackRow } from "./TimelineTrackRow";
27
32
  import { isTimelineClipActive } from "./useTimelineActiveClips";
@@ -31,18 +36,6 @@ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
31
36
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
32
37
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
33
38
 
34
- interface TimelineLanesProps extends TimelineLaneBaseProps {
35
- /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
36
- draggedElement: TimelineElement | null;
37
- multiDragPreview: MultiDragPreviewInput | null;
38
- onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
39
- onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
40
- onResizeElement: TimelineEditCallbacks["onResizeElement"];
41
- onMoveElement: TimelineEditCallbacks["onMoveElement"];
42
- onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
43
- onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
44
- }
45
-
46
39
  export function TimelineLanes({
47
40
  pps,
48
41
  contentOrigin,
@@ -109,6 +102,10 @@ export function TimelineLanes({
109
102
  // from resolving into a second timeline that renders the same logical rows.
110
103
  const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
111
104
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
105
+ const automationLanes = useAutomationLanes();
106
+ useAutomationSelectionKeyboard({ lanes: automationLanes });
107
+ const expandClips = usePlayerStore((s) => s.expandClips);
108
+ const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
112
109
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
113
110
  const logicalRowsByTrack = useMemo(() => {
114
111
  const byTrack = new Map<number, TimelineLogicalRow[]>();
@@ -119,6 +116,16 @@ export function TimelineLanes({
119
116
  }
120
117
  return byTrack;
121
118
  }, [logicalRows]);
119
+ // The caret belongs to the ROW, so it opens and closes every clip on it at
120
+ // once. Toggling only the active clip left the row's state depending on which
121
+ // sibling happened to be selected: expand one, click another, and the row
122
+ // collapsed under a caret that still pointed down.
123
+ const toggleRowExpandedTracked = (keys: readonly string[]) => {
124
+ const willExpand = !keys.some((key) => expandedClipIds.has(key));
125
+ trackStudioKeyframeLaneExpand({ expanded: willExpand });
126
+ if (willExpand) expandClips(keys);
127
+ else for (const key of keys) setClipExpanded(key, false);
128
+ };
122
129
  const toggleClipExpandedTracked = (key: string) => {
123
130
  const willExpand = !expandedClipIds.has(key);
124
131
  trackStudioKeyframeLaneExpand({ expanded: willExpand });
@@ -184,12 +191,13 @@ export function TimelineLanes({
184
191
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
185
192
  // All lanes use the same uniform color — no alternating stripes.
186
193
  const rowBackground = theme.rowBackground;
187
- // Keep diamonds below the beat strip on the active/music track.
188
- const beatStripOnTrack =
189
- (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
190
- (selectedElementId
191
- ? els.some((e) => (e.key ?? e.id) === selectedElementId)
192
- : els.some(isMusicTrack));
194
+ // The beat-dot strip occupies the top of this track's lane (active track,
195
+ // or the music track when nothing is selected). When shown, keyframe
196
+ // diamonds shrink + drop to the bottom half so they don't collide with it.
197
+ const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
198
+ selectedElementId,
199
+ isMusicTrack,
200
+ });
193
201
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
194
202
  const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
195
203
  // Only the selected/most-keyframed clip owns expanded lanes on a shared track.
@@ -200,10 +208,45 @@ export function TimelineLanes({
200
208
  selectedElementIds,
201
209
  );
202
210
  const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
203
- const keyframeClipExpanded =
204
- keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
205
- // Link the sticky caret to the canvas lanes with a stable display-row id.
211
+ const rowExpanded = isTrackRowExpanded(els, expandedClipIds);
212
+ // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a
213
+ // clip left to fill it painted its waveform straight over them so the
214
+ // bar is capped for every clip on the row, not just the one whose
215
+ // property lanes are showing. Undefined means "fill the row", which is
216
+ // right only while it is collapsed and the row is nothing but bar.
217
+ const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
218
+ // The clips whose envelopes this row draws, at their dragged positions.
219
+ // Once per row, not once per clip in the map below.
220
+ const automationElements = els.map(getPreviewElement);
221
+ // Minted here because this is the only place that sees BOTH ends of
222
+ // the disclosure: the caret in the sticky header and the diamond lanes
223
+ // on the canvas. Keyed by display row, not by `trackNum`, which is a
224
+ // fractional sort key and would mint ids like `...-0.16666666666666666`.
206
225
  const lanesId = `${lanesIdPrefix}-track-${row}`;
226
+ // The caret reveals two canvas regions now: the active clip's keyframe
227
+ // lanes and the track's automation lanes. They cannot be one element —
228
+ // one belongs to a clip, the other to the row — so the caret names both.
229
+ const automationLanesId = `${lanesId}-automation`;
230
+ // The header's remove buttons write through the same binding the lanes
231
+ // themselves edit through, so a deletion persists exactly like dragging
232
+ // a point does — and the binding reports read-only for an unselected
233
+ // clip, which is what leaves the buttons off rather than offering one
234
+ // that cannot act.
235
+ const headerLanes =
236
+ keyframeClip && keyframeClipKey
237
+ ? automationLanes.bind(
238
+ keyframeClip,
239
+ selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey),
240
+ )
241
+ : null;
242
+ const removeAutomationLane =
243
+ headerLanes && !headerLanes.readOnly
244
+ ? (target: string) =>
245
+ headerLanes.onCommit({
246
+ version: 1,
247
+ lanes: headerLanes.lanes.filter((lane) => lane.target !== target),
248
+ })
249
+ : undefined;
207
250
  return (
208
251
  <TimelineTrackRow
209
252
  key={rowKey}
@@ -212,6 +255,7 @@ export function TimelineLanes({
212
255
  logicalRow={logicalRow}
213
256
  propertyRows={trackLogicalRows.slice(1)}
214
257
  lanesId={lanesId}
258
+ headerLanesId={`${lanesId} ${automationLanesId}`}
215
259
  top={rowGeometry.getRowTop(row)}
216
260
  height={rowHeight}
217
261
  virtualized={rowsVirtualized}
@@ -230,23 +274,24 @@ export function TimelineLanes({
230
274
  els[0]?.id ??
231
275
  `Track${trackDisplaySuffix(displayNumber)}`
232
276
  }
233
- lanesId={lanesId}
277
+ lanesId={`${lanesId} ${automationLanesId}`}
234
278
  contentOrigin={contentOrigin}
235
279
  keyframeClip={keyframeClip}
280
+ trackElements={els}
236
281
  clipCount={els.length}
237
- isExpanded={keyframeClipExpanded}
282
+ isExpanded={rowExpanded}
238
283
  animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
239
284
  currentTime={currentTime}
240
285
  isTrackHidden={isTrackHidden}
241
286
  isAudioTrack={isAudioTrack}
242
287
  theme={theme}
243
288
  onToggleClipExpanded={() => {
244
- if (keyframeClipKey) {
245
- toggleClipExpandedTracked(keyframeClipKey);
246
- }
289
+ const keys = els.map(getTimelineElementIdentity);
290
+ if (keys.length > 0) toggleRowExpandedTracked(keys);
247
291
  }}
248
292
  onToggleTrackHidden={onToggleTrackHidden}
249
293
  onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
294
+ onRemoveAutomationLane={removeAutomationLane}
250
295
  onSeek={onSeek}
251
296
  rovingTargetId={keyboard.rovingTargetId}
252
297
  />
@@ -319,7 +364,7 @@ export function TimelineLanes({
319
364
  // other clips (incl. siblings on a shared track) show compact
320
365
  // diamonds on their own bar instead.
321
366
  const isTrackKeyframeClip = elementKey === keyframeClipKey;
322
- const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
367
+ const showsLanes = isTrackKeyframeClip && rowExpanded;
323
368
  const capabilities = getTimelineEditCapabilities(el);
324
369
  const isSelected =
325
370
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
@@ -344,6 +389,29 @@ export function TimelineLanes({
344
389
  const passengerOffsetPx = isPassenger
345
390
  ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
346
391
  : 0;
392
+ const clipGestures = createClipGestureHandlers(
393
+ el,
394
+ elementKey,
395
+ previewElement,
396
+ capabilities,
397
+ {
398
+ pps,
399
+ onResizeElement,
400
+ onMoveElement,
401
+ onRazorSplit,
402
+ onRazorSplitAll,
403
+ blockedClipRef,
404
+ shiftClickClipRef,
405
+ suppressClickRef,
406
+ scrollRef,
407
+ setShowPopover,
408
+ setRangeSelection,
409
+ setResizingClip,
410
+ setDraggedClip,
411
+ setSelectedElementId,
412
+ onSelectElement,
413
+ },
414
+ );
347
415
  const clip = (
348
416
  <TimelineClip
349
417
  key={clipKey}
@@ -354,7 +422,7 @@ export function TimelineLanes({
354
422
  el={previewElement}
355
423
  pps={pps}
356
424
  clipY={CLIP_Y}
357
- clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
425
+ clipHeight={clipBarHeight}
358
426
  isSelected={isSelected}
359
427
  isHovered={hoveredClip === clipKey}
360
428
  isDragging={false}
@@ -368,123 +436,9 @@ export function TimelineLanes({
368
436
  }
369
437
  onHoverStart={() => setHoveredClip(clipKey)}
370
438
  onHoverEnd={() => setHoveredClip(null)}
371
- onResizeStart={
372
- // fallow-ignore-next-line complexity
373
- (edge, e) => {
374
- if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
375
- if (edge === "start" && !capabilities.canTrimStart) return;
376
- if (edge === "end" && !capabilities.canTrimEnd) return;
377
- e.stopPropagation();
378
- blockedClipRef.current = null;
379
- setShowPopover(false);
380
- setRangeSelection(null);
381
- setResizingClip({
382
- pointerId: e.pointerId,
383
- element: el,
384
- edge,
385
- originClientX: e.clientX,
386
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
387
- previewStart: el.start,
388
- previewDuration: el.duration,
389
- previewPlaybackStart: el.playbackStart,
390
- started: false,
391
- });
392
- }
393
- }
394
- onPointerDown={
395
- // fallow-ignore-next-line complexity
396
- (e) => {
397
- if (e.button !== 0) return;
398
- if (usePlayerStore.getState().activeTool === "razor") return;
399
- if (e.shiftKey) {
400
- shiftClickClipRef.current = {
401
- element: el,
402
- anchorX: e.clientX,
403
- anchorY: e.clientY,
404
- };
405
- return;
406
- }
407
- const target = e.currentTarget as HTMLElement;
408
- const rect = target.getBoundingClientRect();
409
- const blockedIntent = resolveBlockedTimelineEditIntent({
410
- width: rect.width,
411
- offsetX: e.clientX - rect.left,
412
- handleWidth: CLIP_HANDLE_W,
413
- capabilities,
414
- });
415
- if (
416
- blockedIntent &&
417
- ((blockedIntent === "move" && onMoveElement) ||
418
- (blockedIntent !== "move" && onResizeElement))
419
- ) {
420
- blockedClipRef.current = {
421
- pointerId: e.pointerId,
422
- element: el,
423
- intent: blockedIntent,
424
- originClientX: e.clientX,
425
- originClientY: e.clientY,
426
- started: false,
427
- };
428
- return;
429
- }
430
- if (!onMoveElement || !capabilities.canMove) return;
431
- blockedClipRef.current = null;
432
- setShowPopover(false);
433
- setRangeSelection(null);
434
- setDraggedClip({
435
- pointerId: e.pointerId,
436
- element: el,
437
- originClientX: e.clientX,
438
- originClientY: e.clientY,
439
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
440
- originScrollTop: scrollRef.current?.scrollTop ?? 0,
441
- pointerClientX: e.clientX,
442
- pointerClientY: e.clientY,
443
- pointerOffsetX: e.clientX - rect.left,
444
- pointerOffsetY: e.clientY - rect.top,
445
- previewStart: el.start,
446
- previewTrack: el.track,
447
- desiredTrack: el.track,
448
- insertRow: null,
449
- snapTime: null,
450
- snapType: null,
451
- started: false,
452
- });
453
- }
454
- }
455
- onClick={
456
- // fallow-ignore-next-line complexity
457
- (e) => {
458
- e.stopPropagation();
459
- if (suppressClickRef.current) return;
460
- const { activeTool } = usePlayerStore.getState();
461
- if (activeTool === "razor" && onRazorSplit) {
462
- const clipRect = (
463
- e.currentTarget as HTMLElement
464
- ).getBoundingClientRect();
465
- const clickOffsetX = e.clientX - clipRect.left;
466
- const splitTime = previewElement.start + clickOffsetX / pps;
467
- const clampedTime = Math.max(
468
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
469
- Math.min(
470
- previewElement.start +
471
- previewElement.duration -
472
- SPLIT_BOUNDARY_EPSILON_S,
473
- splitTime,
474
- ),
475
- );
476
- if (e.shiftKey && onRazorSplitAll) {
477
- onRazorSplitAll(clampedTime);
478
- } else {
479
- onRazorSplit(el, clampedTime);
480
- }
481
- return;
482
- }
483
- // Clip selection is idempotent; empty timeline space owns deselection.
484
- setSelectedElementId(elementKey);
485
- onSelectElement?.(el);
486
- }
487
- }
439
+ onResizeStart={clipGestures.onResizeStart}
440
+ onPointerDown={clipGestures.onPointerDown}
441
+ onClick={clipGestures.onClick}
488
442
  onDoubleClick={(e) => {
489
443
  e.stopPropagation();
490
444
  if (suppressClickRef.current) return;
@@ -562,7 +516,11 @@ export function TimelineLanes({
562
516
  suppressClickRef={suppressClickRef}
563
517
  />
564
518
  );
565
- // One keyed child prevents window shifts from remounting focused clips.
519
+
520
+ // Keep one keyed top-level child per element. Returning an
521
+ // array here makes React reconcile the outer array by
522
+ // position, so a window shift remounts otherwise stable
523
+ // clip keys and can tear down focus mid-reveal.
566
524
  if (!isPassenger) {
567
525
  return (
568
526
  <Fragment key={clipKey}>
@@ -590,6 +548,36 @@ export function TimelineLanes({
590
548
  );
591
549
  })
592
550
  }
551
+ {/* The automation lanes belong to the ROW, so they are mounted
552
+ here rather than under the active clip's property lanes.
553
+ Hanging off that clip meant selecting a sibling moved the
554
+ whole subtree into a different clip's element and remounted
555
+ every lane — which threw away each lane's hover state (and
556
+ any gesture mid-flight), so pressing a lane to select its
557
+ clip made the handles you were reaching for disappear.
558
+
559
+ Mounted in BOTH disclosure states, empty while collapsed, so
560
+ the caret's aria-controls resolves either way — same reason
561
+ the keyframe lanes are. Absolute positions inside resolve
562
+ against this same relative row, so the geometry is unchanged
563
+ by the move. */}
564
+ <div id={automationLanesId}>
565
+ {rowExpanded ? (
566
+ <TimelineAutomationLaneSlot
567
+ elements={automationElements}
568
+ isSelected={(element) => {
569
+ const key = getTimelineElementIdentity(element);
570
+ return selectedElementId === key || selectedElementIds.has(key);
571
+ }}
572
+ lanes={automationLanes}
573
+ pps={pps}
574
+ laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0}
575
+ accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent}
576
+ currentTime={currentTime}
577
+ beatTimes={beatAnalysis?.beatTimes}
578
+ />
579
+ ) : null}
580
+ </div>
593
581
  </div>
594
582
  </TimelineTrackRow>
595
583
  );