@hyperframes/studio 0.8.11 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
  2. package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
  3. package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
  4. package/dist/assets/index-CQMHN4nk.js +431 -0
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7646 -6514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/domSelectionTimelineMirror.ts +12 -3
  64. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  65. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  66. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  67. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  68. package/src/hooks/timelineEditingHelpers.ts +19 -7
  69. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  70. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  71. package/src/hooks/timelineTrackVisibility.ts +6 -182
  72. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  73. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  74. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  75. package/src/hooks/useDomEditSession.test.tsx +57 -2
  76. package/src/hooks/useDomEditSession.ts +23 -24
  77. package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
  78. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  79. package/src/hooks/useLivePlayheadTime.ts +3 -1
  80. package/src/hooks/useRemoveBackground.ts +70 -0
  81. package/src/hooks/useTimelineEditing.ts +5 -19
  82. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  83. package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
  84. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  85. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  86. package/src/player/components/PlayerControls.tsx +0 -38
  87. package/src/player/components/Timeline.test.ts +40 -1
  88. package/src/player/components/Timeline.tsx +5 -2
  89. package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
  90. package/src/player/components/TimelineAutomationLane.tsx +129 -36
  91. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  92. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  93. package/src/player/components/TimelineFxButton.tsx +156 -31
  94. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  95. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  96. package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
  97. package/src/player/components/TimelineGroupRow.test.tsx +222 -0
  98. package/src/player/components/TimelineGroupRow.tsx +140 -71
  99. package/src/player/components/TimelineLanes.test.tsx +3 -3
  100. package/src/player/components/TimelineLanes.tsx +25 -23
  101. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  102. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  103. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  104. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  105. package/src/player/components/automationLaneData.test.ts +94 -0
  106. package/src/player/components/automationLaneData.ts +23 -0
  107. package/src/player/components/automationLaneDragMath.ts +28 -0
  108. package/src/player/components/automationLaneGeometry.ts +52 -13
  109. package/src/player/components/groupAutomationElement.test.ts +51 -0
  110. package/src/player/components/groupAutomationElement.ts +37 -0
  111. package/src/player/components/timelineCallbacks.ts +6 -2
  112. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  113. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  114. package/src/player/components/timelineLayout.ts +0 -1
  115. package/src/player/components/timelineViewModel.ts +30 -1
  116. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  117. package/src/player/components/useAutomationLaneGestures.ts +66 -18
  118. package/src/player/components/useAutomationSegmentDrag.ts +110 -0
  119. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  120. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  121. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  122. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  123. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  124. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  125. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  126. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  127. package/src/player/hooks/previewMessageRouter.ts +4 -11
  128. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  129. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  130. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  131. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  132. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  133. package/src/player/lib/automationStoreSync.test.ts +26 -0
  134. package/src/player/lib/automationStoreSync.ts +28 -4
  135. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  136. package/src/player/lib/timelineDOM.test.ts +115 -0
  137. package/src/player/lib/timelineDOM.ts +3 -32
  138. package/src/player/lib/timelineElementHelpers.ts +27 -0
  139. package/src/player/lib/timelineGroupInfo.ts +134 -0
  140. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  141. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  142. package/src/player/store/keyframeSlice.ts +79 -5
  143. package/src/player/store/playerStore.ts +37 -47
  144. package/src/player/store/playerStoreDevHandle.ts +22 -0
  145. package/src/player/store/playerStoreSelection.ts +41 -0
  146. package/src/player/store/timelineElement.ts +32 -0
  147. package/src/utils/hmrStableContext.ts +64 -0
  148. package/src/utils/timelineInspector.test.ts +35 -1
  149. package/src/utils/timelineInspector.ts +38 -0
  150. package/dist/assets/index-Ba9zbpT9.css +0 -1
  151. package/dist/assets/index-DoW5v5eS.js +0 -428
  152. package/src/hooks/useAudioSoloBridge.ts +0 -61
  153. package/src/hooks/useGroupLevel.ts +0 -36
  154. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  155. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  156. package/src/player/components/TimelineSoloButton.tsx +0 -32
  157. package/src/player/store/audioSoloSlice.test.ts +0 -113
  158. package/src/player/store/audioSoloSlice.ts +0 -43
  159. package/src/player/store/groupLevels.ts +0 -33
  160. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  161. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
130
130
  });
131
131
  });
132
132
 
133
+ describe("useDomSelection — canvas-only targets replace timeline clips", () => {
134
+ beforeEach(() => {
135
+ deferreds.clear();
136
+ usePlayerStore.getState().clearSelection();
137
+ });
138
+ afterEach(() => {
139
+ deferreds.clear();
140
+ usePlayerStore.getState().clearSelection();
141
+ });
142
+
143
+ it("deselects every clip when an audio bus is selected", () => {
144
+ const store = usePlayerStore.getState();
145
+ store.setSelectedElementId("voice-1");
146
+ store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
147
+
148
+ const bus = document.createElement("hf-audio-group");
149
+ bus.id = "voiceover";
150
+ const harness = renderHarness({
151
+ rightPanelTab: "design",
152
+ setRightPanelTab: vi.fn(),
153
+ iframe: null,
154
+ timelineElements: [
155
+ { id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
156
+ { id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
157
+ ],
158
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
159
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
160
+ });
161
+
162
+ act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
163
+
164
+ expect(harness.current().domEditSelection?.id).toBe("voiceover");
165
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
166
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
167
+ harness.cleanup();
168
+ });
169
+
170
+ it("lets a bus supersede a clip selection that is still resolving", async () => {
171
+ const iframe = document.createElement("iframe");
172
+ document.body.append(iframe);
173
+ const doc = iframe.contentDocument!;
174
+ const clipNode = doc.createElement("audio");
175
+ clipNode.id = "voice-1";
176
+ const busNode = doc.createElement("hf-audio-group");
177
+ busNode.id = "voiceover";
178
+ doc.body.append(clipNode, busNode);
179
+
180
+ const clip: TimelineElement = {
181
+ id: "voice-1",
182
+ domId: "voice-1",
183
+ tag: "audio",
184
+ start: 0,
185
+ duration: 1,
186
+ track: 0,
187
+ };
188
+ const bus: TimelineElement = {
189
+ id: "voiceover",
190
+ domId: "voiceover",
191
+ tag: "audio",
192
+ start: 0,
193
+ duration: 10,
194
+ track: -0.5,
195
+ };
196
+ const harness = renderHarness({
197
+ rightPanelTab: "design",
198
+ setRightPanelTab: vi.fn(),
199
+ iframe,
200
+ // The bus is a synthetic row target, not a clip in the store.
201
+ timelineElements: [clip],
202
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
203
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
204
+ });
205
+
206
+ let pendingClip = Promise.resolve();
207
+ let pendingBus = Promise.resolve();
208
+ act(() => {
209
+ pendingClip = harness.current().handleTimelineElementSelect(clip);
210
+ pendingBus = harness.current().handleTimelineElementSelect(bus);
211
+ });
212
+ await act(async () => {
213
+ deferreds.get("voiceover")?.resolve();
214
+ await pendingBus;
215
+ deferreds.get("voice-1")?.resolve();
216
+ await pendingClip;
217
+ });
218
+
219
+ expect(harness.current().domEditSelection?.id).toBe("voiceover");
220
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
221
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
222
+ harness.cleanup();
223
+ iframe.remove();
224
+ });
225
+
226
+ it("preserves clip context for a non-bus canvas-only selection", () => {
227
+ const store = usePlayerStore.getState();
228
+ store.setSelectedElementId("voice-1");
229
+ const decoration = document.createElement("div");
230
+ decoration.id = "decoration";
231
+ const harness = renderHarness({
232
+ rightPanelTab: "design",
233
+ setRightPanelTab: vi.fn(),
234
+ iframe: null,
235
+ timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
236
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
237
+ setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
238
+ });
239
+
240
+ act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
241
+
242
+ expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
243
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
244
+ harness.cleanup();
245
+ });
246
+ });
247
+
133
248
  describe("useDomSelection — timeline-select race guard", () => {
134
249
  beforeEach(() => deferreds.clear());
135
250
  afterEach(() => deferreds.clear());
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import type { TimelineElement } from "../player/store/timelineElement";
3
+ import { getEffectiveTimelineDuration } from "../player/components/timelineViewModel";
3
4
 
4
5
  /**
5
6
  * The stored `duration` lags a moment behind an edit that pushes an element
@@ -10,11 +11,12 @@ export function useEffectiveTimelineDuration(
10
11
  timelineDuration: number,
11
12
  timelineElements: readonly TimelineElement[],
12
13
  ): number {
13
- return useMemo(() => {
14
- const maxEnd =
15
- timelineElements.length > 0
16
- ? Math.max(...timelineElements.map((el) => el.start + el.duration))
17
- : 0;
18
- return Math.max(timelineDuration, maxEnd);
19
- }, [timelineDuration, timelineElements]);
14
+ // Delegates to `getEffectiveTimelineDuration` rather than restating the
15
+ // arithmetic: that one guards a non-finite stored duration and a non-finite
16
+ // result (an element with NaN timing), which this copy did not — it would
17
+ // return NaN and every downstream width became NaN with it.
18
+ return useMemo(
19
+ () => getEffectiveTimelineDuration(timelineDuration, timelineElements),
20
+ [timelineDuration, timelineElements],
21
+ );
20
22
  }
@@ -12,7 +12,9 @@
12
12
  * dragged as well as while it is playing.
13
13
  */
14
14
  import { useEffect, useRef, useState } from "react";
15
- import { liveTime, usePlayerStore } from "../player";
15
+ // The store's own module, not the `player` barrel: the barrel pulls the whole
16
+ // timeline in, and a timeline component importing this hook closes a cycle.
17
+ import { liveTime, usePlayerStore } from "../player/store/playerStore";
16
18
 
17
19
  /** Long enough to be much cheaper than a frame, short enough to read as motion. */
18
20
  const THROTTLE_MS = 33;
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { waitForMediaJob } from "../components/studioMediaJobs";
3
+ import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
4
+
5
+ interface RemoveBackgroundOptions {
6
+ createBackgroundPlate?: boolean;
7
+ quality?: "fast" | "balanced" | "best";
8
+ onProgress?: (progress: BackgroundRemovalProgress) => void;
9
+ }
10
+
11
+ /**
12
+ * One removal in flight at a time: starting a second one aborts whichever job
13
+ * is still running, so a stale progress callback can't overwrite a newer
14
+ * result. Unmounting aborts too, or the job would keep running against a
15
+ * panel that is no longer there to show its progress.
16
+ */
17
+ export function useRemoveBackground(
18
+ projectId: string,
19
+ refreshFileTree: () => Promise<void>,
20
+ showToast: (message: string, kind?: "info" | "error") => void,
21
+ ) {
22
+ const abortRef = useRef<AbortController | null>(null);
23
+
24
+ useEffect(
25
+ () => () => {
26
+ abortRef.current?.abort();
27
+ },
28
+ [],
29
+ );
30
+
31
+ return useCallback(
32
+ // fallow-ignore-next-line complexity
33
+ async (inputPath: string, options: RemoveBackgroundOptions) => {
34
+ const response = await fetch(
35
+ `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
36
+ {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({
40
+ inputPath,
41
+ createBackgroundPlate: options.createBackgroundPlate === true,
42
+ quality: options.quality ?? "balanced",
43
+ }),
44
+ },
45
+ );
46
+ const data = (await response.json().catch(() => ({}))) as {
47
+ jobId?: string;
48
+ error?: string;
49
+ };
50
+ if (!response.ok || !data.jobId) {
51
+ throw new Error(data.error || `Background removal failed (${response.status})`);
52
+ }
53
+ showToast("Removing background...", "info");
54
+ abortRef.current?.abort();
55
+ const controller = new AbortController();
56
+ abortRef.current = controller;
57
+ try {
58
+ const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
59
+ await refreshFileTree();
60
+ showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
61
+ return result;
62
+ } finally {
63
+ if (abortRef.current === controller) {
64
+ abortRef.current = null;
65
+ }
66
+ }
67
+ },
68
+ [projectId, refreshFileTree, showToast],
69
+ );
70
+ }
@@ -20,25 +20,21 @@ import {
20
20
  syncPreviewContentDuration,
21
21
  } from "./timelineTimingSync";
22
22
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
23
- import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
24
23
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
25
- import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
26
24
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
25
+ import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
26
+ import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
27
27
  import {
28
- useAudioGroupCarveAssignment,
29
28
  useTimelineElementVisibilityEditing,
30
29
  useTimelineTrackVisibilityEditing,
31
30
  } from "./timelineTrackVisibility";
32
31
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
32
+ import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
33
33
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
34
34
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
35
- import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
35
+ import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
36
36
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
37
37
 
38
- type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
39
- stackingReorder?: TimelineStackingReorderIntent | null;
40
- };
41
-
42
38
  export function useTimelineEditing({
43
39
  projectId,
44
40
  activeCompPath,
@@ -61,9 +57,7 @@ export function useTimelineEditing({
61
57
  }: UseTimelineEditingOptions) {
62
58
  const projectIdRef = useRef(projectId);
63
59
  projectIdRef.current = projectId;
64
-
65
60
  const editQueueRef = useRef(Promise.resolve());
66
- const lastBlockedTimelineToastAtRef = useRef(0);
67
61
 
68
62
  const enqueueEdit = useCallback(
69
63
  (
@@ -451,15 +445,7 @@ export function useTimelineEditing({
451
445
  observeProjectFileVersion,
452
446
  });
453
447
 
454
- const handleBlockedTimelineEdit = useCallback(
455
- (_element: TimelineElement) => {
456
- const now = Date.now();
457
- if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
458
- lastBlockedTimelineToastAtRef.current = now;
459
- showToast("This clip can't be moved or resized from the timeline yet.", "info");
460
- },
461
- [showToast],
462
- );
448
+ const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
463
449
 
464
450
  const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
465
451
  projectId,
@@ -1,6 +1,7 @@
1
1
  import type { MutableRefObject, RefObject } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { TimelineElement } from "../player";
4
+ import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
4
5
  import type { EditHistoryKind } from "../utils/editHistory";
5
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
6
7
 
@@ -55,3 +56,9 @@ export type TimelineFileDropHandler = (
55
56
  files: File[],
56
57
  placement?: { start: number; track: number },
57
58
  ) => Promise<void>;
59
+
60
+ /** What a timeline move commits: the new start and track, plus the z-index
61
+ * reorder a vertical drag resolves to (absent for a pure horizontal move). */
62
+ export type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
63
+ stackingReorder?: TimelineStackingReorderIntent | null;
64
+ };
@@ -0,0 +1,54 @@
1
+ /** The base automation envelope plus the heavier segment grab affordance. */
2
+
3
+ import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
4
+ import { envelopeSegmentPath } from "./automationLaneGeometry";
5
+
6
+ interface AutomationEnvelopePathsProps {
7
+ path: string;
8
+ lane: HfAutomationLane;
9
+ range: AutomationRange;
10
+ accentColor: string;
11
+ activeSegment: number | null;
12
+ xOf(t: number): number;
13
+ yOf(v: number): number;
14
+ }
15
+
16
+ export function AutomationEnvelopePaths({
17
+ path,
18
+ lane,
19
+ range,
20
+ accentColor,
21
+ activeSegment,
22
+ xOf,
23
+ yOf,
24
+ }: AutomationEnvelopePathsProps) {
25
+ const activePath =
26
+ activeSegment === null
27
+ ? null
28
+ : envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
29
+
30
+ return (
31
+ <>
32
+ <path
33
+ data-automation-envelope=""
34
+ d={path}
35
+ fill="none"
36
+ stroke={accentColor}
37
+ strokeWidth={1.5}
38
+ opacity={lane.points.length === 0 ? 0.35 : 0.95}
39
+ />
40
+ {activePath ? (
41
+ <path
42
+ data-automation-segment-active={activeSegment ?? undefined}
43
+ d={activePath}
44
+ fill="none"
45
+ stroke={accentColor}
46
+ strokeWidth={3}
47
+ strokeLinecap="round"
48
+ opacity={1}
49
+ pointerEvents="none"
50
+ />
51
+ ) : null}
52
+ </>
53
+ );
54
+ }
@@ -41,6 +41,10 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
41
41
  return createPortal(
42
42
  <div
43
43
  ref={menuRef}
44
+ // z-[200] for the same reason the timeline's FX popover uses it: this is
45
+ // portaled to `document.body`, but the ruler's sticky header sits at z-70
46
+ // in the SAME root stacking context, so a z-50 menu opened near the top of
47
+ // the timeline is painted through by the ruler and the playhead.
44
48
  className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
45
49
  style={{ left: adjustedX, top: adjustedY }}
46
50
  >
@@ -1,11 +1,56 @@
1
1
  import { TRACK_H } from "./timelineLayout";
2
2
  import { TrackClipCount } from "./TrackClipCount";
3
3
 
4
- // Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
4
+ // Layer row (diamond, name, then the ∿ disclosure on the right edge) — the
5
+ // disclosure lives
5
6
  // here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
6
7
  // caret) because a group's own row keeps the caret for its structural
7
8
  // disclosure (member rows) — this button only ever means "show this row's
8
9
  // lanes", so it needs its own distinct glyph.
10
+ /**
11
+ * The `∿` that shows or hides a row's lanes.
12
+ *
13
+ * Shared, because two layouts need the identical control: the keyframe layer
14
+ * row below, and the plain track header — an audio track with automation keeps
15
+ * its own look (music glyph, indent) and gains this, rather than being
16
+ * re-rendered as a keyframe layer to get at the button.
17
+ */
18
+ export function LaneToggleButton({
19
+ name,
20
+ isExpanded,
21
+ lanesId,
22
+ onToggle,
23
+ }: {
24
+ name: string;
25
+ isExpanded: boolean;
26
+ lanesId: string;
27
+ onToggle: () => void;
28
+ }) {
29
+ return (
30
+ <button
31
+ type="button"
32
+ // ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
33
+ tabIndex={-1}
34
+ aria-expanded={isExpanded}
35
+ aria-controls={lanesId}
36
+ aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
37
+ title={`${isExpanded ? "Hide" : "Show"} lanes`}
38
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
39
+ // only the hit box grows.
40
+ className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
41
+ isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
42
+ }`}
43
+ onPointerDown={(event) => event.stopPropagation()}
44
+ onClick={(event) => {
45
+ event.stopPropagation();
46
+ onToggle();
47
+ }}
48
+ >
49
+ <span aria-hidden="true">∿</span>
50
+ </button>
51
+ );
52
+ }
53
+
9
54
  export function LayerDisclosureRow({
10
55
  name,
11
56
  clipCount,
@@ -45,37 +90,26 @@ export function LayerDisclosureRow({
45
90
  background: gutterBackground,
46
91
  }}
47
92
  >
48
- <button
49
- type="button"
50
- // ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
51
- tabIndex={-1}
52
- aria-expanded={isExpanded}
53
- aria-controls={lanesId}
54
- aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
55
- title={`${isExpanded ? "Hide" : "Show"} lanes`}
56
- // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
57
- // only the hit box grows.
58
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
59
- isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
60
- }`}
61
- onPointerDown={(event) => event.stopPropagation()}
62
- onClick={(event) => {
63
- event.stopPropagation();
64
- onToggleClipExpanded();
65
- }}
66
- >
67
- <span aria-hidden="true">∿</span>
68
- </button>
69
93
  {/* Decorative: the disclosure button above already names the row's keyframe
70
94
  state, and aria-label on a plain span is not exposed reliably anyway. */}
71
95
  <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
72
96
 
73
97
  </span>
74
- <span className="min-w-0 flex-1 truncate font-medium" title={name}>
75
- {name}
76
- </span>
98
+ {/* Wraps rather than truncating: a truncated name needs a hover to be
99
+ read, which a scanned column cannot rely on. */}
100
+ <span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
77
101
  <TrackClipCount clipCount={clipCount} />
78
102
  {children}
103
+ {/* Anchored right, on every header that has one: the lane toggle is the
104
+ row's last word about itself, and a left-hand ∿ put it where the eye
105
+ looks for identity instead. `ml-auto` rather than a spacer so it holds
106
+ the edge whatever else the row grows. */}
107
+ <LaneToggleButton
108
+ name={name}
109
+ isExpanded={isExpanded}
110
+ lanesId={lanesId}
111
+ onToggle={onToggleClipExpanded}
112
+ />
79
113
  </div>
80
114
  );
81
115
  }
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
6
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
- import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
10
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
11
10
  import { SpeedMenu } from "./SpeedMenu";
12
11
  import { VolumeControl } from "./VolumeControl";
@@ -156,47 +155,12 @@ const FullscreenButton = memo(function FullscreenButton({
156
155
 
157
156
  /* ── Main component ──────────────────────────────────────────────── */
158
157
 
159
- /**
160
- * "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
161
- * attribute and never reaches the export — so the state has to say so out loud,
162
- * or a soloed session reads as a broken mix.
163
- */
164
- const SoloBanner = memo(function SoloBanner({
165
- previewIframeRef,
166
- }: {
167
- previewIframeRef: { current: HTMLIFrameElement | null };
168
- }) {
169
- const bannerText = useSoloBannerText(previewIframeRef);
170
- const clearSolo = usePlayerStore.getState().clearSolo;
171
- if (bannerText === null) return null;
172
- return (
173
- <div
174
- role="status"
175
- className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
176
- >
177
- <span>
178
- Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
179
- export is not affected
180
- </span>
181
- <button
182
- type="button"
183
- onClick={() => clearSolo()}
184
- className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
185
- >
186
- Clear
187
- </button>
188
- </div>
189
- );
190
- });
191
-
192
158
  interface PlayerControlsProps {
193
159
  onTogglePlay: () => void;
194
160
  onSeek: (time: number) => void;
195
161
  disabled?: boolean;
196
162
  isFullscreen?: boolean;
197
163
  onToggleFullscreen?: () => void;
198
- /** Needed to read the soloed clips' labels out of the preview document. */
199
- previewIframeRef?: { current: HTMLIFrameElement | null };
200
164
  }
201
165
 
202
166
  export const PlayerControls = memo(function PlayerControls({
@@ -205,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
205
169
  disabled = false,
206
170
  isFullscreen = false,
207
171
  onToggleFullscreen,
208
- previewIframeRef,
209
172
  }: PlayerControlsProps) {
210
173
  const isPlaying = usePlayerStore((s) => s.isPlaying);
211
174
  const duration = usePlayerStore((s) => s.duration);
@@ -258,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
258
221
 
259
222
  return (
260
223
  <div>
261
- {previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
262
224
  <div
263
225
  className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
264
226
  aria-disabled={disabled || undefined}
@@ -299,6 +299,42 @@ describe("Timeline provider boundary", () => {
299
299
  act(() => root.unmount());
300
300
  });
301
301
 
302
+ // The same assertion, with a group present. It passed on an empty fixture
303
+ // while TimelineGroupRow called the THROWING context hook — one grouped clip
304
+ // and the whole timeline render died, not just the row.
305
+ it("renders without the provider even when a group row is on screen", () => {
306
+ const host = createSizedTimelineHost(640);
307
+ usePlayerStore.setState({
308
+ duration: 4,
309
+ timelineReady: true,
310
+ elements: [
311
+ {
312
+ id: "voice-1",
313
+ domId: "voice-1",
314
+ tag: "audio",
315
+ start: 0,
316
+ duration: 2,
317
+ track: 0,
318
+ audioGroup: "voiceover",
319
+ },
320
+ {
321
+ id: "voice-2",
322
+ domId: "voice-2",
323
+ tag: "audio",
324
+ start: 2,
325
+ duration: 2,
326
+ track: 1,
327
+ audioGroup: "voiceover",
328
+ },
329
+ ],
330
+ });
331
+ const root = createRoot(host);
332
+ act(() => root.render(React.createElement(Timeline)));
333
+
334
+ expect(host.querySelector('[role="treegrid"]')).not.toBeNull();
335
+ act(() => root.unmount());
336
+ });
337
+
302
338
  it("renders the complete track list while row virtualization is gated off", () => {
303
339
  const host = createSizedTimelineHost(640);
304
340
  usePlayerStore.setState({
@@ -389,7 +425,10 @@ describe("Timeline provider boundary", () => {
389
425
  button.click();
390
426
  });
391
427
 
392
- const row = button.parentElement?.parentElement;
428
+ // Up from the rowheader rather than counting parents: the header now lays
429
+ // its name and its controls out on two lines, so the button sits one level
430
+ // deeper than it used to.
431
+ const row = button.closest('[role="rowheader"]')?.parentElement;
393
432
  // Row children: [TimelineTrackHeader (sticky column), time-mapped content].
394
433
  const trackContent = row?.children.item(1);
395
434
  expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
33
33
  import {
34
34
  getEffectiveTimelineDuration,
35
35
  getTimelinePreviewElement,
36
- hasKeyframedTimelineClips,
36
+ timelineNeedsLabelColumn,
37
37
  } from "./timelineViewModel";
38
38
  import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
39
39
  import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
115
115
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
116
116
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
117
117
  const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
118
- const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
118
+ const labelMode = useMemo(
119
+ () => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
120
+ [gsapAnimations, expandedElements],
121
+ );
119
122
  // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
120
123
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
121
124
  const contentGutter = labelMode ? GUTTER : 0;