@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
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The visibility control's accessible contract.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* The wording ran behind the `audio-track-mute` canary at 0%, so it had never
|
|
7
|
+
* rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
|
|
8
|
+
* state rather than the action, and dropped the track suffix so every audio row
|
|
9
|
+
* shared one accessible name. Pinned here because the label, the icon and the
|
|
10
|
+
* callback's arguments are the whole identity of this control.
|
|
11
|
+
*
|
|
12
|
+
* The mute presentation itself is gone (`remove mute and solo from tracks and
|
|
13
|
+
* groups`): the control is the visibility eye it always was, and an audio row
|
|
14
|
+
* renders it only when already hidden, which is the way back out.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import React, { act } from "react";
|
|
@@ -24,7 +27,7 @@ afterEach(() => {
|
|
|
24
27
|
|
|
25
28
|
function renderButton(props: {
|
|
26
29
|
hidden: boolean;
|
|
27
|
-
|
|
30
|
+
visible?: boolean;
|
|
28
31
|
trackDisplayNumber: number | null;
|
|
29
32
|
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
|
30
33
|
const host = document.createElement("div");
|
|
@@ -37,8 +40,7 @@ function renderButton(props: {
|
|
|
37
40
|
hidden: props.hidden,
|
|
38
41
|
trackNumber: 7,
|
|
39
42
|
trackDisplayNumber: props.trackDisplayNumber,
|
|
40
|
-
visible: true,
|
|
41
|
-
isAudioTrack: props.isAudioTrack,
|
|
43
|
+
visible: props.visible ?? true,
|
|
42
44
|
onToggle,
|
|
43
45
|
}),
|
|
44
46
|
),
|
|
@@ -49,31 +51,7 @@ function renderButton(props: {
|
|
|
49
51
|
const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
|
|
50
52
|
|
|
51
53
|
describe("VisibilityButton", () => {
|
|
52
|
-
it("names the action, not the state
|
|
53
|
-
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
54
|
-
expect(labelOf(view.host)).toBe("Mute track 2");
|
|
55
|
-
view.unmount();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// The half that was wrong: a muted row read "Muted", so nothing told a
|
|
59
|
-
// screen-reader user that activating it would unmute.
|
|
60
|
-
it("promises the un-mute when the audio track is already muted", () => {
|
|
61
|
-
const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
62
|
-
expect(labelOf(view.host)).toBe("Unmute track 2");
|
|
63
|
-
view.unmount();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
|
|
67
|
-
const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
|
|
68
|
-
const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
|
|
69
|
-
expect(labelOf(first.host)).toBe("Mute track 1");
|
|
70
|
-
expect(labelOf(second.host)).toBe("Mute track 3");
|
|
71
|
-
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
|
72
|
-
first.unmount();
|
|
73
|
-
second.unmount();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("still says Hide/Show on a visual track", () => {
|
|
54
|
+
it("names the action, not the state", () => {
|
|
77
55
|
const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
|
78
56
|
expect(labelOf(shown.host)).toBe("Hide track 2");
|
|
79
57
|
shown.unmount();
|
|
@@ -82,15 +60,34 @@ describe("VisibilityButton", () => {
|
|
|
82
60
|
hiddenRow.unmount();
|
|
83
61
|
});
|
|
84
62
|
|
|
63
|
+
it("keeps each row's name unique, so two tracks are distinguishable", () => {
|
|
64
|
+
const first = renderButton({ hidden: false, trackDisplayNumber: 1 });
|
|
65
|
+
const second = renderButton({ hidden: false, trackDisplayNumber: 3 });
|
|
66
|
+
expect(labelOf(first.host)).toBe("Hide track 1");
|
|
67
|
+
expect(labelOf(second.host)).toBe("Hide track 3");
|
|
68
|
+
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
|
69
|
+
first.unmount();
|
|
70
|
+
second.unmount();
|
|
71
|
+
});
|
|
72
|
+
|
|
85
73
|
// The callback acts on the REAL track key; the display row rides along so the
|
|
86
74
|
// undo-history label announces the same row this button just did, instead of
|
|
87
75
|
// re-deriving it from an ordering that has no group anchors in it.
|
|
88
76
|
it("toggles the real track number and passes the row it announced", () => {
|
|
89
|
-
const view = renderButton({ hidden: false,
|
|
77
|
+
const view = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
|
90
78
|
view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
91
79
|
expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
|
|
92
80
|
view.unmount();
|
|
93
81
|
});
|
|
82
|
+
|
|
83
|
+
// `visible={false}` renders a SPACER, not nothing: every row's control
|
|
84
|
+
// columns have to line up whether or not this row offers the control.
|
|
85
|
+
it("holds the column with a spacer when withheld", () => {
|
|
86
|
+
const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
|
|
87
|
+
expect(view.host.querySelector("button")).toBeNull();
|
|
88
|
+
expect(view.host.querySelector("span")).toBeTruthy();
|
|
89
|
+
view.unmount();
|
|
90
|
+
});
|
|
94
91
|
});
|
|
95
92
|
|
|
96
93
|
describe("PlainTrackHeader", () => {
|
|
@@ -98,7 +95,6 @@ describe("PlainTrackHeader", () => {
|
|
|
98
95
|
const host = document.createElement("div");
|
|
99
96
|
document.body.append(host);
|
|
100
97
|
const root = createRoot(host);
|
|
101
|
-
const onToggleSolo = vi.fn();
|
|
102
98
|
act(() =>
|
|
103
99
|
root.render(
|
|
104
100
|
React.createElement(PlainTrackHeader, {
|
|
@@ -110,52 +106,34 @@ describe("PlainTrackHeader", () => {
|
|
|
110
106
|
isAudioTrack: true,
|
|
111
107
|
onToggleTrackHidden: vi.fn(),
|
|
112
108
|
showTrackLabel: true,
|
|
113
|
-
isGroupMuted: false,
|
|
114
|
-
isSoloed: false,
|
|
115
|
-
onToggleSolo,
|
|
116
109
|
...overrides,
|
|
117
110
|
}),
|
|
118
111
|
),
|
|
119
112
|
);
|
|
120
|
-
return { host, unmount: () => act(() => root.unmount())
|
|
113
|
+
return { host, unmount: () => act(() => root.unmount()) };
|
|
121
114
|
}
|
|
122
115
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const view = renderHeader({ isSoloed: true });
|
|
129
|
-
expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
|
|
130
|
-
view.unmount();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("withholds solo from a visual track", () => {
|
|
134
|
-
const view = renderHeader({ isAudioTrack: false });
|
|
135
|
-
expect(soloButton(view.host)).toBeNull();
|
|
116
|
+
// A row that says what it is with a speaker does not also need the hide
|
|
117
|
+
// affordance sitting in the eye's slot.
|
|
118
|
+
it("withholds the eye from an audible audio track", () => {
|
|
119
|
+
const view = renderHeader();
|
|
120
|
+
expect(view.host.querySelector("button")).toBeNull();
|
|
136
121
|
view.unmount();
|
|
137
122
|
});
|
|
138
123
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
124
|
+
// Withholding it unconditionally withheld the only way back: `data-hidden`
|
|
125
|
+
// silences the clip in preview and drops it from the render, and nothing else
|
|
126
|
+
// writes it, so a track hidden by hand or by "Hide all" was silent with no
|
|
127
|
+
// control anywhere to restore it.
|
|
128
|
+
it("offers it back once the audio track is hidden", () => {
|
|
143
129
|
const view = renderHeader({ isTrackHidden: true });
|
|
144
|
-
expect(
|
|
130
|
+
expect(labelOf(view.host)).toBe("Show track 1");
|
|
145
131
|
view.unmount();
|
|
146
132
|
});
|
|
147
133
|
|
|
148
|
-
it("
|
|
149
|
-
const view = renderHeader({
|
|
150
|
-
expect(
|
|
151
|
-
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
|
|
152
|
-
view.unmount();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("leaves an audible row unstruck", () => {
|
|
156
|
-
const view = renderHeader();
|
|
157
|
-
expect(labelSpan(view.host)?.className).not.toContain("line-through");
|
|
158
|
-
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
|
|
134
|
+
it("keeps the eye on a visual track", () => {
|
|
135
|
+
const view = renderHeader({ isAudioTrack: false });
|
|
136
|
+
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
159
137
|
view.unmount();
|
|
160
138
|
});
|
|
161
139
|
});
|
|
@@ -1,29 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
2
3
|
import { Music } from "../../icons/SystemIcons";
|
|
3
|
-
import { TimelineSoloButton } from "./TimelineSoloButton";
|
|
4
4
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import { TrackClipCount } from "./TrackClipCount";
|
|
6
6
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* Action-phrased and per-track, like the visual branch: `hidden` here is the
|
|
12
|
-
* CURRENT state, so the name has to promise the opposite. It read "Muted" /
|
|
13
|
-
* "Mute" until the rollout, which named the state instead of the action (a
|
|
14
|
-
* screen-reader user could not tell that activating an already-muted row would
|
|
15
|
-
* unmute it) and dropped `suffix`, so every audio row shared one accessible
|
|
16
|
-
* name — music plus VO being the ordinary case. The wording matches the undo
|
|
17
|
-
* entry `timelineTrackVisibility` writes for the same click, where `hidden` is
|
|
18
|
-
* the INCOMING state and the two therefore read inverted.
|
|
19
|
-
*/
|
|
20
|
-
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
|
21
|
-
if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
|
|
8
|
+
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
|
|
9
|
+
// this is the visibility eye it always was, and audio rows do not render it.
|
|
10
|
+
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
|
|
22
11
|
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
23
12
|
}
|
|
24
13
|
|
|
25
|
-
function visibilityButtonIcon(
|
|
26
|
-
const Icon =
|
|
14
|
+
function visibilityButtonIcon(hidden: boolean) {
|
|
15
|
+
const Icon = hidden ? EyeSlash : Eye;
|
|
27
16
|
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
28
17
|
}
|
|
29
18
|
|
|
@@ -32,22 +21,19 @@ export function VisibilityButton({
|
|
|
32
21
|
trackNumber,
|
|
33
22
|
trackDisplayNumber,
|
|
34
23
|
visible,
|
|
35
|
-
isAudioTrack,
|
|
36
24
|
onToggle,
|
|
37
25
|
}: {
|
|
38
26
|
hidden: boolean;
|
|
39
27
|
trackNumber: number;
|
|
40
28
|
trackDisplayNumber: number | null;
|
|
41
29
|
visible: boolean;
|
|
42
|
-
isAudioTrack?: boolean;
|
|
43
30
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
44
31
|
}) {
|
|
45
32
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
46
33
|
// Display number in the text, real key in the callback. The two must not be
|
|
47
34
|
// conflated in either direction.
|
|
48
35
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
49
|
-
const
|
|
50
|
-
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
|
36
|
+
const label = visibilityButtonLabel(hidden, suffix);
|
|
51
37
|
return (
|
|
52
38
|
<button
|
|
53
39
|
type="button"
|
|
@@ -59,10 +45,12 @@ export function VisibilityButton({
|
|
|
59
45
|
onPointerDown={(event) => event.stopPropagation()}
|
|
60
46
|
onClick={(event) => {
|
|
61
47
|
event.stopPropagation();
|
|
48
|
+
// Display number alongside the real key: the undo-history label must
|
|
49
|
+
// announce the same row this button just did (see `onToggleTrackHidden`).
|
|
62
50
|
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
|
63
51
|
}}
|
|
64
52
|
>
|
|
65
|
-
{visibilityButtonIcon(
|
|
53
|
+
{visibilityButtonIcon(hidden)}
|
|
66
54
|
</button>
|
|
67
55
|
);
|
|
68
56
|
}
|
|
@@ -77,10 +65,8 @@ export function PlainTrackHeader({
|
|
|
77
65
|
showTrackLabel,
|
|
78
66
|
isTrackHidden,
|
|
79
67
|
isAudioTrack,
|
|
80
|
-
isGroupMuted,
|
|
81
|
-
isSoloed,
|
|
82
|
-
onToggleSolo,
|
|
83
68
|
onToggleTrackHidden,
|
|
69
|
+
trailing,
|
|
84
70
|
}: {
|
|
85
71
|
trackNumber: number;
|
|
86
72
|
trackDisplayNumber: number | null;
|
|
@@ -90,37 +76,61 @@ export function PlainTrackHeader({
|
|
|
90
76
|
isAudioTrack: boolean;
|
|
91
77
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
92
78
|
showTrackLabel: boolean;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
/** Trailing controls that belong on the control line — the FX entry points,
|
|
80
|
+
* which the caller owns because only it knows the clip they act on. */
|
|
81
|
+
trailing?: React.ReactNode;
|
|
96
82
|
}) {
|
|
97
83
|
return (
|
|
98
84
|
<>
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
85
|
+
{/* One line: the name, then every control pushed to the right edge. The
|
|
86
|
+
two-line split this replaced existed to stop four controls truncating
|
|
87
|
+
the name — but the name already truncates on its own (`min-w-0` plus
|
|
88
|
+
`truncate`), and the controls are `shrink-0`, so they hold the edge
|
|
89
|
+
and the name gives way instead. */}
|
|
90
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
91
|
+
{isAudioTrack && (
|
|
92
|
+
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
93
|
+
)}
|
|
94
|
+
{/* No `flex-1`: the name takes only the width it needs, so the clip
|
|
95
|
+
count sits against it rather than being pushed out to meet the
|
|
96
|
+
controls. The slack goes to the `ml-auto` group below instead.
|
|
97
|
+
|
|
98
|
+
Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
+
read at all, and a tooltip is no use to a name you are scanning a
|
|
100
|
+
column of. `break-words` so a long single token breaks instead of
|
|
101
|
+
forcing the column wider. */}
|
|
102
|
+
{showTrackLabel && (
|
|
103
|
+
<span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
|
|
104
|
+
)}
|
|
105
|
+
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
|
+
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
|
|
107
|
+
truncating name leaves, so the controls sit on the edge whatever the
|
|
108
|
+
name's length. */}
|
|
109
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
110
|
+
{/* Not on an audio track. The control is the old visibility eye, and
|
|
111
|
+
on audio it silences rather than hides — but a row that already says
|
|
112
|
+
what it is with a speaker does not also need the hide affordance
|
|
113
|
+
sitting in the eye's slot. `visible={false}` rather than omitting the
|
|
114
|
+
element, so the spacer keeps every row's control columns aligned.
|
|
115
|
+
|
|
116
|
+
EXCEPT when the audio track is ALREADY hidden. Withholding the
|
|
117
|
+
control unconditionally withheld the only way back: `data-hidden`
|
|
118
|
+
silences the clip in preview and drops it from the render, the
|
|
119
|
+
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
|
|
120
|
+
else writes it — so a track hidden before this rule (or by "Hide
|
|
121
|
+
all", or by hand) was silent with no control anywhere to restore it.
|
|
122
|
+
Offering the eye only in that state keeps the affordance off a normal
|
|
123
|
+
audio row while leaving the door open from the inside. */}
|
|
124
|
+
<VisibilityButton
|
|
125
|
+
hidden={isTrackHidden}
|
|
126
|
+
trackNumber={trackNumber}
|
|
127
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
128
|
+
visible={!isAudioTrack || isTrackHidden}
|
|
129
|
+
onToggle={onToggleTrackHidden}
|
|
130
|
+
/>
|
|
131
|
+
{trailing}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
124
134
|
</>
|
|
125
135
|
);
|
|
126
136
|
}
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
automationLaneLabel,
|
|
5
5
|
automationLaneLabelParts,
|
|
6
6
|
elementAutomation,
|
|
7
|
+
elementAutomationLanes,
|
|
7
8
|
groupAutomationLanes,
|
|
8
9
|
laneGroupKey,
|
|
9
10
|
elementFxChain,
|
|
11
|
+
isCarveLane,
|
|
10
12
|
} from "./automationLaneData";
|
|
11
13
|
import type { TimelineElement } from "../store/timelineElement";
|
|
12
14
|
|
|
@@ -268,3 +270,95 @@ describe("groupAutomationLanes", () => {
|
|
|
268
270
|
expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
|
|
269
271
|
});
|
|
270
272
|
});
|
|
273
|
+
|
|
274
|
+
// A carve writes its own filter bands AND the lanes that drive them, and
|
|
275
|
+
// `withoutCarveLanes` replaces every one of them on each re-run — so a drag on
|
|
276
|
+
// one is discarded the next time the carve analyses. The rack also counts the
|
|
277
|
+
// carve as ONE module rather than the filters it compiles to, so a lane per
|
|
278
|
+
// band reads as "automation on effects I removed", which is exactly how it was
|
|
279
|
+
// reported.
|
|
280
|
+
describe("carve-generated lanes", () => {
|
|
281
|
+
const carved = (): TimelineElement => ({
|
|
282
|
+
id: "vo",
|
|
283
|
+
tag: "audio",
|
|
284
|
+
start: 0,
|
|
285
|
+
duration: 5,
|
|
286
|
+
track: 0,
|
|
287
|
+
fxChain: JSON.stringify({
|
|
288
|
+
version: 1,
|
|
289
|
+
nodes: [
|
|
290
|
+
{
|
|
291
|
+
type: "peaking",
|
|
292
|
+
id: "n1",
|
|
293
|
+
fromCarve: true,
|
|
294
|
+
params: { frequency: 160, gain: -6, q: 1.4 },
|
|
295
|
+
},
|
|
296
|
+
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -3, q: 1 } },
|
|
297
|
+
],
|
|
298
|
+
}),
|
|
299
|
+
automation: JSON.stringify({
|
|
300
|
+
version: 1,
|
|
301
|
+
lanes: [
|
|
302
|
+
{
|
|
303
|
+
target: "fx.n1.gain",
|
|
304
|
+
points: [
|
|
305
|
+
{ t: 0, v: 0 },
|
|
306
|
+
{ t: 1, v: -4 },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
target: "fx.n2.gain",
|
|
311
|
+
points: [
|
|
312
|
+
{ t: 0, v: 0 },
|
|
313
|
+
{ t: 1, v: -2 },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
target: "volume",
|
|
318
|
+
points: [
|
|
319
|
+
{ t: 0, v: 1 },
|
|
320
|
+
{ t: 1, v: 0.5 },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// These lanes used to be withheld. A bed whose EVERY lane is the carve's —
|
|
328
|
+
// which is what a plain voiceover carve produces — then showed no automation
|
|
329
|
+
// at all and no control to reveal any, so the carve looked like it had done
|
|
330
|
+
// nothing. The ducking curve is what a carve IS; it is shown, and marked
|
|
331
|
+
// read-only because the carve rewrites it on each analysis.
|
|
332
|
+
it("draws the carve's lanes alongside the author's", () => {
|
|
333
|
+
const targets = elementAutomationLanes(carved()).map((lane) => lane.target);
|
|
334
|
+
expect(targets).toContain("fx.n1.gain");
|
|
335
|
+
expect(targets).toContain("fx.n2.gain");
|
|
336
|
+
expect(targets).toContain("volume");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("counts a carve band as a timeline row, so the row reserves its height", () => {
|
|
340
|
+
const rows = groupAutomationLanes([carved()]);
|
|
341
|
+
expect(rows).toHaveLength(3);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("tells a carve's lane from the author's on the same element", () => {
|
|
345
|
+
const chain = elementFxChain(carved());
|
|
346
|
+
expect(isCarveLane("fx.n1.gain", chain)).toBe(true);
|
|
347
|
+
// n2 is a hand-built peaking band with the same parameter — only the
|
|
348
|
+
// `fromCarve` tag separates them.
|
|
349
|
+
expect(isCarveLane("fx.n2.gain", chain)).toBe(false);
|
|
350
|
+
expect(isCarveLane("volume", chain)).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("treats every lane as the author's when nothing is carve-tagged", () => {
|
|
354
|
+
const plain = {
|
|
355
|
+
...carved(),
|
|
356
|
+
fxChain: JSON.stringify({
|
|
357
|
+
version: 1,
|
|
358
|
+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 160, gain: -6, q: 1.4 } }],
|
|
359
|
+
}),
|
|
360
|
+
};
|
|
361
|
+
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
|
|
362
|
+
expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -91,7 +91,30 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Is this lane one the CARVE wrote, rather than the author?
|
|
96
|
+
*
|
|
97
|
+
* The carve compiles to tagged nodes and rewrites their envelopes on every
|
|
98
|
+
* re-run (`withoutCarveLanes` replaces each one), so a drag on such a lane is
|
|
99
|
+
* silently discarded the next time it analyses. Read-only rather than hidden:
|
|
100
|
+
* the ducking curve is what a carve IS, and the whole reason to look at a
|
|
101
|
+
* carved bed in the timeline is to see where it makes room. Hiding them left a
|
|
102
|
+
* bed whose every lane was the carve's showing no automation at all and no
|
|
103
|
+
* control to reveal any — which reads as the carve having done nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function isCarveLane(target: string, chain: HfAudioFxChain | null): boolean {
|
|
106
|
+
return (chain?.nodes ?? []).some(
|
|
107
|
+
(node) => node.fromCarve && node.id && target.startsWith(`fx.${node.id}.`),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
/** Lanes in the order they are drawn, one row each. */
|
|
112
|
+
/**
|
|
113
|
+
* The lanes a TIMELINE row should draw.
|
|
114
|
+
*
|
|
115
|
+
* Every lane the element carries, the carve's included — see `isCarveLane` for
|
|
116
|
+
* why those are shown read-only instead of withheld.
|
|
117
|
+
*/
|
|
95
118
|
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
|
|
96
119
|
return elementAutomation(element).lanes;
|
|
97
120
|
}
|
|
@@ -59,6 +59,34 @@ export function armGroupDrag(
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Snapshot the two endpoints owned by a segment drag.
|
|
64
|
+
*
|
|
65
|
+
* The pointer itself is the anchor: a line can be grabbed anywhere along its
|
|
66
|
+
* span, and the segment must not jump so either endpoint takes the pointer's
|
|
67
|
+
* place when the gesture starts.
|
|
68
|
+
*/
|
|
69
|
+
export function armSegmentDrag(
|
|
70
|
+
lane: HfAutomationLane,
|
|
71
|
+
index: number,
|
|
72
|
+
anchor: { t: number; v: number },
|
|
73
|
+
): GroupDragSnapshot | null {
|
|
74
|
+
const a = lane.points[index];
|
|
75
|
+
const b = lane.points[index + 1];
|
|
76
|
+
if (!a || !b) return null;
|
|
77
|
+
return {
|
|
78
|
+
points: lane.points.map((p) => ({ ...p })),
|
|
79
|
+
indices: [index, index + 1],
|
|
80
|
+
anchor: { ...anchor },
|
|
81
|
+
selection: {
|
|
82
|
+
t0: a.t,
|
|
83
|
+
t1: b.t,
|
|
84
|
+
v0: Math.min(a.v, b.v),
|
|
85
|
+
v1: Math.max(a.v, b.v),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
export interface GroupMoveResult {
|
|
63
91
|
points: HfAutomationLane["points"];
|
|
64
92
|
selection: AutomationSelectionBox;
|
|
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
|
|
|
35
35
|
export const MIN_POINT_GAP_SEC = 0.001;
|
|
36
36
|
/** Hit radius for grabbing a point, in px. */
|
|
37
37
|
export const GRAB_PX = 7;
|
|
38
|
+
/** Distance from the drawn envelope that offers a segment drag, in px. */
|
|
39
|
+
export const SEGMENT_GRAB_PX = 5;
|
|
38
40
|
/** Samples used to draw a segment the eye should see as curved. */
|
|
39
41
|
export const DRAW_SAMPLES = 64;
|
|
40
42
|
/**
|
|
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
|
|
|
222
224
|
return best;
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
/** Draw commands from one breakpoint to the next, sampled when it is curved. */
|
|
228
|
+
function segmentLineCommands(input: {
|
|
229
|
+
lane: HfAutomationLane;
|
|
230
|
+
range: AutomationRange;
|
|
231
|
+
index: number;
|
|
232
|
+
xOf(t: number): number;
|
|
233
|
+
yOf(v: number): number;
|
|
234
|
+
}): string[] {
|
|
235
|
+
const { lane, range, index, xOf, yOf } = input;
|
|
236
|
+
const a = lane.points[index];
|
|
237
|
+
const b = lane.points[index + 1];
|
|
238
|
+
if (!a || !b) return [];
|
|
239
|
+
// A via point bends the segment with no `curve` of its own, so the
|
|
240
|
+
// straight-line shortcut has to rule out both.
|
|
241
|
+
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
|
|
242
|
+
return [`L ${xOf(b.t)} ${yOf(b.v)}`];
|
|
243
|
+
}
|
|
244
|
+
return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
|
|
245
|
+
const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
|
|
246
|
+
return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
225
250
|
/**
|
|
226
251
|
* The svg path for one lane's envelope.
|
|
227
252
|
*
|
|
@@ -247,24 +272,38 @@ export function envelopePath(input: {
|
|
|
247
272
|
}
|
|
248
273
|
const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
|
|
249
274
|
for (let i = 0; i + 1 < lane.points.length; i += 1) {
|
|
250
|
-
|
|
251
|
-
const b = lane.points[i + 1];
|
|
252
|
-
if (!a || !b) continue;
|
|
253
|
-
// A via point bends the segment with no `curve` of its own, so the
|
|
254
|
-
// straight-line shortcut has to rule out both.
|
|
255
|
-
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
|
|
256
|
-
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
|
|
260
|
-
const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
|
|
261
|
-
pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
|
|
262
|
-
}
|
|
275
|
+
pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
|
|
263
276
|
}
|
|
264
277
|
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
|
|
265
278
|
return pts.join(" ");
|
|
266
279
|
}
|
|
267
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The visible path for one segment, without the lane's constant extensions.
|
|
283
|
+
*
|
|
284
|
+
* Used for the hover/drag affordance: only the segment under the pointer grows
|
|
285
|
+
* heavier, rather than making the entire envelope look selected. It samples by
|
|
286
|
+
* the same rule as `envelopePath`, so a curved or logarithmic segment's hover
|
|
287
|
+
* stroke sits exactly on the line the audio model draws.
|
|
288
|
+
*/
|
|
289
|
+
export function envelopeSegmentPath(input: {
|
|
290
|
+
lane: HfAutomationLane;
|
|
291
|
+
range: AutomationRange;
|
|
292
|
+
index: number;
|
|
293
|
+
xOf(t: number): number;
|
|
294
|
+
yOf(v: number): number;
|
|
295
|
+
}): string | null {
|
|
296
|
+
const { lane, range, index, xOf, yOf } = input;
|
|
297
|
+
const a = lane.points[index];
|
|
298
|
+
const b = lane.points[index + 1];
|
|
299
|
+
if (!a || !b) return null;
|
|
300
|
+
const pts = [
|
|
301
|
+
`M ${xOf(a.t)} ${yOf(a.v)}`,
|
|
302
|
+
...segmentLineCommands({ lane, range, index, xOf, yOf }),
|
|
303
|
+
];
|
|
304
|
+
return pts.join(" ");
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
|
|
269
308
|
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
|
|
270
309
|
}
|