@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
4
+ import { groupAutomationLanes } from "./automationLaneData";
4
5
  import {
5
6
  timelineKeyframeSelectionKey,
6
7
  type TimelineKeyframeTarget,
@@ -8,11 +9,13 @@ import {
8
9
  import {
9
10
  timelineClipFocusId,
10
11
  timelineEaseFocusId,
12
+ timelineGroupRowId,
11
13
  timelineKeyframeFocusId,
12
14
  timelinePropertyRowId,
13
15
  timelineTrackRowId,
14
16
  } from "./timelineNavigationIdentity";
15
17
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
18
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
16
19
 
17
20
  export type TimelineNavigationKey =
18
21
  | "ArrowLeft"
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
54
57
  kind: "row";
55
58
  physicalTrackKey: number;
56
59
  logicalIndex: number;
57
- level: 1 | 2;
60
+ level: 1 | 2 | 3;
58
61
  parentId: string | null;
59
62
  elementId: string | null;
63
+ /** Set only on a group's own row (level 1, no clips of its own). */
64
+ groupId?: string;
60
65
  expandable: boolean;
61
66
  expanded: boolean;
62
67
  propertyGroup?: PropertyGroupName;
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
65
70
 
66
71
  export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
67
72
 
68
- interface BuildTimelineLogicalRowsInput {
73
+ export interface BuildTimelineLogicalRowsInput {
69
74
  tracks: readonly (readonly [number, readonly TimelineElement[]])[];
70
75
  displayTrackOrder: readonly number[];
71
76
  laneCounts: ReadonlyMap<string, number>;
72
77
  selectedElementId: string | null;
73
78
  selectedElementIds: ReadonlySet<string>;
74
79
  expandedClipIds: ReadonlySet<string>;
80
+ /** Groups whose member rows the caret has shown (structural, not lanes). */
81
+ expandedGroupIds: ReadonlySet<string>;
82
+ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
83
+ expandedLaneOwnerIds: ReadonlySet<string>;
84
+ groups: readonly TimelineTrackGroupInfo[];
85
+ trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
75
86
  gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
76
87
  }
77
88
 
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
106
117
  });
107
118
  }
108
119
 
120
+ /** A track's active clip (if any), its element id, and its automation lanes. */
121
+ function resolveActiveTrackClip(
122
+ elements: readonly TimelineElement[],
123
+ laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
124
+ selectedElementId: string | null,
125
+ selectedElementIds: ReadonlySet<string>,
126
+ gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
127
+ ): {
128
+ activeClip: TimelineElement | null;
129
+ activeId: string | null;
130
+ lanes: ReturnType<typeof getTimelinePropertyLanes>;
131
+ } {
132
+ const activeClip = resolveTrackKeyframeClip(
133
+ elements,
134
+ laneCounts,
135
+ selectedElementId,
136
+ selectedElementIds,
137
+ );
138
+ const activeId = activeClip ? elementId(activeClip) : null;
139
+ const lanes = activeClip
140
+ ? getTimelinePropertyLanes(
141
+ gsapAnimations.get(elementId(activeClip)) ?? [],
142
+ activeClip.start,
143
+ activeClip.duration,
144
+ )
145
+ : [];
146
+ return { activeClip, activeId, lanes };
147
+ }
148
+
109
149
  function keyframeTarget(
110
150
  keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
111
151
  ): TimelineKeyframeTarget {
@@ -167,6 +207,42 @@ function propertyItems(
167
207
  return items;
168
208
  }
169
209
 
210
+ /** A clip's lanes are visible when either the caret or the `∿` button opened it. */
211
+ function isRowOpen(
212
+ activeId: string | null,
213
+ expandedClipIds: ReadonlySet<string>,
214
+ expandedLaneOwnerIds: ReadonlySet<string>,
215
+ ): boolean {
216
+ if (activeId === null) return false;
217
+ return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
218
+ }
219
+
220
+ /** A single automation-lane row, one level deeper than the track/group row that owns it. */
221
+ function buildLaneRow(
222
+ track: number,
223
+ logicalIndex: number,
224
+ activeId: string,
225
+ activeClip: TimelineElement,
226
+ lane: ReturnType<typeof getTimelinePropertyLanes>[number],
227
+ level: 2 | 3,
228
+ parentId: string,
229
+ ): TimelineLogicalRow {
230
+ const laneRowId = timelinePropertyRowId(activeId, lane.group);
231
+ return {
232
+ id: laneRowId,
233
+ kind: "row",
234
+ physicalTrackKey: track,
235
+ logicalIndex,
236
+ level,
237
+ parentId,
238
+ elementId: activeId,
239
+ expandable: false,
240
+ expanded: false,
241
+ propertyGroup: lane.group,
242
+ items: propertyItems(laneRowId, activeClip, lane.keyframes),
243
+ };
244
+ }
245
+
170
246
  /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
171
247
  export function buildTimelineLogicalRows({
172
248
  tracks,
@@ -175,57 +251,98 @@ export function buildTimelineLogicalRows({
175
251
  selectedElementId,
176
252
  selectedElementIds,
177
253
  expandedClipIds,
254
+ expandedGroupIds,
255
+ expandedLaneOwnerIds,
256
+ groups,
257
+ trackGroupOf,
178
258
  gsapAnimations,
179
259
  }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
180
260
  const trackMap = new Map(tracks);
261
+ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
181
262
  const rows: TimelineLogicalRow[] = [];
182
- for (const track of displayTrackOrder) {
263
+
264
+ // A real track's own row (level 1 ungrouped, level 2 under a group) plus,
265
+ // when its clip's lanes are open, the lane rows one level deeper.
266
+ function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
183
267
  const elements = trackMap.get(track) ?? [];
184
268
  const trackId = timelineTrackRowId(track);
185
- const activeClip = resolveTrackKeyframeClip(
269
+ const { activeClip, activeId, lanes } = resolveActiveTrackClip(
186
270
  elements,
187
271
  laneCounts,
188
272
  selectedElementId,
189
273
  selectedElementIds,
274
+ gsapAnimations,
190
275
  );
191
- const activeId = activeClip ? elementId(activeClip) : null;
192
- const lanes = activeClip
193
- ? getTimelinePropertyLanes(
194
- gsapAnimations.get(elementId(activeClip)) ?? [],
195
- activeClip.start,
196
- activeClip.duration,
197
- )
198
- : [];
199
- const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
276
+ const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
200
277
  rows.push({
201
278
  id: trackId,
202
279
  kind: "row",
203
280
  physicalTrackKey: track,
204
281
  logicalIndex: rows.length,
205
- level: 1,
206
- parentId: null,
282
+ level,
283
+ parentId,
207
284
  elementId: activeId,
208
285
  expandable: lanes.length > 0,
209
286
  expanded,
210
287
  items: clipItems(trackId, elements),
211
288
  });
212
- if (!expanded || !activeClip) continue;
289
+ if (!expanded || !activeClip || !activeId) return;
213
290
  for (const lane of lanes) {
214
- const rowId = timelinePropertyRowId(activeId, lane.group);
215
- rows.push({
216
- id: rowId,
217
- kind: "row",
218
- physicalTrackKey: track,
219
- logicalIndex: rows.length,
220
- level: 2,
221
- parentId: trackId,
222
- elementId: activeId,
223
- expandable: false,
224
- expanded: false,
225
- propertyGroup: lane.group,
226
- items: propertyItems(rowId, activeClip, lane.keyframes),
227
- });
291
+ rows.push(
292
+ buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
293
+ );
294
+ }
295
+ }
296
+
297
+ // A group's own row (level 1) plus, when its `∿` is open, its own
298
+ // automation-lane rows (level 2) — structural content deferred to whatever
299
+ // step wires group automation editing; this reserves the rows and their
300
+ // count.
301
+ function emitGroup(group: TimelineTrackGroupInfo): void {
302
+ const groupRowId = timelineGroupRowId(group.id);
303
+ const groupExpanded = expandedGroupIds.has(group.id);
304
+ rows.push({
305
+ id: groupRowId,
306
+ kind: "row",
307
+ physicalTrackKey: group.anchorKey,
308
+ logicalIndex: rows.length,
309
+ level: 1,
310
+ parentId: null,
311
+ elementId: null,
312
+ groupId: group.id,
313
+ expandable: group.memberTracks.length > 0,
314
+ expanded: groupExpanded,
315
+ items: [],
316
+ });
317
+ if (expandedLaneOwnerIds.has(group.id)) {
318
+ const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
319
+ for (const laneGroup of groupAutomationLanes(memberElements)) {
320
+ rows.push({
321
+ id: `${groupRowId}::${laneGroup.key}`,
322
+ kind: "row",
323
+ physicalTrackKey: group.anchorKey,
324
+ logicalIndex: rows.length,
325
+ level: 2,
326
+ parentId: groupRowId,
327
+ elementId: null,
328
+ expandable: false,
329
+ expanded: false,
330
+ items: [],
331
+ });
332
+ }
333
+ }
334
+ if (!groupExpanded) return;
335
+ for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
336
+ }
337
+
338
+ for (const key of displayTrackOrder) {
339
+ const group = groupByAnchor.get(key);
340
+ if (group) {
341
+ emitGroup(group);
342
+ continue;
228
343
  }
344
+ if (trackGroupOf.has(key)) continue; // emitted above, under its group
345
+ emitTrack(key, 1, null);
229
346
  }
230
347
  return rows;
231
348
  }
@@ -13,6 +13,7 @@ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
13
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
14
14
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
15
15
  import type { TimelineClipRenderContext } from "./TimelineTypes";
16
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
16
17
 
17
18
  /**
18
19
  * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
@@ -99,6 +100,8 @@ export interface TimelineLaneBaseProps {
99
100
  */
100
101
  onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
101
102
  beatAnalysis?: MusicBeatAnalysis | null;
103
+ /** Resolved audio groups, positioned in row order — see useTimelineTrackDerivations. */
104
+ groups: readonly TimelineTrackGroupInfo[];
102
105
  }
103
106
 
104
107
  /**
@@ -7,10 +7,10 @@ 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
10
11
  export const RULER_H = 24;
11
12
  export const CLIP_Y = 3;
12
13
  export const CLIP_HANDLE_W = 18;
13
-
14
14
  export interface TimelineBeatEntry {
15
15
  readonly index: number;
16
16
  readonly time: number;
@@ -13,6 +13,10 @@ export function timelineTrackRowId(track: number): string {
13
13
  return stableId("track", track);
14
14
  }
15
15
 
16
+ export function timelineGroupRowId(groupId: string): string {
17
+ return stableId("group", groupId);
18
+ }
19
+
16
20
  export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
17
21
  return stableId("property", elementId, group);
18
22
  }
@@ -0,0 +1,38 @@
1
+ import { useMemo } from "react";
2
+ import { usePlayerStore } from "../store/playerStore";
3
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
4
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
5
+
6
+ /** The four pieces of group-disclosure state a group row's header reads and writes. */
7
+ export function useTimelineGroupDisclosure() {
8
+ return {
9
+ expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
10
+ expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
11
+ toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
12
+ toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
13
+ };
14
+ }
15
+
16
+ /** Two lookups TimelineLanes needs once per render: a row's logical rows by
17
+ * physical key, and which physical key is a group's own anchor row. */
18
+ export function useTimelineLaneRowIndexes(
19
+ logicalRows: readonly TimelineLogicalRow[],
20
+ groups: readonly TimelineTrackGroupInfo[],
21
+ ) {
22
+ const logicalRowsByTrack = useMemo(() => {
23
+ const byTrack = new Map<number, TimelineLogicalRow[]>();
24
+ for (const logicalRow of logicalRows) {
25
+ const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
26
+ trackRows.push(logicalRow);
27
+ byTrack.set(logicalRow.physicalTrackKey, trackRows);
28
+ }
29
+ return byTrack;
30
+ }, [logicalRows]);
31
+
32
+ const groupByAnchor = useMemo(
33
+ () => new Map(groups.map((group) => [group.anchorKey, group])),
34
+ [groups],
35
+ );
36
+
37
+ return { logicalRowsByTrack, groupByAnchor };
38
+ }
@@ -3,6 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
4
  import type { TimelineRowGeometry } from "./timelineLayout";
5
5
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
6
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
6
7
  import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
7
8
  import { usePlayerStore } from "../store/playerStore";
8
9
  import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
@@ -15,6 +16,8 @@ interface TimelineLogicalFocusInput {
15
16
  laneCounts: ReadonlyMap<string, number>;
16
17
  selectedElementId: string | null;
17
18
  selectedElementIds: ReadonlySet<string>;
19
+ groups: readonly TimelineTrackGroupInfo[];
20
+ trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
18
21
  gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
19
22
  elements: readonly TimelineElement[];
20
23
  pixelsPerSecond: number;
@@ -32,6 +35,8 @@ interface TimelineLogicalFocusInput {
32
35
 
33
36
  export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
34
37
  const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
38
+ const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
39
+ const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
35
40
  const projectId = usePlayerStore((state) => state.timelineProjectId);
36
41
  const logicalRows = useTimelineLogicalRows({
37
42
  tracks: input.tracks,
@@ -40,6 +45,10 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
40
45
  selectedElementId: input.selectedElementId,
41
46
  selectedElementIds: input.selectedElementIds,
42
47
  expandedClipIds,
48
+ expandedGroupIds,
49
+ expandedLaneOwnerIds,
50
+ groups: input.groups,
51
+ trackGroupOf: input.trackGroupOf,
43
52
  gsapAnimations: input.gsapAnimations,
44
53
  });
45
54
  const focus = useTimelineFocusCoordinator({
@@ -22,6 +22,10 @@ const displayTrackOrder = tracks.map(([track]) => track);
22
22
  const laneCounts = new Map<string, number>();
23
23
  const selectedElementIds = new Set<string>();
24
24
  const expandedClipIds = new Set<string>();
25
+ const expandedGroupIds = new Set<string>();
26
+ const expandedLaneOwnerIds = new Set<string>();
27
+ const groups: never[] = [];
28
+ const trackGroupOf = new Map();
25
29
  const gsapAnimations = new Map();
26
30
 
27
31
  function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
@@ -33,6 +37,10 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
33
37
  selectedElementId: null,
34
38
  selectedElementIds,
35
39
  expandedClipIds,
40
+ expandedGroupIds,
41
+ expandedLaneOwnerIds,
42
+ groups,
43
+ trackGroupOf,
36
44
  gsapAnimations,
37
45
  });
38
46
  snapshots.push(logicalRows);
@@ -1,17 +1,10 @@
1
1
  import { useMemo } from "react";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import type { TimelineElement } from "../store/playerStore";
4
- import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
2
+ import {
3
+ buildTimelineLogicalRows,
4
+ type BuildTimelineLogicalRowsInput,
5
+ } from "./timelineKeyboardNavigation";
5
6
 
6
- interface TimelineLogicalRowsInput {
7
- tracks: readonly (readonly [number, readonly TimelineElement[]])[];
8
- displayTrackOrder: readonly number[];
9
- laneCounts: ReadonlyMap<string, number>;
10
- selectedElementId: string | null;
11
- selectedElementIds: ReadonlySet<string>;
12
- expandedClipIds: ReadonlySet<string>;
13
- gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
14
- }
7
+ type TimelineLogicalRowsInput = BuildTimelineLogicalRowsInput;
15
8
 
16
9
  /** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
17
10
  export function useTimelineLogicalRows({
@@ -21,6 +14,10 @@ export function useTimelineLogicalRows({
21
14
  selectedElementId,
22
15
  selectedElementIds,
23
16
  expandedClipIds,
17
+ expandedGroupIds,
18
+ expandedLaneOwnerIds,
19
+ groups,
20
+ trackGroupOf,
24
21
  gsapAnimations,
25
22
  }: TimelineLogicalRowsInput) {
26
23
  return useMemo(
@@ -32,11 +29,19 @@ export function useTimelineLogicalRows({
32
29
  selectedElementId,
33
30
  selectedElementIds,
34
31
  expandedClipIds,
32
+ expandedGroupIds,
33
+ expandedLaneOwnerIds,
34
+ groups,
35
+ trackGroupOf,
35
36
  gsapAnimations,
36
37
  }),
37
38
  [
38
39
  displayTrackOrder,
39
40
  expandedClipIds,
41
+ expandedGroupIds,
42
+ expandedLaneOwnerIds,
43
+ groups,
44
+ trackGroupOf,
40
45
  gsapAnimations,
41
46
  laneCounts,
42
47
  selectedElementId,
@@ -0,0 +1,30 @@
1
+ import { isMultiDragActive, multiDragDeltaSeconds } from "./timelineMultiDragPreview";
2
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
3
+ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
4
+
5
+ /** The extra render window a live multi-drag needs: its passengers still draw
6
+ * at their ORIGIN position, offset by the drag delta, outside the normal
7
+ * virtualized range. Empty when nothing is dragging. */
8
+ export function useTimelineMultiDragActorWindows(
9
+ multiDragPreview: MultiDragPreviewInput | null,
10
+ rowsVirtualized: boolean,
11
+ renderTimeRange: TimelineTimeRange,
12
+ ) {
13
+ const multiDragDelta =
14
+ multiDragPreview && isMultiDragActive(multiDragPreview)
15
+ ? multiDragDeltaSeconds(multiDragPreview)
16
+ : 0;
17
+ const actorWindows =
18
+ rowsVirtualized && multiDragPreview && multiDragDelta !== 0
19
+ ? [
20
+ {
21
+ range: {
22
+ start: renderTimeRange.start - multiDragDelta,
23
+ end: renderTimeRange.end - multiDragDelta,
24
+ },
25
+ identities: multiDragPreview.selectedKeys,
26
+ },
27
+ ]
28
+ : [];
29
+ return actorWindows;
30
+ }
@@ -1,20 +1,153 @@
1
1
  import { useMemo } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
+ import { isCanaryEnabled } from "../../telemetry/canary";
3
4
  import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
4
5
 
6
+ /** One resolved audio group, positioned in the row order. */
7
+ export interface TimelineTrackGroupInfo {
8
+ id: string;
9
+ label: string;
10
+ /**
11
+ * Synthetic sort key for the group's own row — the same fractional-key
12
+ * convention sub-composition expansion already uses (see
13
+ * timelineTrackDisplay.ts): the first (lowest) member track's number minus
14
+ * 0.5, so it slots in immediately above that member.
15
+ */
16
+ anchorKey: number;
17
+ /** Member track numbers, ascending. */
18
+ memberTracks: number[];
19
+ /** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
20
+ volume: number;
21
+ /** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
22
+ hidden: boolean;
23
+ /** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
24
+ fxChain?: string;
25
+ }
26
+
27
+ interface GroupMembership {
28
+ trackToGroupId: Map<number, string>;
29
+ memberTracksByGroup: Map<string, number[]>;
30
+ labelByGroup: Map<string, string>;
31
+ volumeByGroup: Map<string, number>;
32
+ hiddenByGroup: Map<string, boolean>;
33
+ fxChainByGroup: Map<string, string | undefined>;
34
+ }
35
+
36
+ /** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
37
+ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
38
+ const trackToGroupId = new Map<number, string>();
39
+ const memberTracksByGroup = new Map<string, number[]>();
40
+ const labelByGroup = new Map<string, string>();
41
+ const volumeByGroup = new Map<string, number>();
42
+ const hiddenByGroup = new Map<string, boolean>();
43
+ const fxChainByGroup = new Map<string, string | undefined>();
44
+ for (const [trackNum, elements] of rawTracks) {
45
+ const owner = elements.find((el) => el.audioGroup);
46
+ if (!owner?.audioGroup) continue;
47
+ trackToGroupId.set(trackNum, owner.audioGroup);
48
+ if (!labelByGroup.has(owner.audioGroup)) {
49
+ labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
50
+ volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
51
+ hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
52
+ fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
53
+ }
54
+ const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
55
+ members.push(trackNum);
56
+ memberTracksByGroup.set(owner.audioGroup, members);
57
+ }
58
+ return {
59
+ trackToGroupId,
60
+ memberTracksByGroup,
61
+ labelByGroup,
62
+ volumeByGroup,
63
+ hiddenByGroup,
64
+ fxChainByGroup,
65
+ };
66
+ }
67
+
68
+ /** One group's resolved row info, built once the first time its id is seen. */
69
+ function buildGroupInfo(
70
+ groupId: string,
71
+ fallbackTrackNum: number,
72
+ membership: GroupMembership,
73
+ ): TimelineTrackGroupInfo {
74
+ const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
75
+ (a, b) => a - b,
76
+ );
77
+ const fxChain = membership.fxChainByGroup.get(groupId);
78
+ return {
79
+ id: groupId,
80
+ label: membership.labelByGroup.get(groupId) ?? groupId,
81
+ anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
82
+ memberTracks,
83
+ volume: membership.volumeByGroup.get(groupId) ?? 1,
84
+ hidden: membership.hiddenByGroup.get(groupId) ?? false,
85
+ ...(fxChain ? { fxChain } : {}),
86
+ };
87
+ }
88
+
89
+ /** Push a group's synthetic anchor row plus its members' rows, contiguously. */
90
+ function emitGroupRows(
91
+ info: TimelineTrackGroupInfo,
92
+ rawByTrack: ReadonlyMap<number, TimelineElement[]>,
93
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>,
94
+ tracks: [number, TimelineElement[]][],
95
+ ): void {
96
+ tracks.push([info.anchorKey, []]);
97
+ for (const member of info.memberTracks) {
98
+ trackGroupOf.set(member, info);
99
+ tracks.push([member, rawByTrack.get(member) ?? []]);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Pull each group's members out of raw ascending track order and re-emit them
105
+ * contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
106
+ * their position; a group's members move up to sit under its anchor even when
107
+ * other (ungrouped) tracks were interleaved between them.
108
+ */
109
+ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
110
+ tracks: [number, TimelineElement[]][];
111
+ groups: TimelineTrackGroupInfo[];
112
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>;
113
+ } {
114
+ const membership = resolveGroupMembership(rawTracks);
115
+ const rawByTrack = new Map(rawTracks);
116
+ const groups: TimelineTrackGroupInfo[] = [];
117
+ const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
118
+ const emitted = new Set<string>();
119
+ const tracks: [number, TimelineElement[]][] = [];
120
+
121
+ for (const [trackNum, elements] of rawTracks) {
122
+ const groupId = membership.trackToGroupId.get(trackNum);
123
+ if (!groupId) {
124
+ tracks.push([trackNum, elements]);
125
+ continue;
126
+ }
127
+ if (emitted.has(groupId)) continue;
128
+ emitted.add(groupId);
129
+ const info = buildGroupInfo(groupId, trackNum, membership);
130
+ groups.push(info);
131
+ emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
132
+ }
133
+ return { tracks, groups, trackGroupOf };
134
+ }
135
+
5
136
  /**
6
137
  * Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
7
- * clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
8
- * `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
9
- * cohesive unit (600-line studio cap); each memo keys on the expanded display
10
- * element set exactly as before.
138
+ * clip grouping (`tracks`, group-aware order), per-lane visual styles, the
139
+ * matching `trackOrder`, and audio-group membership. Extracted from
140
+ * Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
141
+ * expanded display element set exactly as before.
11
142
  */
12
143
  export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
13
144
  tracks: [number, TimelineElement[]][];
14
145
  trackStyles: Map<number, TrackVisualStyle>;
15
146
  trackOrder: number[];
147
+ groups: TimelineTrackGroupInfo[];
148
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>;
16
149
  } {
17
- const tracks = useMemo(() => {
150
+ const rawTracks = useMemo(() => {
18
151
  const map = new Map<number, TimelineElement[]>();
19
152
  for (const el of expandedElements) {
20
153
  const list = map.get(el.track) ?? [];
@@ -24,6 +157,17 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
24
157
  return Array.from(map.entries()).sort(([a], [b]) => a - b);
25
158
  }, [expandedElements]);
26
159
 
160
+ const { tracks, groups, trackGroupOf } = useMemo(() => {
161
+ if (!isCanaryEnabled("audio-groups")) {
162
+ return {
163
+ tracks: rawTracks,
164
+ groups: [],
165
+ trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
166
+ };
167
+ }
168
+ return groupTimelineTracks(rawTracks);
169
+ }, [rawTracks]);
170
+
27
171
  const trackStyles = useMemo(() => {
28
172
  const map = new Map<number, TrackVisualStyle>();
29
173
  for (const [trackNum, els] of tracks) {
@@ -34,5 +178,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
34
178
 
35
179
  const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
36
180
 
37
- return { tracks, trackStyles, trackOrder };
181
+ return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
38
182
  }