@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
@@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
10
10
  import {
11
11
  normalizeAutomation,
12
12
  resolveAutomationRange,
13
+ sampleAutomationLane,
13
14
  VOLUME_RANGE,
14
15
  type HfAutomation,
15
16
  } from "@hyperframes/core/audio-automation";
@@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
668
669
  });
669
670
  });
670
671
 
672
+ describe("TimelineAutomationLane segment drag", () => {
673
+ const four: HfAutomation = {
674
+ version: 1,
675
+ lanes: [
676
+ {
677
+ target: "volume",
678
+ points: [
679
+ { t: 0, v: 1 },
680
+ { t: 1, v: 0.8 },
681
+ { t: 2, v: 0.6 },
682
+ { t: 3.5, v: 0.2 },
683
+ ],
684
+ },
685
+ ],
686
+ };
687
+
688
+ const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
689
+ props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
690
+ t: number;
691
+ v: number;
692
+ viaX?: number;
693
+ viaY?: number;
694
+ }[];
695
+
696
+ it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
697
+ const { container, svg } = mount(ramp);
698
+ const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
699
+ expect(envelope?.getAttribute("stroke-width")).toBe("1.5");
700
+
701
+ fire(svg, "pointermove", at(2, 0.5));
702
+ const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
703
+ expect(active).not.toBeNull();
704
+ expect(active?.getAttribute("stroke-width")).toBe("3");
705
+ expect(svg.style.cursor).toBe("grab");
706
+
707
+ // Same time span, but far enough above the drawn ramp to be background.
708
+ fire(svg, "pointermove", at(2, 0.9));
709
+ expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
710
+ expect(svg.style.cursor).toBe("crosshair");
711
+ });
712
+
713
+ it("moves both segment endpoints by the same time and value delta", () => {
714
+ const { svg, props } = mount(four);
715
+ // Midpoint of the segment from (1, .8) to (2, .6).
716
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
717
+ fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });
718
+
719
+ const points = previewedPoints(props);
720
+ expect(points[0]).toEqual({ t: 0, v: 1 });
721
+ expect(points[1]!.t).toBeCloseTo(1.5, 2);
722
+ expect(points[2]!.t).toBeCloseTo(2.5, 2);
723
+ expect(points[1]!.v).toBeCloseTo(0.6, 2);
724
+ expect(points[2]!.v).toBeCloseTo(0.4, 2);
725
+ expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
726
+ });
727
+
728
+ it("treats a press outside the line's proximity as a background range drag", () => {
729
+ const onRangeSelect = vi.fn();
730
+ const { svg, props } = mount(four, { onRangeSelect });
731
+ fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
732
+ fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
733
+ expect(onRangeSelect).toHaveBeenCalled();
734
+ expect(props.onPreview).not.toHaveBeenCalled();
735
+ });
736
+
737
+ it("preserves the segment's curve while translating its endpoints", () => {
738
+ const curved: HfAutomation = {
739
+ version: 1,
740
+ lanes: [
741
+ {
742
+ target: "volume",
743
+ points: [
744
+ { t: 0, v: 1 },
745
+ { t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
746
+ { t: 2, v: 0.6 },
747
+ { t: 3.5, v: 0.2 },
748
+ ],
749
+ },
750
+ ],
751
+ };
752
+ const { svg, props } = mount(curved);
753
+ const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
754
+ fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
755
+ fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
756
+ const points = previewedPoints(props);
757
+ expect(points[1]?.viaX).toBe(0.4);
758
+ expect(points[1]?.viaY).toBe(0.7);
759
+ });
760
+
761
+ it("stops both endpoints together before the next breakpoint", () => {
762
+ const { svg, props } = mount(four);
763
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
764
+ fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
765
+ const points = previewedPoints(props);
766
+ expect(points[2]!.t).toBeLessThan(points[3]!.t);
767
+ expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
768
+ expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
769
+ });
770
+
771
+ it("previews every move and persists the segment once on release", () => {
772
+ const { svg, props } = mount(four);
773
+ fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
774
+ for (const t of [1.7, 1.9, 2.1]) {
775
+ fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
776
+ }
777
+ expect(props.onPreview).toHaveBeenCalledTimes(3);
778
+ expect(props.onCommit).not.toHaveBeenCalled();
779
+ fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
780
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
781
+ });
782
+ });
783
+
671
784
  /** `mount`, plus the re-render a real store update causes — the persisted
672
785
  * automation and the new selection coming back down as props. */
673
786
  const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
@@ -1747,3 +1860,70 @@ describe("TimelineAutomationLane stretch", () => {
1747
1860
  expect(svg.style.cursor).not.toBe("col-resize");
1748
1861
  });
1749
1862
  });
1863
+
1864
+ describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
1865
+ /** Hover the lane the way a pointer entering it does. */
1866
+ function hover(container: HTMLElement): SVGSVGElement {
1867
+ const svg = container.querySelector("svg")!;
1868
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
1869
+ // React implements onPointerEnter through the delegated `pointerover`
1870
+ // event, not a native `pointerenter` — which does not bubble and so never
1871
+ // reaches its listener.
1872
+ fire(svg, "pointerover");
1873
+ return svg as SVGSVGElement;
1874
+ }
1875
+
1876
+ const handles = (container: HTMLElement) =>
1877
+ [...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
1878
+
1879
+ // A grab handle raised on hover is an offer, and a carve lane cannot honour
1880
+ // it: the analysis rewrites these envelopes on every run, so a point moved
1881
+ // here is discarded rather than saved. Dimming alone did not say that — the
1882
+ // handles still came up under the cursor and the drag silently did nothing.
1883
+ it("keeps its point handles hidden on hover", () => {
1884
+ const { container } = render(
1885
+ <TimelineAutomationLane
1886
+ {...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
1887
+ />,
1888
+ );
1889
+ hover(container);
1890
+ const drawn = handles(container);
1891
+ expect(drawn.length).toBeGreaterThan(0);
1892
+ for (const c of drawn) {
1893
+ expect(c.style.opacity).toBe("0");
1894
+ expect(c.style.cursor).toBe("default");
1895
+ }
1896
+ });
1897
+
1898
+ it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
1899
+ const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
1900
+ hover(container);
1901
+ const drawn = handles(container);
1902
+ expect(drawn.length).toBeGreaterThan(0);
1903
+ for (const c of drawn) {
1904
+ expect(c.style.opacity).toBe("1");
1905
+ expect(c.style.cursor).toBe("grab");
1906
+ }
1907
+ });
1908
+
1909
+ it("says why, in the lane, once hovered", () => {
1910
+ const { container } = render(
1911
+ <TimelineAutomationLane
1912
+ {...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
1913
+ />,
1914
+ );
1915
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
1916
+ hover(container);
1917
+ expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
1918
+ "Owned by the carve.",
1919
+ );
1920
+ });
1921
+
1922
+ it("says nothing when no reason was given", () => {
1923
+ const { container } = render(
1924
+ <TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
1925
+ );
1926
+ hover(container);
1927
+ expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
1928
+ });
1929
+ });
@@ -1,21 +1,15 @@
1
1
  /**
2
2
  * Breakpoint automation over an audio clip, edited the way a DAW edits it:
3
3
  * double-click the line to add a point, drag one to shape it, right-click or
4
- * Shift+click a point to remove it, Alt-drag the line between two points to bend
5
- * it, and double-click a point to type an exact value.
4
+ * Shift+click a point to remove it, drag a line segment to move both endpoints,
5
+ * Alt-drag the line to bend it, and double-click a point to type an exact value.
6
6
  *
7
- * Modifiers follow Ableton's, because that is the muscle memory an automation
8
- * lane inherits: Shift locks a drag to one axis and fines the value down, Alt
9
- * over a segment curves it, and Alt during a point drag ignores the grid.
7
+ * Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a
8
+ * segment or ignores the grid during a point drag. Background drags select a
9
+ * set that can be moved, deleted, or shaped together.
10
10
  *
11
- * Drag the background to draw a selection box around a set of breakpoints, then
12
- * Delete to remove them, drag any one of them to move the whole set, or
13
- * right-click inside the box for shapes over its span.
14
- *
15
- * The lane knows nothing about any particular effect. Which parameters it can
16
- * offer, their ranges, units and whether they read logarithmically all come
17
- * from the FX registry, so an effect gained upstream needs no change here — the
18
- * same principle the property panel's controls follow.
11
+ * Effect parameters, ranges, units, and scaling come from the FX registry, so
12
+ * an upstream effect needs no lane-specific code here.
19
13
  */
20
14
 
21
15
  import {
@@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
42
36
  import { simplifyPoints } from "./automationSimplify";
43
37
  import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
44
38
  import { defaultTimelineTheme } from "./timelineTheme";
39
+ import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths";
45
40
 
46
41
  /**
47
42
  * Drawn radius of a breakpoint.
@@ -86,13 +81,88 @@ function pointCircleStyle(
86
81
  }
87
82
 
88
83
  /** Pointer shape: a read-only lane can only be selected, a live one edited. */
89
- function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
84
+ /**
85
+ * Whether a point's grab handle is up.
86
+ *
87
+ * A raised handle is an offer to drag, so a read-only lane keeps them down: the
88
+ * carve rewrites its envelopes from the analysis on every run, and a point
89
+ * moved here is discarded rather than saved. Hidden rather than unmounted
90
+ * either way, so the hit area survives — a point dragged past the lane's edge
91
+ * fires pointerleave mid-gesture, and a handle that vanished then would drop
92
+ * the drag. A live drag and a selected range both keep them up regardless: the
93
+ * range is the subject of a pending Delete, and which points it caught cannot
94
+ * depend on where the mouse is.
95
+ */
96
+ /** The svg's tooltip: what this lane's gestures actually are. */
97
+ function laneTitle(readOnly: boolean | undefined): string {
98
+ return readOnly
99
+ ? "Drag a box to select points, which also selects this clip; then double-click to add a point"
100
+ : "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 a line segment to move both endpoints. 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.";
101
+ }
102
+
103
+ /**
104
+ * Why this lane will not take an edit, in the lane itself.
105
+ *
106
+ * Dimming and a default cursor say "not editable" but not WHY, and the why is
107
+ * the part that matters: the carve rewrites these envelopes from its own
108
+ * analysis on every run, so a point moved here is discarded rather than saved.
109
+ * Only while hovered, so a stack of read-only lanes is not a stack of notices,
110
+ * and never over a selection the author is about to act on.
111
+ *
112
+ * Separate from the gesture `hint` slot, which belongs to handlers that do not
113
+ * run on a read-only lane at all.
114
+ */
115
+ function ReadOnlyNote({
116
+ readOnly,
117
+ note,
118
+ hovered,
119
+ hasRange,
120
+ leftPx,
121
+ widthPx,
122
+ }: {
123
+ readOnly: boolean | undefined;
124
+ note: string | undefined;
125
+ hovered: boolean;
126
+ /** A selection is the subject of a pending action; do not cover it. */
127
+ hasRange: boolean;
128
+ leftPx: number;
129
+ widthPx: number;
130
+ }) {
131
+ if (!readOnly || !note || !hovered || hasRange) return null;
132
+ return (
133
+ <div
134
+ data-automation-readonly-note=""
135
+ 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"
136
+ style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
137
+ >
138
+ {note}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function pointHandleOpacity(args: {
144
+ readOnly: boolean | undefined;
145
+ hovered: boolean;
146
+ dragging: boolean;
147
+ hasRange: boolean;
148
+ }): number {
149
+ if (args.dragging || args.hasRange) return 1;
150
+ return !args.readOnly && args.hovered ? 1 : 0;
151
+ }
152
+
153
+ function laneCursor(
154
+ readOnly: boolean | undefined,
155
+ dragging: boolean,
156
+ stretching: boolean,
157
+ segmentHovering: boolean,
158
+ ): 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
92
161
  // any other cursor there would advertise a gesture the press will not start.
93
162
  if (stretching) return "col-resize";
94
163
  if (readOnly) return "pointer";
95
- return dragging ? "grabbing" : "crosshair";
164
+ if (dragging) return "grabbing";
165
+ return segmentHovering ? "grab" : "crosshair";
96
166
  }
97
167
 
98
168
  export interface TimelineAutomationLaneProps {
@@ -120,6 +190,9 @@ export interface TimelineAutomationLaneProps {
120
190
  snapTimes?: readonly number[];
121
191
  /** Editing writes to the selected element, so an unselected clip is read-only. */
122
192
  readOnly?: boolean;
193
+ /** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
194
+ * disabled; the author still has to guess what would let them edit it. */
195
+ readOnlyNote?: string;
123
196
  /** Called when a read-only lane is pressed: selects the clip so it goes live. */
124
197
  onSelect?(): void;
125
198
  /** Active selection box on THIS lane, or null. */
@@ -142,6 +215,7 @@ export function TimelineAutomationLane({
142
215
  onCommit,
143
216
  snapTimes,
144
217
  readOnly,
218
+ readOnlyNote,
145
219
  onSelect,
146
220
  rangeSelection,
147
221
  onRangeSelect,
@@ -245,7 +319,16 @@ export function TimelineAutomationLane({
245
319
  duration,
246
320
  rangeSelection,
247
321
  });
248
- const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
322
+ const {
323
+ dragIndex,
324
+ curveIndex,
325
+ segmentDragIndex,
326
+ segmentHoverIndex,
327
+ edgeDrag,
328
+ edgeHover,
329
+ hint,
330
+ editing,
331
+ } = gestures;
249
332
 
250
333
  const removeAt = useCallback(
251
334
  (index: number): void => {
@@ -353,8 +436,9 @@ export function TimelineAutomationLane({
353
436
  height: h,
354
437
  cursor: laneCursor(
355
438
  readOnly,
356
- dragIndex !== null || curveIndex !== null,
439
+ dragIndex !== null || curveIndex !== null || segmentDragIndex !== null,
357
440
  edgeDrag !== null || edgeHover,
441
+ segmentHoverIndex !== null,
358
442
  ),
359
443
  opacity: readOnly ? 0.55 : 1,
360
444
  touchAction: "none",
@@ -362,7 +446,10 @@ export function TimelineAutomationLane({
362
446
  width={widthPx + PAD_X * 2}
363
447
  height={h}
364
448
  onPointerEnter={() => setHovered(true)}
365
- onPointerLeave={() => setHovered(false)}
449
+ onPointerLeave={() => {
450
+ setHovered(false);
451
+ gestures.onPointerLeave();
452
+ }}
366
453
  onPointerDown={gestures.onPointerDown}
367
454
  onPointerMove={gestures.onPointerMove}
368
455
  onPointerUp={gestures.endDrag}
@@ -372,11 +459,7 @@ export function TimelineAutomationLane({
372
459
  role="group"
373
460
  aria-label={`${range.label} automation`}
374
461
  >
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>
462
+ <title>{laneTitle(readOnly)}</title>
380
463
  {/* No plate behind the envelope: the lane used to darken its clip's width,
381
464
  which drew a box inside the row and made a stack of lanes read as tiles
382
465
  rather than as rows of one timeline. The row background shows through, and
@@ -409,12 +492,14 @@ export function TimelineAutomationLane({
409
492
  pointerEvents="none"
410
493
  />
411
494
  ) : null}
412
- <path
413
- d={path}
414
- fill="none"
415
- stroke={accentColor}
416
- strokeWidth={1.5}
417
- opacity={lane.points.length === 0 ? 0.35 : 0.95}
495
+ <AutomationEnvelopePaths
496
+ path={path}
497
+ lane={lane}
498
+ range={range}
499
+ accentColor={accentColor}
500
+ activeSegment={segmentDragIndex ?? segmentHoverIndex}
501
+ xOf={xOf}
502
+ yOf={yOf}
418
503
  />
419
504
  {lane.points.map((p, i) => {
420
505
  // Endpoint-inclusive, the same rule Delete uses, so what looks caught by
@@ -433,15 +518,14 @@ export function TimelineAutomationLane({
433
518
  fill={accentColor}
434
519
  stroke={stroke}
435
520
  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
521
  style={{
443
522
  cursor: readOnly ? "default" : "grab",
444
- opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0,
523
+ opacity: pointHandleOpacity({
524
+ readOnly,
525
+ hovered,
526
+ dragging: dragIndex !== null,
527
+ hasRange: Boolean(rangeSelection),
528
+ }),
445
529
  }}
446
530
  onContextMenu={(e) => {
447
531
  e.preventDefault();
@@ -484,6 +568,15 @@ export function TimelineAutomationLane({
484
568
  </div>
485
569
  ) : null}
486
570
 
571
+ <ReadOnlyNote
572
+ readOnly={readOnly}
573
+ note={readOnlyNote}
574
+ hovered={hovered}
575
+ hasRange={Boolean(rangeSelection)}
576
+ leftPx={leftPx}
577
+ widthPx={widthPx}
578
+ />
579
+
487
580
  {menuAt && rangeSelection ? (
488
581
  <AutomationSelectionMenu
489
582
  x={menuAt.x}
@@ -12,7 +12,7 @@ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core
12
12
  import { TimelineAutomationLane } from "./TimelineAutomationLane";
13
13
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
14
  import { getTimelineLaneTop } from "./timelineLayout";
15
- import { groupAutomationLanes } from "./automationLaneData";
15
+ import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
16
16
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
17
17
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
18
18
  import type { TimelineElement } from "../store/playerStore";
@@ -100,7 +100,21 @@ function ClipAutomationLanes({
100
100
  onCommit={bound.onCommit}
101
101
  onSelect={bound.onSelect}
102
102
  snapTimes={snapTimes}
103
- readOnly={bound.readOnly}
103
+ // The carve owns its own envelopes and rewrites them on every
104
+ // re-run, so a drag would be silently discarded — shown, but not
105
+ // editable. Per LANE, not per binding: a carved bed can carry the
106
+ // author's own volume curve beside the carve's bands.
107
+ readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}
108
+ // Two distinct reasons, so two notes. The carve one names the way
109
+ // out — change strength, or switch the carve off and own the chain
110
+ // — because "not editable" without that reads as broken.
111
+ readOnlyNote={
112
+ isCarveLane(lane.target, bound.chain)
113
+ ? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
114
+ : bound.readOnly
115
+ ? "Read-only here."
116
+ : undefined
117
+ }
104
118
  rangeSelection={
105
119
  bound.selection?.target === lane.target
106
120
  ? {
@@ -128,6 +142,10 @@ export interface TimelineAutomationLaneSlotProps {
128
142
  pps: number;
129
143
  /** Keyframe lanes already stacked above, which automation sits under. */
130
144
  laneCount: number;
145
+ /** Exact y for the first lane, overriding `laneCount`. A group's lanes sit
146
+ * directly under its header row rather than under a stack of keyframe
147
+ * lanes, so it cannot be said in `laneCount`. */
148
+ topOffset?: number;
131
149
  accentColor: string;
132
150
  /** Composition-time playhead; the slot converts it to clip-local. */
133
151
  currentTime: number;
@@ -151,6 +169,7 @@ export function TimelineAutomationLaneSlot({
151
169
  lanes,
152
170
  pps,
153
171
  laneCount,
172
+ topOffset,
154
173
  accentColor,
155
174
  currentTime,
156
175
  beatTimes,
@@ -165,7 +184,7 @@ export function TimelineAutomationLaneSlot({
165
184
  else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
166
185
  }
167
186
  });
168
- const top = getTimelineLaneTop(laneCount);
187
+ const top = topOffset ?? getTimelineLaneTop(laneCount);
169
188
  return (
170
189
  <>
171
190
  {clips.map((element) => (