@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  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 +3 -5
  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 +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  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 +8 -8
  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 +8 -1
  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.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -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
+ }
@@ -0,0 +1,103 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * Row derivation for audio groups.
5
+ *
6
+ * This ran behind the `audio-groups` canary at 0% until the rollout, so it had
7
+ * never executed in the enabled state in any suite — the off-cohort branch
8
+ * returned raw tracks and stopped. Now it reorders rows and emits synthetic
9
+ * anchor rows for every user, which is exactly the pair of invariants pinned
10
+ * here: an ungrouped project is untouched, and a group's members become
11
+ * contiguous under an anchor at `memberTracks[0] - 0.5`.
12
+ */
13
+
14
+ import React, { act } from "react";
15
+ import { createRoot } from "react-dom/client";
16
+ import { afterEach, describe, expect, it } from "vitest";
17
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
18
+ import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
19
+
20
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ afterEach(() => {
23
+ usePlayerStore.getState().reset();
24
+ });
25
+
26
+ function clip(id: string, track: number, extra: Partial<TimelineElement> = {}): TimelineElement {
27
+ return { id, label: id, tag: "audio", start: 0, duration: 1, track, ...extra };
28
+ }
29
+
30
+ function derive(elements: TimelineElement[]): ReturnType<typeof useTimelineTrackDerivations> {
31
+ let result: ReturnType<typeof useTimelineTrackDerivations> | undefined;
32
+ function Probe() {
33
+ result = useTimelineTrackDerivations(elements);
34
+ return null;
35
+ }
36
+ const root = createRoot(document.createElement("div"));
37
+ act(() => root.render(React.createElement(Probe)));
38
+ act(() => root.unmount());
39
+ if (!result) throw new Error("derivations did not render");
40
+ return result;
41
+ }
42
+
43
+ describe("useTimelineTrackDerivations", () => {
44
+ it("leaves an ungrouped project in raw ascending order", () => {
45
+ const { tracks, groups, trackGroupOf } = derive([clip("b", 2), clip("a", 0), clip("c", 1)]);
46
+
47
+ expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]);
48
+ expect(tracks.map(([, els]) => els.map((el) => el.id))).toEqual([["a"], ["c"], ["b"]]);
49
+ expect(groups).toEqual([]);
50
+ expect(trackGroupOf.size).toBe(0);
51
+ });
52
+
53
+ // The reordering case: track 1 sits between the group's two members in raw
54
+ // order and must not be dragged into the group.
55
+ it("pulls interleaved members contiguous under a synthetic anchor row", () => {
56
+ const { tracks, groups, trackGroupOf } = derive([
57
+ clip("vo-1", 0, { audioGroup: "voiceover", audioGroupLabel: "Voiceover" }),
58
+ clip("music", 1),
59
+ clip("vo-2", 2, { audioGroup: "voiceover" }),
60
+ ]);
61
+
62
+ // Anchor immediately above the lowest member, then both members, then the
63
+ // ungrouped track keeps its own position after them.
64
+ expect(tracks.map(([track]) => track)).toEqual([-0.5, 0, 2, 1]);
65
+ // The anchor row owns no clips of its own.
66
+ expect(tracks[0]?.[1]).toEqual([]);
67
+
68
+ expect(groups).toHaveLength(1);
69
+ expect(groups[0]).toMatchObject({
70
+ id: "voiceover",
71
+ label: "Voiceover",
72
+ anchorKey: -0.5,
73
+ memberTracks: [0, 2],
74
+ });
75
+
76
+ expect(trackGroupOf.get(0)?.id).toBe("voiceover");
77
+ expect(trackGroupOf.get(2)?.id).toBe("voiceover");
78
+ expect(trackGroupOf.has(1)).toBe(false);
79
+ });
80
+
81
+ it("carries the group element's own label, volume and mute onto the row", () => {
82
+ const { groups } = derive([
83
+ clip("vo-1", 3, {
84
+ audioGroup: "vo",
85
+ audioGroupLabel: "VO bus",
86
+ audioGroupVolume: 0.5,
87
+ audioGroupHidden: true,
88
+ }),
89
+ ]);
90
+
91
+ expect(groups[0]).toMatchObject({
92
+ label: "VO bus",
93
+ anchorKey: 2.5,
94
+ volume: 0.5,
95
+ hidden: true,
96
+ });
97
+ });
98
+
99
+ it("falls back to the group id and unity volume when the bus carries neither", () => {
100
+ const { groups } = derive([clip("sfx-1", 0, { audioGroup: "sfx" })]);
101
+ expect(groups[0]).toMatchObject({ id: "sfx", label: "sfx", volume: 1, hidden: false });
102
+ });
103
+ });
@@ -2,19 +2,151 @@ import { useMemo } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
4
4
 
5
+ /** One resolved audio group, positioned in the row order. */
6
+ export interface TimelineTrackGroupInfo {
7
+ id: string;
8
+ label: string;
9
+ /**
10
+ * Synthetic sort key for the group's own row — the same fractional-key
11
+ * convention sub-composition expansion already uses (see
12
+ * timelineTrackDisplay.ts): the first (lowest) member track's number minus
13
+ * 0.5, so it slots in immediately above that member.
14
+ */
15
+ anchorKey: number;
16
+ /** Member track numbers, ascending. */
17
+ memberTracks: number[];
18
+ /** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
19
+ volume: number;
20
+ /** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
21
+ hidden: boolean;
22
+ /** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
23
+ fxChain?: string;
24
+ }
25
+
26
+ interface GroupMembership {
27
+ trackToGroupId: Map<number, string>;
28
+ memberTracksByGroup: Map<string, number[]>;
29
+ labelByGroup: Map<string, string>;
30
+ volumeByGroup: Map<string, number>;
31
+ hiddenByGroup: Map<string, boolean>;
32
+ fxChainByGroup: Map<string, string | undefined>;
33
+ }
34
+
35
+ /** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
36
+ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
37
+ const trackToGroupId = new Map<number, string>();
38
+ const memberTracksByGroup = new Map<string, number[]>();
39
+ const labelByGroup = new Map<string, string>();
40
+ const volumeByGroup = new Map<string, number>();
41
+ const hiddenByGroup = new Map<string, boolean>();
42
+ const fxChainByGroup = new Map<string, string | undefined>();
43
+ for (const [trackNum, elements] of rawTracks) {
44
+ const owner = elements.find((el) => el.audioGroup);
45
+ if (!owner?.audioGroup) continue;
46
+ trackToGroupId.set(trackNum, owner.audioGroup);
47
+ if (!labelByGroup.has(owner.audioGroup)) {
48
+ labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
49
+ volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
50
+ hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
51
+ fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
52
+ }
53
+ const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
54
+ members.push(trackNum);
55
+ memberTracksByGroup.set(owner.audioGroup, members);
56
+ }
57
+ return {
58
+ trackToGroupId,
59
+ memberTracksByGroup,
60
+ labelByGroup,
61
+ volumeByGroup,
62
+ hiddenByGroup,
63
+ fxChainByGroup,
64
+ };
65
+ }
66
+
67
+ /** One group's resolved row info, built once the first time its id is seen. */
68
+ function buildGroupInfo(
69
+ groupId: string,
70
+ fallbackTrackNum: number,
71
+ membership: GroupMembership,
72
+ ): TimelineTrackGroupInfo {
73
+ const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
74
+ (a, b) => a - b,
75
+ );
76
+ const fxChain = membership.fxChainByGroup.get(groupId);
77
+ return {
78
+ id: groupId,
79
+ label: membership.labelByGroup.get(groupId) ?? groupId,
80
+ anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
81
+ memberTracks,
82
+ volume: membership.volumeByGroup.get(groupId) ?? 1,
83
+ hidden: membership.hiddenByGroup.get(groupId) ?? false,
84
+ ...(fxChain ? { fxChain } : {}),
85
+ };
86
+ }
87
+
88
+ /** Push a group's synthetic anchor row plus its members' rows, contiguously. */
89
+ function emitGroupRows(
90
+ info: TimelineTrackGroupInfo,
91
+ rawByTrack: ReadonlyMap<number, TimelineElement[]>,
92
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>,
93
+ tracks: [number, TimelineElement[]][],
94
+ ): void {
95
+ tracks.push([info.anchorKey, []]);
96
+ for (const member of info.memberTracks) {
97
+ trackGroupOf.set(member, info);
98
+ tracks.push([member, rawByTrack.get(member) ?? []]);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Pull each group's members out of raw ascending track order and re-emit them
104
+ * contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
105
+ * their position; a group's members move up to sit under its anchor even when
106
+ * other (ungrouped) tracks were interleaved between them.
107
+ */
108
+ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
109
+ tracks: [number, TimelineElement[]][];
110
+ groups: TimelineTrackGroupInfo[];
111
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>;
112
+ } {
113
+ const membership = resolveGroupMembership(rawTracks);
114
+ const rawByTrack = new Map(rawTracks);
115
+ const groups: TimelineTrackGroupInfo[] = [];
116
+ const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
117
+ const emitted = new Set<string>();
118
+ const tracks: [number, TimelineElement[]][] = [];
119
+
120
+ for (const [trackNum, elements] of rawTracks) {
121
+ const groupId = membership.trackToGroupId.get(trackNum);
122
+ if (!groupId) {
123
+ tracks.push([trackNum, elements]);
124
+ continue;
125
+ }
126
+ if (emitted.has(groupId)) continue;
127
+ emitted.add(groupId);
128
+ const info = buildGroupInfo(groupId, trackNum, membership);
129
+ groups.push(info);
130
+ emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
131
+ }
132
+ return { tracks, groups, trackGroupOf };
133
+ }
134
+
5
135
  /**
6
136
  * 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.
137
+ * clip grouping (`tracks`, group-aware order), per-lane visual styles, the
138
+ * matching `trackOrder`, and audio-group membership. Extracted from
139
+ * Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
140
+ * expanded display element set exactly as before.
11
141
  */
12
142
  export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
13
143
  tracks: [number, TimelineElement[]][];
14
144
  trackStyles: Map<number, TrackVisualStyle>;
15
145
  trackOrder: number[];
146
+ groups: TimelineTrackGroupInfo[];
147
+ trackGroupOf: Map<number, TimelineTrackGroupInfo>;
16
148
  } {
17
- const tracks = useMemo(() => {
149
+ const rawTracks = useMemo(() => {
18
150
  const map = new Map<number, TimelineElement[]>();
19
151
  for (const el of expandedElements) {
20
152
  const list = map.get(el.track) ?? [];
@@ -24,6 +156,11 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
24
156
  return Array.from(map.entries()).sort(([a], [b]) => a - b);
25
157
  }, [expandedElements]);
26
158
 
159
+ const { tracks, groups, trackGroupOf } = useMemo(
160
+ () => groupTimelineTracks(rawTracks),
161
+ [rawTracks],
162
+ );
163
+
27
164
  const trackStyles = useMemo(() => {
28
165
  const map = new Map<number, TrackVisualStyle>();
29
166
  for (const [trackNum, els] of tracks) {
@@ -34,5 +171,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
34
171
 
35
172
  const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
36
173
 
37
- return { tracks, trackStyles, trackOrder };
174
+ return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
38
175
  }
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
8
8
  import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
9
9
  import {
10
+ STRIP_H,
10
11
  TRACK_H,
11
12
  createTimelineRowGeometry,
12
13
  type TimelineRowGeometry,
13
14
  trackHeights,
14
15
  type TimelineTrackHeightClip,
15
16
  } from "./timelineLayout";
17
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
16
18
 
17
19
  export { getTrackStyle } from "./timelineIcons";
18
20
 
@@ -127,13 +129,35 @@ function computeLaneCounts(
127
129
  return laneCounts;
128
130
  }
129
131
 
132
+ /** Group anchor rows have no elements of their own (`groupTimelineTracks`
133
+ * pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
134
+ * looks at a row's clips — always gives them TRACK_H. Override those
135
+ * specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
136
+ * group's own `∿` (bus strip) is open. */
137
+ function applyGroupStripHeights(
138
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[],
139
+ rowHeights: number[],
140
+ groups: readonly TimelineTrackGroupInfo[],
141
+ expandedLaneOwnerIds: ReadonlySet<string>,
142
+ ): number[] {
143
+ if (groups.length === 0) return rowHeights;
144
+ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
145
+ return tracks.map(([track], index) => {
146
+ const group = groupByAnchor.get(track);
147
+ if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
148
+ return TRACK_H + STRIP_H;
149
+ });
150
+ }
151
+
130
152
  function useTimelineRowHeights(
131
153
  tracks: [number, TimelineElement[]][],
132
154
  gsapAnimations: Map<string, GsapAnimation[]>,
133
155
  selectedElementId: string | null,
134
156
  selectedElementIds: ReadonlySet<string>,
157
+ groups: readonly TimelineTrackGroupInfo[],
135
158
  ) {
136
159
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
160
+ const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
137
161
  const { laneCounts, rowGeometry } = useMemo(() => {
138
162
  const laneCounts = computeLaneCounts(tracks, gsapAnimations);
139
163
  // Keyframe lanes follow only the active clip, so a track with several
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
163
187
  },
164
188
  ];
165
189
  });
166
- const rowHeights = trackHeights(heightTracks, expandedClipIds);
190
+ const rowHeights = applyGroupStripHeights(
191
+ tracks,
192
+ trackHeights(heightTracks, expandedClipIds),
193
+ groups,
194
+ expandedLaneOwnerIds,
195
+ );
167
196
  return {
168
197
  laneCounts,
169
198
  rowGeometry: createTimelineRowGeometry(
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
171
200
  rowHeights,
172
201
  ),
173
202
  };
174
- }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
203
+ }, [
204
+ expandedClipIds,
205
+ expandedLaneOwnerIds,
206
+ gsapAnimations,
207
+ groups,
208
+ tracks,
209
+ selectedElementId,
210
+ selectedElementIds,
211
+ ]);
175
212
  const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
176
213
  rowGeometryRef.current = rowGeometry;
177
214
  return {
@@ -188,7 +225,8 @@ export function useTimelineTrackLayout(
188
225
  selectedElementId: string | null,
189
226
  selectedElementIds: ReadonlySet<string>,
190
227
  ) {
191
- const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
228
+ const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
229
+ useTimelineTrackDerivations(expandedElements);
192
230
  const trackOrderRef = useRef(trackOrder);
193
231
  trackOrderRef.current = trackOrder;
194
232
  const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
@@ -196,6 +234,7 @@ export function useTimelineTrackLayout(
196
234
  gsapAnimations,
197
235
  selectedElementId,
198
236
  selectedElementIds,
237
+ groups,
199
238
  );
200
239
 
201
240
  return {
@@ -207,6 +246,8 @@ export function useTimelineTrackLayout(
207
246
  rowGeometry,
208
247
  rowGeometryRef,
209
248
  rowHeights,
249
+ groups,
250
+ trackGroupOf,
210
251
  };
211
252
  }
212
253
 
@@ -227,7 +268,23 @@ function useDisplayRowHeights(
227
268
  function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
228
269
  return useMemo(() => {
229
270
  if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
230
- return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
271
+ // A group's members sit out of raw numeric order (pulled under their
272
+ // anchor row), so a plain numeric sort here would undo that grouping the
273
+ // moment a clip drags onto a brand-new track. Insert the new preview
274
+ // track only relative to other REAL (integer) tracks, leaving any
275
+ // fractional group-anchor keys exactly where grouping placed them.
276
+ const preview = draggedClip.previewTrack;
277
+ const result: number[] = [];
278
+ let inserted = false;
279
+ for (const key of trackOrder) {
280
+ if (!inserted && Number.isInteger(key) && key > preview) {
281
+ result.push(preview);
282
+ inserted = true;
283
+ }
284
+ result.push(key);
285
+ }
286
+ if (!inserted) result.push(preview);
287
+ return result;
231
288
  }, [draggedClip, trackOrder]);
232
289
  }
233
290