@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.
- package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
- package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
- package/dist/assets/index-CQMHN4nk.js +431 -0
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +7646 -6514
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/domSelectionTimelineMirror.ts +12 -3
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
- package/src/player/components/TimelineAutomationLane.tsx +129 -36
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
- package/src/player/components/TimelineGroupRow.test.tsx +222 -0
- package/src/player/components/TimelineGroupRow.tsx +140 -71
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /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,
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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={() =>
|
|
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
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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:
|
|
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
|
-
|
|
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) => (
|