@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
@@ -0,0 +1,146 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import { usePlayerStore } from "../store/playerStore";
3
+ import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
4
+ import {
5
+ HF_AUDIO_FX_ATTR,
6
+ serializeAudioFxChain,
7
+ type HfAudioFxChain,
8
+ } from "@hyperframes/core/audio-fx";
9
+ import type { TimelineTheme } from "./timelineTheme";
10
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
11
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
12
+ import { TimelineTrackRow } from "./TimelineTrackRow";
13
+ import { TimelineGroupHeader } from "./TimelineGroupHeader";
14
+ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
15
+ import { groupAutomationLanes } from "./automationLaneData";
16
+ import { LABEL_COL_W } from "./timelineLayout";
17
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
18
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
19
+
20
+ interface TimelineGroupRowProps {
21
+ index: number;
22
+ rowKey: number;
23
+ group: TimelineTrackGroupInfo;
24
+ logicalRow: TimelineLogicalRow;
25
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[];
26
+ top: number;
27
+ height: number;
28
+ virtualized: boolean;
29
+ contentOrigin: number;
30
+ theme: TimelineTheme;
31
+ rovingTargetId?: string | null;
32
+ expandedGroupIds: ReadonlySet<string>;
33
+ expandedLaneOwnerIds: ReadonlySet<string>;
34
+ toggleGroupExpanded: (id: string) => void;
35
+ toggleLaneOwnerExpanded: (id: string) => void;
36
+ }
37
+
38
+ /** A group's own row: the accessible shell (shared with track rows) plus the group header. */
39
+ export function TimelineGroupRow({
40
+ index,
41
+ rowKey,
42
+ group,
43
+ logicalRow,
44
+ tracks,
45
+ top,
46
+ height,
47
+ virtualized,
48
+ contentOrigin,
49
+ theme,
50
+ rovingTargetId = null,
51
+ expandedGroupIds,
52
+ expandedLaneOwnerIds,
53
+ toggleGroupExpanded,
54
+ toggleLaneOwnerExpanded,
55
+ }: TimelineGroupRowProps) {
56
+ const memberElements = group.memberTracks.flatMap(
57
+ (track) => tracks.find(([t]) => t === track)?.[1] ?? [],
58
+ );
59
+ const memberLabels = group.memberTracks.map((track, i) => {
60
+ const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
61
+ return owner?.label ?? owner?.id ?? `track ${i + 1}`;
62
+ });
63
+ const isLaneOpen = expandedLaneOwnerIds.has(group.id);
64
+ const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
65
+ const domEditActions = useDomEditActionsContextOptional();
66
+ const soloed = usePlayerStore((s) => s.soloed);
67
+ const toggleSolo = usePlayerStore((s) => s.toggleSolo);
68
+ const memberIds = memberElements.map((el) => el.key ?? el.id);
69
+ const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
70
+ const value = next.nodes.length ? serializeAudioFxChain(next) : null;
71
+ if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
72
+ else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
73
+ };
74
+ const openGroupFxRack = () => {
75
+ const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
76
+ group.id,
77
+ );
78
+ if (!target) return;
79
+ void domEditActions
80
+ ?.buildDomSelectionFromTarget(target)
81
+ .then((selection) => selection && domEditActions.applyDomSelection(selection));
82
+ };
83
+ return (
84
+ <TimelineTrackRow
85
+ index={index}
86
+ rowKey={rowKey}
87
+ logicalRow={logicalRow}
88
+ propertyRows={[]}
89
+ lanesId=""
90
+ headerLanesId=""
91
+ top={top}
92
+ height={height}
93
+ virtualized={virtualized}
94
+ background={theme.rowBackground}
95
+ borderColor={theme.rowBorder}
96
+ rovingTargetId={rovingTargetId}
97
+ >
98
+ <TimelineGroupHeader
99
+ label={group.label}
100
+ memberCount={group.memberTracks.length}
101
+ isExpanded={expandedGroupIds.has(group.id)}
102
+ onToggleExpanded={() => toggleGroupExpanded(group.id)}
103
+ laneCount={groupAutomationLanes(memberElements).length}
104
+ isLaneOpen={isLaneOpen}
105
+ onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
106
+ hidden={group.hidden}
107
+ onToggleHidden={() =>
108
+ onSetAudioGroupAttributeQuiet?.(
109
+ group.id,
110
+ "data-hidden",
111
+ group.hidden ? null : "",
112
+ group.hidden ? "Unmute group" : `Mute group ${group.label}`,
113
+ )
114
+ }
115
+ isSoloed={soloed.has(group.id)}
116
+ isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
117
+ onToggleSolo={(options) => toggleSolo(group.id, options)}
118
+ fxChain={group.fxChain}
119
+ onFxChainChange={(next) => writeGroupFxChain(next, false)}
120
+ onFxChainPreview={(next) => writeGroupFxChain(next, true)}
121
+ onOpenFxRack={openGroupFxRack}
122
+ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
123
+ theme={theme}
124
+ />
125
+ {isLaneOpen && (
126
+ <TimelineGroupBusStrip
127
+ groupId={group.id}
128
+ volume={group.volume}
129
+ memberLabels={memberLabels}
130
+ onVolumeChange={(value) =>
131
+ onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
132
+ }
133
+ onVolumeCommit={(value) =>
134
+ onSetAudioGroupAttributeQuiet?.(
135
+ group.id,
136
+ "data-volume",
137
+ String(value),
138
+ "Set group volume",
139
+ )
140
+ }
141
+ theme={theme}
142
+ />
143
+ )}
144
+ </TimelineTrackRow>
145
+ );
146
+ }
@@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
118
118
  selectedElementId: null,
119
119
  selectedElementIds: next.selectedElementIds ?? new Set(),
120
120
  expandedClipIds: new Set(next.expandedClipIds ?? []),
121
+ expandedGroupIds: new Set(),
122
+ expandedLaneOwnerIds: new Set(),
123
+ groups: [],
124
+ trackGroupOf: new Map(),
121
125
  gsapAnimations,
122
126
  })}
123
127
  clipIndex={createTimelineClipIndex(tracks)}
@@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
127
131
  trackOrder={displayTrackOrder}
128
132
  tracks={tracks}
129
133
  trackStyles={new Map()}
134
+ groups={[]}
130
135
  laneCounts={laneCounts}
131
136
  selectedElementId={null}
132
137
  selectedElementIds={next.selectedElementIds ?? new Set()}
@@ -198,7 +203,9 @@ describe("TimelineLanes track numbering", () => {
198
203
  const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
199
204
  act(() => second?.click());
200
205
 
201
- expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
206
+ // Third argument is the display row the button announced, so the undo entry
207
+ // records the same row the user just read off it.
208
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
202
209
  act(() => view.root.unmount());
203
210
  });
204
211
 
@@ -1,4 +1,4 @@
1
- import { Fragment, useId, useMemo } from "react";
1
+ import { Fragment, useId } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -7,6 +7,8 @@ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
7
7
  import { useAutomationLanes } from "./useAutomationLanes";
8
8
  import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
9
9
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
10
+ import { TimelineGroupRow } from "./TimelineGroupRow";
11
+ import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
10
12
  import {
11
13
  isTrackRowExpanded,
12
14
  resolveTrackKeyframeClip,
@@ -17,12 +19,8 @@ import { clipTimingStart } from "../../hooks/gsapShared";
17
19
  import { getTimelineEditCapabilities } from "./timelineEditing";
18
20
  import { CLIP_Y, TRACK_H } from "./timelineLayout";
19
21
  import { usePlayerStore } from "../store/playerStore";
20
- import {
21
- isMultiDragActive,
22
- isMultiDragPassenger,
23
- multiDragDeltaSeconds,
24
- multiDragPassengerOffsetPx,
25
- } from "./timelineMultiDragPreview";
22
+ import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
23
+ import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
26
24
  import type { TimelineLanesProps } from "./timelineLaneProps";
27
25
  import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
28
26
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
@@ -32,7 +30,6 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
32
30
  import { isTimelineClipActive } from "./useTimelineActiveClips";
33
31
  import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
34
32
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
35
- import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
36
33
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
37
34
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
38
35
 
@@ -55,6 +52,7 @@ export function TimelineLanes({
55
52
  trackOrder,
56
53
  tracks,
57
54
  trackStyles,
55
+ groups,
58
56
  laneCounts,
59
57
  selectedElementId,
60
58
  selectedElementIds,
@@ -102,20 +100,14 @@ export function TimelineLanes({
102
100
  // from resolving into a second timeline that renders the same logical rows.
103
101
  const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
104
102
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
103
+ const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
104
+ useTimelineGroupDisclosure();
105
105
  const automationLanes = useAutomationLanes();
106
106
  useAutomationSelectionKeyboard({ lanes: automationLanes });
107
107
  const expandClips = usePlayerStore((s) => s.expandClips);
108
108
  const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
109
109
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
110
- const logicalRowsByTrack = useMemo(() => {
111
- const byTrack = new Map<number, TimelineLogicalRow[]>();
112
- for (const logicalRow of logicalRows) {
113
- const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
114
- trackRows.push(logicalRow);
115
- byTrack.set(logicalRow.physicalTrackKey, trackRows);
116
- }
117
- return byTrack;
118
- }, [logicalRows]);
110
+ const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
119
111
  // The caret belongs to the ROW, so it opens and closes every clip on it at
120
112
  // once. Toggling only the active clip left the row's state depending on which
121
113
  // sibling happened to be selected: expand one, click another, and the row
@@ -131,22 +123,11 @@ export function TimelineLanes({
131
123
  trackStudioKeyframeLaneExpand({ expanded: willExpand });
132
124
  toggleClipExpanded(key);
133
125
  };
134
- const multiDragDelta =
135
- multiDragPreview && isMultiDragActive(multiDragPreview)
136
- ? multiDragDeltaSeconds(multiDragPreview)
137
- : 0;
138
- const actorWindows =
139
- rowsVirtualized && multiDragPreview && multiDragDelta !== 0
140
- ? [
141
- {
142
- range: {
143
- start: renderTimeRange.start - multiDragDelta,
144
- end: renderTimeRange.end - multiDragDelta,
145
- },
146
- identities: multiDragPreview.selectedKeys,
147
- },
148
- ]
149
- : [];
126
+ const actorWindows = useTimelineMultiDragActorWindows(
127
+ multiDragPreview,
128
+ rowsVirtualized,
129
+ renderTimeRange,
130
+ );
150
131
  const keyboard = useTimelineKeyboardActor({
151
132
  logicalRows,
152
133
  focusedTargetId,
@@ -171,6 +152,31 @@ export function TimelineLanes({
171
152
  virtualRows.map(({ index: row, rowKey }) => {
172
153
  const trackNum = displayTrackOrder[row];
173
154
  if (trackNum === undefined) return null;
155
+ const group = groupByAnchor.get(trackNum);
156
+ if (group) {
157
+ const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
158
+ if (!groupLogicalRow) return null;
159
+ return (
160
+ <TimelineGroupRow
161
+ key={rowKey}
162
+ index={row}
163
+ rowKey={rowKey}
164
+ group={group}
165
+ logicalRow={groupLogicalRow}
166
+ tracks={tracks}
167
+ top={rowGeometry.getRowTop(row)}
168
+ height={rowGeometry.getRowHeight(row)}
169
+ virtualized={rowsVirtualized}
170
+ contentOrigin={contentOrigin}
171
+ theme={theme}
172
+ rovingTargetId={keyboard.rovingTargetId}
173
+ expandedGroupIds={expandedGroupIds}
174
+ expandedLaneOwnerIds={expandedLaneOwnerIds}
175
+ toggleGroupExpanded={toggleGroupExpanded}
176
+ toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
177
+ />
178
+ );
179
+ }
174
180
  const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
175
181
  const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
176
182
  const logicalRow = trackLogicalRows[0];
@@ -12,6 +12,8 @@ import {
12
12
  import { ClipContextMenu } from "./ClipContextMenu";
13
13
  import { TrackGapContextMenu } from "./TrackGapContextMenu";
14
14
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
15
+ import { copyTextToClipboard } from "../../utils/clipboard";
16
+ import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
15
17
 
16
18
  export interface ClipContextMenuState {
17
19
  x: number;
@@ -195,6 +197,38 @@ export function TimelineOverlays({
195
197
  }
196
198
  : undefined
197
199
  }
200
+ // Routed to the same focused-ease-segment path a segment click takes,
201
+ // so the menu advertises the editor that exists rather than growing a
202
+ // second one. Offered only for a keyframe that names a tween to focus.
203
+ onEditEase={
204
+ kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
205
+ ? (elementId, keyframe) => {
206
+ if (
207
+ keyframe.animationId === undefined ||
208
+ keyframe.tweenPercentage === undefined
209
+ ) {
210
+ return;
211
+ }
212
+ usePlayerStore.getState().setFocusedEaseSegment({
213
+ animationId: keyframe.animationId,
214
+ collidingAnimationTargets: keyframe.collidingAnimationTargets,
215
+ tweenPercentage: keyframe.tweenPercentage,
216
+ elementId,
217
+ });
218
+ trackStudioSegmentEaseEdit({ action: "open" });
219
+ }
220
+ : undefined
221
+ }
222
+ onCopyProperties={(elementId, keyframe) => {
223
+ const entry = usePlayerStore.getState().keyframeCache.get(elementId);
224
+ // Tolerance match on clip-%, the same basis the cache is keyed on —
225
+ // an exact float compare misses a keyframe the menu just opened over.
226
+ const kf = entry?.keyframes.find(
227
+ (item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
228
+ );
229
+ if (!kf) return false;
230
+ return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
231
+ }}
198
232
  />
199
233
  )}
200
234
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * "Hear only this" — the `⌗` toggle beside a track's mute control. Session
3
+ * state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
4
+ * toggles membership without disturbing the rest of the set.
5
+ */
6
+ export function TimelineSoloButton({
7
+ isSoloed,
8
+ onToggle,
9
+ }: {
10
+ isSoloed: boolean;
11
+ onToggle: (options?: { add?: boolean }) => void;
12
+ }) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ tabIndex={-1}
17
+ aria-pressed={isSoloed}
18
+ aria-label="Hear only this"
19
+ title="Hear only this"
20
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
21
+ isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
22
+ }`}
23
+ onPointerDown={(event) => event.stopPropagation()}
24
+ onClick={(event) => {
25
+ event.stopPropagation();
26
+ onToggle({ add: event.metaKey || event.ctrlKey });
27
+ }}
28
+ >
29
+ <span aria-hidden="true">⌗</span>
30
+ </button>
31
+ );
32
+ }
@@ -204,7 +204,8 @@ describe("TimelineTrackHeader", () => {
204
204
  expect(view.host.innerHTML).not.toContain("0.16666666666666666");
205
205
 
206
206
  act(() => eye?.click());
207
- expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
207
+ // The real fractional key acts; the display row rides along for the label.
208
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1);
208
209
  act(() => view.root.unmount());
209
210
  });
210
211
 
@@ -1,14 +1,22 @@
1
- import { Eye, EyeSlash } from "@phosphor-icons/react";
2
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { Music } from "../../icons/SystemIcons";
4
- import type { TimelineElement } from "../store/playerStore";
2
+ import {
3
+ HF_AUDIO_FX_ATTR,
4
+ serializeAudioFxChain,
5
+ type HfAudioFxChain,
6
+ } from "@hyperframes/core/audio-fx";
7
+ import { classifyAudioName } from "@hyperframes/core/audio-carve";
8
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
9
+ import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
5
10
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
11
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
12
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
13
+ import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
14
+ import { TimelineFxButton } from "./TimelineFxButton";
6
15
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
7
16
  import { groupAutomationLanes } from "./automationLaneData";
8
17
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
9
18
  import { clipTimingStart } from "../../hooks/gsapShared";
10
19
  import { LayerDisclosureRow } from "./LayerDisclosureRow";
11
- import { TrackClipCount } from "./TrackClipCount";
12
20
  import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
13
21
  import type { TimelineTheme } from "./timelineTheme";
14
22
  import {
@@ -61,90 +69,6 @@ interface TimelineTrackHeaderProps {
61
69
  onSeek?: (time: number) => void;
62
70
  }
63
71
 
64
- function VisibilityButton({
65
- hidden,
66
- trackNumber,
67
- trackDisplayNumber,
68
- visible,
69
- onToggle,
70
- }: {
71
- hidden: boolean;
72
- trackNumber: number;
73
- trackDisplayNumber: number | null;
74
- visible: boolean;
75
- onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
76
- }) {
77
- if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
78
- // Display number in the text, real key in the callback. The two must not be
79
- // conflated in either direction.
80
- const suffix = trackDisplaySuffix(trackDisplayNumber);
81
- const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
82
- return (
83
- <button
84
- type="button"
85
- aria-label={label}
86
- title={label}
87
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
88
- hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
89
- }`}
90
- onPointerDown={(event) => event.stopPropagation()}
91
- onClick={(event) => {
92
- event.stopPropagation();
93
- void onToggle?.(trackNumber, !hidden);
94
- }}
95
- >
96
- {hidden ? (
97
- <EyeSlash size={14} weight="bold" aria-hidden="true" />
98
- ) : (
99
- <Eye size={14} weight="bold" aria-hidden="true" />
100
- )}
101
- </button>
102
- );
103
- }
104
-
105
- // The header a track gets when it has no keyframe clip to disclose: label, clip
106
- // count, eye. Not deprecated — it is the live path for every track without lanes.
107
- function PlainTrackHeader({
108
- trackNumber,
109
- trackDisplayNumber,
110
- trackLabel,
111
- clipCount,
112
- showTrackLabel,
113
- isTrackHidden,
114
- isAudioTrack,
115
- onToggleTrackHidden,
116
- }: Pick<
117
- TimelineTrackHeaderProps,
118
- | "trackNumber"
119
- | "trackDisplayNumber"
120
- | "trackLabel"
121
- | "clipCount"
122
- | "isTrackHidden"
123
- | "isAudioTrack"
124
- | "onToggleTrackHidden"
125
- > & { showTrackLabel: boolean }) {
126
- return (
127
- <>
128
- {isAudioTrack && (
129
- <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
130
- )}
131
- {showTrackLabel && (
132
- <span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
133
- {trackLabel}
134
- </span>
135
- )}
136
- {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
137
- <VisibilityButton
138
- hidden={isTrackHidden}
139
- trackNumber={trackNumber}
140
- trackDisplayNumber={trackDisplayNumber}
141
- visible
142
- onToggle={onToggleTrackHidden}
143
- />
144
- </>
145
- );
146
- }
147
-
148
72
  // Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
149
73
  function PropertyGroupNavigation({
150
74
  navigation,
@@ -406,6 +330,7 @@ function AutomationLaneHeaderRow({
406
330
  );
407
331
  }
408
332
 
333
+ // fallow-ignore-next-line complexity
409
334
  export function TimelineTrackHeader({
410
335
  trackNumber,
411
336
  trackDisplayNumber,
@@ -457,6 +382,38 @@ export function TimelineTrackHeader({
457
382
  // left an audio clip's envelopes unreachable, since the track could not expand.
458
383
  const disclosable = lanes.length > 0 || automationRows.length > 0;
459
384
  const isKeyframeLayer = !!keyframeClip && disclosable;
385
+ // Solo is per-clip/per-group, never per track (design doc §2.2) — this header
386
+ // acts on the track's first clip as a pragmatic stand-in for "this track",
387
+ // the same simplification the mute button doesn't need to make (it patches
388
+ // every clip on the track at once).
389
+ const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
390
+ const soloed = usePlayerStore((s) => s.soloed);
391
+ const toggleSolo = usePlayerStore((s) => s.toggleSolo);
392
+
393
+ // C1: the FX entry point. A single audio clip has one chain to point at; a
394
+ // track holding several ungrouped ones has no single chain — the design
395
+ // doc refuses to build "N clips = N chains", so that case gets a pointer
396
+ // at grouping (B6's normative rule) instead of a popover.
397
+ const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
398
+ useTimelineEditContextOptional();
399
+ const domEditActions = useDomEditActionsContextOptional();
400
+ const singleAudioClip =
401
+ isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
402
+ const isTrackGrouped = trackElements.some((el) => el.audioGroup);
403
+ const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
404
+ const value = next.nodes.length ? serializeAudioFxChain(next) : null;
405
+ if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
406
+ else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
407
+ };
408
+ const openClipFxRack = (clip: TimelineElement) => {
409
+ void domEditActions?.handleTimelineElementSelect(clip);
410
+ };
411
+ const groupUngroupedClips = () => {
412
+ const doc = domEditActions?.previewIframeRef.current?.contentDocument;
413
+ if (!doc || !onGroupClips) return;
414
+ const clipIds = trackElements.map((el) => el.key ?? el.id);
415
+ void onGroupClips(clipIds, mintGroupId(doc));
416
+ };
460
417
 
461
418
  return (
462
419
  <div
@@ -476,16 +433,34 @@ export function TimelineTrackHeader({
476
433
  }}
477
434
  >
478
435
  {!keyframeClip || !disclosable ? (
479
- <PlainTrackHeader
480
- trackNumber={trackNumber}
481
- trackDisplayNumber={trackDisplayNumber}
482
- trackLabel={trackLabel}
483
- clipCount={clipCount}
484
- showTrackLabel={showTrackLabel}
485
- isTrackHidden={isTrackHidden}
486
- isAudioTrack={isAudioTrack}
487
- onToggleTrackHidden={onToggleTrackHidden}
488
- />
436
+ <>
437
+ <PlainTrackHeader
438
+ trackNumber={trackNumber}
439
+ trackDisplayNumber={trackDisplayNumber}
440
+ trackLabel={trackLabel}
441
+ clipCount={clipCount}
442
+ showTrackLabel={showTrackLabel}
443
+ isTrackHidden={isTrackHidden}
444
+ isAudioTrack={isAudioTrack}
445
+ isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
446
+ isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
447
+ onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
448
+ onToggleTrackHidden={onToggleTrackHidden}
449
+ />
450
+ {singleAudioClip && (
451
+ <TimelineFxButton
452
+ variant="chain"
453
+ fxChainRaw={singleAudioClip.fxChain}
454
+ trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
455
+ onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
456
+ onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
457
+ onOpenRack={() => openClipFxRack(singleAudioClip)}
458
+ />
459
+ )}
460
+ {isAudioTrack && clipCount > 1 && !isTrackGrouped && (
461
+ <TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
462
+ )}
463
+ </>
489
464
  ) : (
490
465
  <>
491
466
  <LayerDisclosureRow
@@ -515,6 +490,7 @@ export function TimelineTrackHeader({
515
490
  trackNumber={trackNumber}
516
491
  trackDisplayNumber={trackDisplayNumber}
517
492
  visible
493
+ isAudioTrack={isAudioTrack}
518
494
  onToggle={onToggleTrackHidden}
519
495
  />
520
496
  </LayerDisclosureRow>