@hyperframes/studio 0.8.10 → 0.8.12

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 (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  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 +6440 -5514
  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/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -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
+ };
@@ -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;
@@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => {
1747
1747
  expect(svg.style.cursor).not.toBe("col-resize");
1748
1748
  });
1749
1749
  });
1750
+
1751
+ describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
1752
+ /** Hover the lane the way a pointer entering it does. */
1753
+ function hover(container: HTMLElement): SVGSVGElement {
1754
+ const svg = container.querySelector("svg")!;
1755
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
1756
+ // React implements onPointerEnter through the delegated `pointerover`
1757
+ // event, not a native `pointerenter` — which does not bubble and so never
1758
+ // reaches its listener.
1759
+ fire(svg, "pointerover");
1760
+ return svg as SVGSVGElement;
1761
+ }
1762
+
1763
+ const handles = (container: HTMLElement) =>
1764
+ [...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
1765
+
1766
+ // A grab handle raised on hover is an offer, and a carve lane cannot honour
1767
+ // it: the analysis rewrites these envelopes on every run, so a point moved
1768
+ // here is discarded rather than saved. Dimming alone did not say that — the
1769
+ // handles still came up under the cursor and the drag silently did nothing.
1770
+ it("keeps its point handles hidden on hover", () => {
1771
+ const { container } = render(
1772
+ <TimelineAutomationLane
1773
+ {...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
1774
+ />,
1775
+ );
1776
+ hover(container);
1777
+ const drawn = handles(container);
1778
+ expect(drawn.length).toBeGreaterThan(0);
1779
+ for (const c of drawn) {
1780
+ expect(c.style.opacity).toBe("0");
1781
+ expect(c.style.cursor).toBe("default");
1782
+ }
1783
+ });
1784
+
1785
+ it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
1786
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
1787
+ hover(container);
1788
+ const drawn = handles(container);
1789
+ expect(drawn.length).toBeGreaterThan(0);
1790
+ for (const c of drawn) {
1791
+ expect(c.style.opacity).toBe("1");
1792
+ expect(c.style.cursor).toBe("grab");
1793
+ }
1794
+ });
1795
+
1796
+ it("says why, in the lane, once hovered", () => {
1797
+ const { container } = render(
1798
+ <TimelineAutomationLane
1799
+ {...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
1800
+ />,
1801
+ );
1802
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
1803
+ hover(container);
1804
+ expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
1805
+ "Owned by the carve.",
1806
+ );
1807
+ });
1808
+
1809
+ it("says nothing when no reason was given", () => {
1810
+ const { container } = render(
1811
+ <TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
1812
+ );
1813
+ hover(container);
1814
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
1815
+ });
1816
+ });
@@ -86,6 +86,75 @@ function pointCircleStyle(
86
86
  }
87
87
 
88
88
  /** Pointer shape: a read-only lane can only be selected, a live one edited. */
89
+ /**
90
+ * Whether a point's grab handle is up.
91
+ *
92
+ * A raised handle is an offer to drag, so a read-only lane keeps them down: the
93
+ * carve rewrites its envelopes from the analysis on every run, and a point
94
+ * moved here is discarded rather than saved. Hidden rather than unmounted
95
+ * either way, so the hit area survives — a point dragged past the lane's edge
96
+ * fires pointerleave mid-gesture, and a handle that vanished then would drop
97
+ * the drag. A live drag and a selected range both keep them up regardless: the
98
+ * range is the subject of a pending Delete, and which points it caught cannot
99
+ * depend on where the mouse is.
100
+ */
101
+ /** The svg's tooltip: what this lane's gestures actually are. */
102
+ function laneTitle(readOnly: boolean | undefined): string {
103
+ return readOnly
104
+ ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
105
+ : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
106
+ }
107
+
108
+ /**
109
+ * Why this lane will not take an edit, in the lane itself.
110
+ *
111
+ * Dimming and a default cursor say "not editable" but not WHY, and the why is
112
+ * the part that matters: the carve rewrites these envelopes from its own
113
+ * analysis on every run, so a point moved here is discarded rather than saved.
114
+ * Only while hovered, so a stack of read-only lanes is not a stack of notices,
115
+ * and never over a selection the author is about to act on.
116
+ *
117
+ * Separate from the gesture `hint` slot, which belongs to handlers that do not
118
+ * run on a read-only lane at all.
119
+ */
120
+ function ReadOnlyNote({
121
+ readOnly,
122
+ note,
123
+ hovered,
124
+ hasRange,
125
+ leftPx,
126
+ widthPx,
127
+ }: {
128
+ readOnly: boolean | undefined;
129
+ note: string | undefined;
130
+ hovered: boolean;
131
+ /** A selection is the subject of a pending action; do not cover it. */
132
+ hasRange: boolean;
133
+ leftPx: number;
134
+ widthPx: number;
135
+ }) {
136
+ if (!readOnly || !note || !hovered || hasRange) return null;
137
+ return (
138
+ <div
139
+ data-automation-readonly-note=""
140
+ className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
141
+ style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
142
+ >
143
+ {note}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function pointHandleOpacity(args: {
149
+ readOnly: boolean | undefined;
150
+ hovered: boolean;
151
+ dragging: boolean;
152
+ hasRange: boolean;
153
+ }): number {
154
+ if (args.dragging || args.hasRange) return 1;
155
+ return !args.readOnly && args.hovered ? 1 : 0;
156
+ }
157
+
89
158
  function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
90
159
  // A stretch handle wins over everything it might also sit above: the handle is
91
160
  // a few px wide and always overlaps whatever is under the selection edge, so
@@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps {
120
189
  snapTimes?: readonly number[];
121
190
  /** Editing writes to the selected element, so an unselected clip is read-only. */
122
191
  readOnly?: boolean;
192
+ /** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
193
+ * disabled; the author still has to guess what would let them edit it. */
194
+ readOnlyNote?: string;
123
195
  /** Called when a read-only lane is pressed: selects the clip so it goes live. */
124
196
  onSelect?(): void;
125
197
  /** Active selection box on THIS lane, or null. */
@@ -142,6 +214,7 @@ export function TimelineAutomationLane({
142
214
  onCommit,
143
215
  snapTimes,
144
216
  readOnly,
217
+ readOnlyNote,
145
218
  onSelect,
146
219
  rangeSelection,
147
220
  onRangeSelect,
@@ -372,11 +445,7 @@ export function TimelineAutomationLane({
372
445
  role="group"
373
446
  aria-label={`${range.label} automation`}
374
447
  >
375
- <title>
376
- {readOnly
377
- ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
378
- : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."}
379
- </title>
448
+ <title>{laneTitle(readOnly)}</title>
380
449
  {/* No plate behind the envelope: the lane used to darken its clip's width,
381
450
  which drew a box inside the row and made a stack of lanes read as tiles
382
451
  rather than as rows of one timeline. The row background shows through, and
@@ -433,15 +502,14 @@ export function TimelineAutomationLane({
433
502
  fill={accentColor}
434
503
  stroke={stroke}
435
504
  strokeWidth={strokeWidth}
436
- // Hidden rather than unmounted, so the hit area survives: a point
437
- // dragged past the lane's edge fires pointerleave mid-gesture, and a
438
- // handle that vanishes then would drop the drag. A drag in progress
439
- // and a selected range both keep them up for the same reason — the
440
- // range is the subject of a pending Delete, and which points it
441
- // caught cannot depend on where the mouse is.
442
505
  style={{
443
506
  cursor: readOnly ? "default" : "grab",
444
- opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0,
507
+ opacity: pointHandleOpacity({
508
+ readOnly,
509
+ hovered,
510
+ dragging: dragIndex !== null,
511
+ hasRange: Boolean(rangeSelection),
512
+ }),
445
513
  }}
446
514
  onContextMenu={(e) => {
447
515
  e.preventDefault();
@@ -484,6 +552,15 @@ export function TimelineAutomationLane({
484
552
  </div>
485
553
  ) : null}
486
554
 
555
+ <ReadOnlyNote
556
+ readOnly={readOnly}
557
+ note={readOnlyNote}
558
+ hovered={hovered}
559
+ hasRange={Boolean(rangeSelection)}
560
+ leftPx={leftPx}
561
+ widthPx={widthPx}
562
+ />
563
+
487
564
  {menuAt && rangeSelection ? (
488
565
  <AutomationSelectionMenu
489
566
  x={menuAt.x}