@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
@@ -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(),
@@ -77,8 +77,8 @@ export interface BuildTimelineLogicalRowsInput {
77
77
  selectedElementId: string | null;
78
78
  selectedElementIds: ReadonlySet<string>;
79
79
  expandedClipIds: ReadonlySet<string>;
80
- /** Groups whose member rows the caret has shown (structural, not lanes). */
81
- expandedGroupIds: ReadonlySet<string>;
80
+ /** Groups the caret has COLLAPSED — absent means expanded, the default. */
81
+ collapsedGroupIds: ReadonlySet<string>;
82
82
  /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
83
83
  expandedLaneOwnerIds: ReadonlySet<string>;
84
84
  groups: readonly TimelineTrackGroupInfo[];
@@ -251,7 +251,7 @@ export function buildTimelineLogicalRows({
251
251
  selectedElementId,
252
252
  selectedElementIds,
253
253
  expandedClipIds,
254
- expandedGroupIds,
254
+ collapsedGroupIds,
255
255
  expandedLaneOwnerIds,
256
256
  groups,
257
257
  trackGroupOf,
@@ -273,7 +273,8 @@ export function buildTimelineLogicalRows({
273
273
  selectedElementIds,
274
274
  gsapAnimations,
275
275
  );
276
- const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
276
+ const disclosable = isTrackDisclosable(elements, lanes.length);
277
+ const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable;
277
278
  rows.push({
278
279
  id: trackId,
279
280
  kind: "row",
@@ -282,7 +283,7 @@ export function buildTimelineLogicalRows({
282
283
  level,
283
284
  parentId,
284
285
  elementId: activeId,
285
- expandable: lanes.length > 0,
286
+ expandable: disclosable,
286
287
  expanded,
287
288
  items: clipItems(trackId, elements),
288
289
  });
@@ -300,7 +301,7 @@ export function buildTimelineLogicalRows({
300
301
  // count.
301
302
  function emitGroup(group: TimelineTrackGroupInfo): void {
302
303
  const groupRowId = timelineGroupRowId(group.id);
303
- const groupExpanded = expandedGroupIds.has(group.id);
304
+ const groupExpanded = !collapsedGroupIds.has(group.id);
304
305
  rows.push({
305
306
  id: groupRowId,
306
307
  kind: "row",
@@ -315,8 +316,10 @@ export function buildTimelineLogicalRows({
315
316
  items: [],
316
317
  });
317
318
  if (expandedLaneOwnerIds.has(group.id)) {
318
- const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
319
- for (const laneGroup of groupAutomationLanes(memberElements)) {
319
+ // The group's own member list, not `trackMap`: a COLLAPSED group can have
320
+ // its lane shelf open, and its members are absent from the display list —
321
+ // so looking them up there emitted zero lane rows for exactly that case.
322
+ for (const laneGroup of groupAutomationLanes(group.memberElements)) {
320
323
  rows.push({
321
324
  id: `${groupRowId}::${laneGroup.key}`,
322
325
  kind: "row",
@@ -452,3 +455,17 @@ export function resolveTimelineFocusFallback(
452
455
  }
453
456
  return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
454
457
  }
458
+
459
+ /**
460
+ * Does a track have anything to open — the header's own `disclosable`.
461
+ *
462
+ * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and
463
+ * keyed on tweens alone here an audio track whose only disclosable content is
464
+ * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid,
465
+ * so ArrowRight could not open it. Automation rows are counted per shared
466
+ * PROPERTY across the track's clips, the way the header counts them, not per
467
+ * clip.
468
+ */
469
+ function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean {
470
+ return laneCount > 0 || groupAutomationLanes(elements).length > 0;
471
+ }
@@ -7,7 +7,6 @@ export const GUTTER = 32;
7
7
  export const LABEL_COL_W = 232;
8
8
  export const TRACK_H = 48;
9
9
  export const LANE_H = 28;
10
- export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
11
10
  export const RULER_H = 24;
12
11
  export const CLIP_Y = 3;
13
12
  export const CLIP_HANDLE_W = 18;
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
4
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
5
  import { animationContributesLane } from "./TimelinePropertyLanes";
6
6
 
7
- export function hasKeyframedTimelineClips(
7
+ function hasKeyframedTimelineClips(
8
8
  animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
9
9
  ): boolean {
10
10
  return Array.from(animationsByElement.values()).some((animations) =>
@@ -12,6 +12,35 @@ export function hasKeyframedTimelineClips(
12
12
  );
13
13
  }
14
14
 
15
+ /**
16
+ * Does the timeline need the wide label column?
17
+ *
18
+ * Keyframed clips need it for their property-lane names. Audio GROUPS need it
19
+ * for the same reason a keyframed clip does — a row whose name has nowhere else
20
+ * to go. A track row survives a narrow gutter because its CLIPS carry the name
21
+ * on the bar; a group row has no clips at all, so in the 80px gutter its label
22
+ * rendered at zero width and its FX and lane buttons were clipped off the
23
+ * side.
24
+ *
25
+ * Widening the column for the whole timeline, rather than letting just the
26
+ * group row overhang: the header is sticky and opaque, so an oversized one
27
+ * painted a slab across the rest of its own row, stayed pinned there through
28
+ * horizontal scroll, and had the playhead drawn straight through it.
29
+ */
30
+ export function timelineNeedsLabelColumn(
31
+ animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
32
+ elements: readonly TimelineElement[],
33
+ ): boolean {
34
+ return (
35
+ hasKeyframedTimelineClips(animationsByElement) ||
36
+ // The column exists FOR the group rows, so this must stay in step with
37
+ // whatever decides they are drawn (`useTimelineTrackDerivations`) —
38
+ // widening it with no group row on screen is a 232px shift of every clip
39
+ // with nothing to explain it.
40
+ elements.some((element) => Boolean(element.audioGroup))
41
+ );
42
+ }
43
+
15
44
  export function getEffectiveTimelineDuration(
16
45
  duration: number,
17
46
  elements: readonly TimelineElement[],
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The rows a track header draws BELOW its own line: one per disclosed keyframe
3
+ * property group, one per automation envelope.
4
+ *
5
+ * Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
6
+ * 763 lines against the studio's 600-line cap. Nothing else moved: these three
7
+ * components never read the header's state, only their own props, which is why
8
+ * they are the seam.
9
+ */
10
+
11
+ import type { TimelineElement } from "../store/playerStore";
12
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
13
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
+ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
15
+ import {
16
+ resolveLaneHeaderState,
17
+ type KeyframeNavigationState,
18
+ type TimelinePropertyLane,
19
+ } from "./trackHeaderLaneState";
20
+ import { valueReadout } from "./trackHeaderLaneValues";
21
+ import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
22
+
23
+ // Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
24
+ function PropertyGroupNavigation({
25
+ navigation,
26
+ label,
27
+ expandedElement,
28
+ onSeek,
29
+ children,
30
+ }: {
31
+ navigation: KeyframeNavigationState;
32
+ label: string;
33
+ expandedElement: TimelineElement;
34
+ onSeek?: (time: number) => void;
35
+ children: React.ReactNode;
36
+ }) {
37
+ // The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
38
+ // target is met with a centered transparent ::before overlay instead of a
39
+ // bigger box; focus-visible matches every other control in this header.
40
+ const CHEVRON_BUTTON_CLASS =
41
+ "relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
42
+ "focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
43
+ "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
44
+ "before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
45
+ const seekTo = (keyframe: { percentage: number } | null) => {
46
+ if (keyframe) {
47
+ onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
48
+ }
49
+ };
50
+ return (
51
+ <span className="flex shrink-0 items-center gap-0.5">
52
+ <button
53
+ type="button"
54
+ aria-label={`Previous ${label} keyframe`}
55
+ disabled={!navigation.prevKeyframe}
56
+ className={CHEVRON_BUTTON_CLASS}
57
+ onClick={(event) => {
58
+ event.stopPropagation();
59
+ seekTo(navigation.prevKeyframe);
60
+ }}
61
+ >
62
+
63
+ </button>
64
+ {children}
65
+ <button
66
+ type="button"
67
+ aria-label={`Next ${label} keyframe`}
68
+ disabled={!navigation.nextKeyframe}
69
+ className={CHEVRON_BUTTON_CLASS}
70
+ onClick={(event) => {
71
+ event.stopPropagation();
72
+ seekTo(navigation.nextKeyframe);
73
+ }}
74
+ >
75
+
76
+ </button>
77
+ </span>
78
+ );
79
+ }
80
+
81
+ export function PropertyGroupHeaderRow({
82
+ lanesId,
83
+ lane,
84
+ laneIndex,
85
+ isLastLane,
86
+ expandedElement,
87
+ currentTime,
88
+ clipPercentage,
89
+ gutterBackground,
90
+ columnWidth,
91
+ onTogglePropertyGroupKeyframe,
92
+ onSeek,
93
+ rovingTargetId = null,
94
+ }: {
95
+ lanesId: string;
96
+ lane: TimelinePropertyLane;
97
+ laneIndex: number;
98
+ isLastLane: boolean;
99
+ expandedElement: TimelineElement;
100
+ currentTime: number;
101
+ clipPercentage: number;
102
+ gutterBackground: string;
103
+ columnWidth: number;
104
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
105
+ onSeek?: (time: number) => void;
106
+ rovingTargetId: string | null;
107
+ }) {
108
+ const elementId = expandedElement.key ?? expandedElement.id;
109
+ const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
110
+ lane,
111
+ currentTime,
112
+ clipPercentage,
113
+ );
114
+
115
+ return (
116
+ <div
117
+ id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
118
+ data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
119
+ data-timeline-element-id={elementId}
120
+ tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
121
+ data-property-group={lane.group}
122
+ data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
123
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
124
+ style={{
125
+ top: getTimelineLaneTop(laneIndex),
126
+ // The header column narrows to contentOrigin whenever that is under
127
+ // LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
128
+ // readout over the canvas, on top of the clips it is labelling.
129
+ width: columnWidth,
130
+ height: LANE_H,
131
+ background: gutterBackground,
132
+ }}
133
+ >
134
+ {/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
135
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
136
+ <span
137
+ className="absolute left-1.5 top-0 w-px bg-white/15"
138
+ style={{ height: isLastLane ? "50%" : "100%" }}
139
+ />
140
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
141
+ </span>
142
+ <span className="w-[46px] shrink-0 truncate text-white" title={label}>
143
+ {label}
144
+ </span>
145
+ <PropertyGroupNavigation
146
+ navigation={navigation}
147
+ label={label}
148
+ expandedElement={expandedElement}
149
+ onSeek={onSeek}
150
+ >
151
+ <button
152
+ type="button"
153
+ aria-pressed={!!navigation.currentKeyframe}
154
+ aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
155
+ title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
156
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
157
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
158
+ onClick={(event) => {
159
+ // Same as the disclosure caret and the eye: a control in the label
160
+ // column owns its click, it does not also hit the track row behind it.
161
+ event.stopPropagation();
162
+ if (toggleTarget) {
163
+ void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
164
+ }
165
+ }}
166
+ >
167
+ {navigation.currentKeyframe ? "◆" : "◇"}
168
+ </button>
169
+ </PropertyGroupNavigation>
170
+ <span
171
+ className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
172
+ title={valueReadout(lane.group, values)}
173
+ >
174
+ {valueReadout(lane.group, values)}
175
+ </span>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ /**
181
+ * One envelope's row in the label column.
182
+ *
183
+ * Named here rather than inside the lane, on the same tree connector the
184
+ * keyframe rows use: an automation lane is a child of its clip exactly as a
185
+ * property group is, and drawing its name over the envelope put the label on top
186
+ * of the curve it describes and scrolled it away from its own row.
187
+ */
188
+ export function AutomationLaneHeaderRow({
189
+ target,
190
+ label,
191
+ name,
192
+ param,
193
+ alsoAutomatedBy,
194
+ top,
195
+ isLastLane,
196
+ gutterBackground,
197
+ columnWidth,
198
+ onRemove,
199
+ isCarve,
200
+ onReveal,
201
+ }: {
202
+ /** The lane the ACTIVE clip draws in this row, or null when it draws none —
203
+ * the row belongs to the property, and a clip may be absent from it. */
204
+ target: string | null;
205
+ /** The whole thing on one line: the row's identity, its tooltip, and the
206
+ * remove button's name. */
207
+ label: string;
208
+ /** What the effect is — "Peaking EQ 1.6 kHz". */
209
+ name: string;
210
+ /** Which knob the envelope drives. Empty when there is no second line to draw. */
211
+ param: string;
212
+ /** Set when this clip's group automates the SAME parameter. Gain stages
213
+ * multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
214
+ * who drew one curve and then another hears something quieter than either
215
+ * with nothing on screen to say why (groups doc §5). Not a warning; an
216
+ * explanation. */
217
+ alsoAutomatedBy?: string;
218
+ top: number;
219
+ isLastLane: boolean;
220
+ gutterBackground: string;
221
+ columnWidth: number;
222
+ onRemove?: (target: string) => void;
223
+ /** The carve owns this envelope and rewrites it on every re-run, so it is
224
+ * shown but not the author's to edit or delete. */
225
+ isCarve?: boolean;
226
+ /** Reveal this parameter in the rack. Absent, the name is inert rather than a
227
+ * button that looks live and does nothing. */
228
+ onReveal?: () => void;
229
+ }) {
230
+ return (
231
+ <div
232
+ data-automation-lane-label={label}
233
+ data-timeline-lane-top={top}
234
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
235
+ style={{
236
+ top,
237
+ width: columnWidth,
238
+ height: AUTOMATION_LANE_H,
239
+ background: gutterBackground,
240
+ }}
241
+ >
242
+ {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
243
+ tick at the name's own height. */}
244
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
245
+ <span
246
+ className="absolute left-1.5 top-0 w-px bg-white/15"
247
+ style={{ height: isLastLane ? "50%" : "100%" }}
248
+ />
249
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
250
+ </span>
251
+ {/* Two lines: what the effect is, then which knob the envelope drives. On
252
+ one line a band's own name was the first thing truncated in a column this
253
+ narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
254
+ bands apart. */}
255
+ {/* A button, because the name IS the way to the knob: clicking it opens
256
+ the rack on the effect this envelope drives and scrolls to it. Nothing
257
+ else in the timeline can get there — a lane names a parameter, and the
258
+ rack is where a parameter is set. */}
259
+ <button
260
+ type="button"
261
+ tabIndex={-1}
262
+ aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
263
+ title={onReveal ? `Show ${label} in the effect rack` : label}
264
+ disabled={!onReveal}
265
+ className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
266
+ onPointerDown={(event) => event.stopPropagation()}
267
+ onClick={(event) => {
268
+ // The label column owns its click; it does not also fall through to
269
+ // the track row behind it and move the selection.
270
+ event.stopPropagation();
271
+ onReveal?.();
272
+ }}
273
+ >
274
+ <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
275
+ {name}
276
+ </span>
277
+ {param ? (
278
+ <span
279
+ data-automation-lane-param=""
280
+ className="truncate font-mono text-[9px] text-white/40"
281
+ >
282
+ {param}
283
+ </span>
284
+ ) : null}
285
+ {alsoAutomatedBy ? (
286
+ <span
287
+ data-automation-lane-also=""
288
+ className="truncate text-[9px] text-[#F5C542]/80"
289
+ title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
290
+ >
291
+ {alsoAutomatedBy} is also fading this.
292
+ </span>
293
+ ) : null}
294
+ </button>
295
+ {/* Beside the name it labels, because that is the only place an envelope is
296
+ named at all: a carve writes its own lanes, and the FX panel's automate
297
+ toggle can only reach a parameter it still lists — so without this an
298
+ envelope could be created and never removed.
299
+
300
+ Only for the clip the header is showing, since that is the only one a
301
+ write can reach: a shared row's other envelopes belong to clips that
302
+ are not selected, and a button that silently removed one of them (or
303
+ none) would be worse than no button. */}
304
+ {/* Not on a carve's own lane: the carve regenerates it on the next
305
+ analysis, so removing one band would come back and reads as the
306
+ button not working. Switching the carve off in the rack is what
307
+ removes them, all together, which is how they were made. */}
308
+ {onRemove && target !== null && !isCarve && (
309
+ <button
310
+ type="button"
311
+ aria-label={`Remove ${label} automation`}
312
+ title={`Remove ${label} automation`}
313
+ // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
314
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
315
+ onPointerDown={(event) => event.stopPropagation()}
316
+ onClick={(event) => {
317
+ // A control in the label column owns its click; it does not also hit
318
+ // the track row behind it and change the selection.
319
+ event.stopPropagation();
320
+ onRemove(target);
321
+ }}
322
+ >
323
+ ×
324
+ </button>
325
+ )}
326
+ </div>
327
+ );
328
+ }