@hyperframes/studio 0.7.107 → 0.7.108
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-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FX section for an audio element: the chain, plus the voiceover carve.
|
|
3
|
+
*
|
|
4
|
+
* The carve is its own module — see `propertyPanelFxCarveModule.tsx` for why it
|
|
5
|
+
* is not an entry in the chain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
|
|
9
|
+
import {
|
|
10
|
+
defaultAudioFxParams,
|
|
11
|
+
mintAudioFxNodeId,
|
|
12
|
+
type HfAudioFxChain,
|
|
13
|
+
type HfAudioFxNode,
|
|
14
|
+
type HfAudioFxParamValues,
|
|
15
|
+
} from "@hyperframes/core/audio-fx";
|
|
16
|
+
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
17
|
+
import {
|
|
18
|
+
addAudioEq,
|
|
19
|
+
audioEqIds,
|
|
20
|
+
removeAudioEq,
|
|
21
|
+
setAudioEqBandGain,
|
|
22
|
+
} from "@hyperframes/core/audio-fx-eq";
|
|
23
|
+
import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-fx-profiles";
|
|
24
|
+
import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
|
|
25
|
+
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
|
26
|
+
import { FxRackChain } from "./propertyPanelFxRackChain.js";
|
|
27
|
+
import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
|
|
28
|
+
import { useFxAudition } from "./useFxAudition.js";
|
|
29
|
+
import {
|
|
30
|
+
nodeOrigin,
|
|
31
|
+
trackNodeAdded,
|
|
32
|
+
trackNodeMoved,
|
|
33
|
+
trackNodeRemoved,
|
|
34
|
+
trackPresetApplied,
|
|
35
|
+
trackPresetAuditioned,
|
|
36
|
+
trackPresetAutomated,
|
|
37
|
+
trackPresetRemoved,
|
|
38
|
+
} from "./audioFxTelemetry.js";
|
|
39
|
+
import type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
|
|
40
|
+
|
|
41
|
+
export type { FxSectionProps } from "./propertyPanelFxSectionTypes.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One effect appended, at the values its module opens on.
|
|
45
|
+
*
|
|
46
|
+
* For most effects that is the registry's defaults. For the five with a
|
|
47
|
+
* derived knob it is NOT: the registry defaults are not a point on the
|
|
48
|
+
* profile's curve, so the module opened reading a strength it was not set to —
|
|
49
|
+
* a compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB,
|
|
50
|
+
* which is the "quieter as you turn it up" bug the profiles exist to prevent,
|
|
51
|
+
* on the very first frame. Seeding through the profile puts the knob and the
|
|
52
|
+
* mechanism in agreement from the start.
|
|
53
|
+
*/
|
|
54
|
+
function withEffect(base: HfAudioFxChain, type: string): HfAudioFxChain {
|
|
55
|
+
return {
|
|
56
|
+
...base,
|
|
57
|
+
nodes: [
|
|
58
|
+
...base.nodes,
|
|
59
|
+
{
|
|
60
|
+
type,
|
|
61
|
+
id: mintAudioFxNodeId(base),
|
|
62
|
+
enabled: true,
|
|
63
|
+
params: getAudioFxProfile(type)
|
|
64
|
+
? applyAudioFxProfile(type, 0.5, defaultAudioFxParams(type))
|
|
65
|
+
: defaultAudioFxParams(type),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The same, for a job — an ordinary node that arrives already named and aimed. */
|
|
72
|
+
function withJob(base: HfAudioFxChain, job: HfAudioFxJob): HfAudioFxChain {
|
|
73
|
+
return { ...base, nodes: [...base.nodes, audioFxJobNode(job, base)] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The preset-run card, the add shelf and the audition machinery are already
|
|
77
|
+
// their own files; what is left is the section deciding which of them to show.
|
|
78
|
+
// fallow-ignore-next-line complexity
|
|
79
|
+
export function FxSection({
|
|
80
|
+
chain,
|
|
81
|
+
automatedTargets,
|
|
82
|
+
liveAutomationValues,
|
|
83
|
+
onAutomateParam,
|
|
84
|
+
onRemoveParamAutomation,
|
|
85
|
+
onRemoveNodeAutomation,
|
|
86
|
+
onRemoveNodesAutomation,
|
|
87
|
+
onChainChange,
|
|
88
|
+
onChainPreview,
|
|
89
|
+
carve,
|
|
90
|
+
carvedAgainstBy,
|
|
91
|
+
onCarveChange,
|
|
92
|
+
onCarvePreview,
|
|
93
|
+
sourceOptions,
|
|
94
|
+
trackKind,
|
|
95
|
+
analysing,
|
|
96
|
+
disabled,
|
|
97
|
+
onLevel,
|
|
98
|
+
onRemoveLevel,
|
|
99
|
+
levelled,
|
|
100
|
+
onAuditionLevel,
|
|
101
|
+
auditioningLevel,
|
|
102
|
+
onAutomatePreset,
|
|
103
|
+
onRemovePresetAutomation,
|
|
104
|
+
automatedPresets,
|
|
105
|
+
onAuditionTransport,
|
|
106
|
+
}: FxSectionProps) {
|
|
107
|
+
const presetAutomated = automatedPresets ?? new Set<string>();
|
|
108
|
+
// Falls back to the persisting write when no preview handler is supplied, which
|
|
109
|
+
// keeps the control working rather than going dead.
|
|
110
|
+
const previewCarve = onCarvePreview ?? onCarveChange;
|
|
111
|
+
|
|
112
|
+
// Nothing to carve against means nothing to show — see the block below.
|
|
113
|
+
// Not offered on the voice another track is already carving against — that
|
|
114
|
+
// track is the far end of someone else's relationship, and a carve of its own
|
|
115
|
+
// could only name a source it must not.
|
|
116
|
+
const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
|
|
117
|
+
|
|
118
|
+
const [adding, setAdding] = useState(false);
|
|
119
|
+
const [picking, setPicking] = useState(false);
|
|
120
|
+
const [openNode, setOpenNode] = useState<number | null>(0);
|
|
121
|
+
|
|
122
|
+
const mutate = useCallback(
|
|
123
|
+
(nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }),
|
|
124
|
+
[chain, onChainChange],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Dragging a knob previews without persisting; releasing it commits once.
|
|
128
|
+
const previewNode = useCallback(
|
|
129
|
+
(index: number, params: HfAudioFxParamValues) =>
|
|
130
|
+
onChainPreview?.({
|
|
131
|
+
...chain,
|
|
132
|
+
nodes: chain.nodes.map((n, i) => (i === index ? { ...n, params } : n)),
|
|
133
|
+
}),
|
|
134
|
+
[chain, onChainPreview],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
|
|
138
|
+
|
|
139
|
+
const applyPreset = useCallback(
|
|
140
|
+
(id: string) => {
|
|
141
|
+
const preset = getAudioFxPreset(id);
|
|
142
|
+
if (!preset) return;
|
|
143
|
+
// Appends. Stacking a character preset onto an already-cleaned voice is a
|
|
144
|
+
// real thing to want, and replacing silently would throw work away — so
|
|
145
|
+
// the destructive option is a separate gesture, not the default one.
|
|
146
|
+
const next = applyAudioFxPreset(chain, preset);
|
|
147
|
+
// Re-applying replaces this preset's own nodes in place rather than
|
|
148
|
+
// appending a second copy, and the two are different decisions — worth
|
|
149
|
+
// telling apart in the numbers.
|
|
150
|
+
const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
|
|
151
|
+
trackPresetApplied(
|
|
152
|
+
preset.id,
|
|
153
|
+
preset.family,
|
|
154
|
+
preset.nodes.length,
|
|
155
|
+
reapply ? "reapply" : "append",
|
|
156
|
+
{ trackKind },
|
|
157
|
+
);
|
|
158
|
+
// The audition WAS this, so there is nothing to put back — and putting the
|
|
159
|
+
// old chain back over the write that just landed is a race the author
|
|
160
|
+
// hears as the preset arriving and then leaving again.
|
|
161
|
+
clearAudition();
|
|
162
|
+
mutate(next.nodes);
|
|
163
|
+
// Land on the first node the preset wrote, so the author can hear what
|
|
164
|
+
// arrived and immediately see what it is made of.
|
|
165
|
+
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id));
|
|
166
|
+
setPicking(false);
|
|
167
|
+
},
|
|
168
|
+
[chain, mutate, clearAudition, trackKind],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const addJob = useCallback(
|
|
172
|
+
(job: HfAudioFxJob) => {
|
|
173
|
+
clearAudition();
|
|
174
|
+
trackNodeAdded(job.type, "job", job.id, { trackKind });
|
|
175
|
+
mutate(withJob(chain, job).nodes);
|
|
176
|
+
setOpenNode(chain.nodes.length);
|
|
177
|
+
setAdding(false);
|
|
178
|
+
},
|
|
179
|
+
[chain, mutate, clearAudition, trackKind],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const addEffect = useCallback(
|
|
183
|
+
(type: string) => {
|
|
184
|
+
clearAudition();
|
|
185
|
+
trackNodeAdded(type, "effect", null, { trackKind });
|
|
186
|
+
mutate(withEffect(chain, type).nodes);
|
|
187
|
+
setOpenNode(chain.nodes.length);
|
|
188
|
+
setAdding(false);
|
|
189
|
+
},
|
|
190
|
+
[chain, mutate, clearAudition, trackKind],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const updateNode = useCallback(
|
|
194
|
+
(index: number, patch: Partial<HfAudioFxNode>) =>
|
|
195
|
+
mutate(chain.nodes.map((n, i) => (i === index ? { ...n, ...patch } : n))),
|
|
196
|
+
[chain.nodes, mutate],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* How much of a preset is applied, 0..1.
|
|
201
|
+
*
|
|
202
|
+
* The switch and the lane are the same value, not two ways of silencing a
|
|
203
|
+
* preset: `presetAmount` drives the wet/dry blend the graph wraps the run in,
|
|
204
|
+
* so Off is amount 0 and a lane ramping 0 → 1 is the same control moving
|
|
205
|
+
* continuously. Writing `enabled` instead would take the nodes out of the
|
|
206
|
+
* graph, which a lane cannot do part-way and cannot do without a rebuild.
|
|
207
|
+
*
|
|
208
|
+
* On every node of the run because that is where the chain can hold it — see
|
|
209
|
+
* `HfAudioFxNode.presetAmount`.
|
|
210
|
+
*/
|
|
211
|
+
const setRunAmount = useCallback(
|
|
212
|
+
(items: { node: HfAudioFxNode; i: number }[], amount: number, persist = true) => {
|
|
213
|
+
const slots = new Set(items.map((item) => item.i));
|
|
214
|
+
const next = {
|
|
215
|
+
...chain,
|
|
216
|
+
nodes: chain.nodes.map((n, i) => (slots.has(i) ? { ...n, presetAmount: amount } : n)),
|
|
217
|
+
};
|
|
218
|
+
if (persist) mutate(next.nodes);
|
|
219
|
+
else onChainPreview?.(next);
|
|
220
|
+
},
|
|
221
|
+
[chain, mutate, onChainPreview],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Take a preset back out whole, lanes and all.
|
|
226
|
+
*
|
|
227
|
+
* Same contract as removing one node — an orphaned lane keeps driving a
|
|
228
|
+
* parameter that is no longer in the graph, and with ids minted lowest-free
|
|
229
|
+
* the next effect added inherits it.
|
|
230
|
+
*/
|
|
231
|
+
const removeRun = useCallback(
|
|
232
|
+
(items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
|
|
233
|
+
// One call, not a loop: every write is computed from the same snapshot and
|
|
234
|
+
// replaces the whole attribute, so a loop kept only its last write and left
|
|
235
|
+
// the other nodes' lanes behind as orphans. The preset id goes with it —
|
|
236
|
+
// the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
|
|
237
|
+
// so nothing else would ever collect it, and re-applying the preset later
|
|
238
|
+
// resurrected the old ramp.
|
|
239
|
+
const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
|
|
240
|
+
if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
|
|
241
|
+
if (presetId) trackPresetRemoved(presetId, { trackKind });
|
|
242
|
+
const slots = new Set(items.map((item) => item.i));
|
|
243
|
+
mutate(chain.nodes.filter((_, i) => !slots.has(i)));
|
|
244
|
+
setOpenNode(null);
|
|
245
|
+
},
|
|
246
|
+
[chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const removeNode = useCallback(
|
|
250
|
+
(index: number) => {
|
|
251
|
+
// The node's lanes go with it. `resolveAutomation` only hides an orphan at
|
|
252
|
+
// read time; left in the attribute, and with ids minted lowest-free, the
|
|
253
|
+
// next effect added takes the same id and inherits the dead envelope —
|
|
254
|
+
// arriving with its control disabled and "Automated" without the author
|
|
255
|
+
// ever automating it, and baked into the render.
|
|
256
|
+
const removed = chain.nodes[index];
|
|
257
|
+
const removedId = removed?.id;
|
|
258
|
+
if (removedId) onRemoveNodeAutomation?.(removedId);
|
|
259
|
+
if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
|
|
260
|
+
mutate(chain.nodes.filter((_, i) => i !== index));
|
|
261
|
+
setOpenNode(null);
|
|
262
|
+
},
|
|
263
|
+
[chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Open by default: the module is the carve's whole control surface now, and a
|
|
267
|
+
// collapsed card would hide the knob the author came here for.
|
|
268
|
+
const [carveOpen, setCarveOpen] = useState(true);
|
|
269
|
+
const carveNodes = useMemo(() => chain.nodes.filter((n) => n.fromCarve), [chain.nodes]);
|
|
270
|
+
/** Everything the author added, with the chain index every edit addresses. */
|
|
271
|
+
const handBuilt = useMemo(
|
|
272
|
+
() =>
|
|
273
|
+
chain.nodes
|
|
274
|
+
.map((node, i) => ({ node, i }))
|
|
275
|
+
// Carve and EQ bands belong to their own modules; showing them here too
|
|
276
|
+
// would put the same filter on screen twice with two ways to edit it.
|
|
277
|
+
.filter(({ node }) => !node.fromCarve && !node.fromEq),
|
|
278
|
+
[chain.nodes],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The hand-built list cut into runs, so a preset reads as one thing.
|
|
283
|
+
*
|
|
284
|
+
* Applying a preset drops five rows into the rack with nothing saying they
|
|
285
|
+
* arrived together — which is the same failure the carve module was built to
|
|
286
|
+
* fix, one level down. Consecutive only: a preset whose nodes have been pulled
|
|
287
|
+
* apart by a reorder is no longer a unit, and drawing a bracket around the gap
|
|
288
|
+
* would claim an adjacency the signal path does not have.
|
|
289
|
+
*/
|
|
290
|
+
const runs = useMemo(() => {
|
|
291
|
+
const out: { preset?: string; items: { node: HfAudioFxNode; i: number }[] }[] = [];
|
|
292
|
+
for (const item of handBuilt) {
|
|
293
|
+
const preset = item.node.fromPreset;
|
|
294
|
+
const last = out.at(-1);
|
|
295
|
+
if (last && last.preset === preset) last.items.push(item);
|
|
296
|
+
else out.push({ ...(preset ? { preset } : {}), items: [item] });
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}, [handBuilt]);
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Preset runs the author has folded shut.
|
|
303
|
+
*
|
|
304
|
+
* A preset is one thing they added, and once it is set the seven modules
|
|
305
|
+
* inside are detail — a rack with two presets in it was thirteen cards deep
|
|
306
|
+
* before anything hand-built appeared. Collapsed by id rather than by index so
|
|
307
|
+
* it survives a reorder, and open by default: a preset that arrives already
|
|
308
|
+
* hidden is one nobody learns is a chain they can edit.
|
|
309
|
+
*/
|
|
310
|
+
const [collapsedRuns, setCollapsedRuns] = useState<ReadonlySet<string>>(new Set());
|
|
311
|
+
|
|
312
|
+
const eqIds = useMemo(() => audioEqIds(chain), [chain]);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The number each row wears, counted over what the rack actually shows.
|
|
316
|
+
*
|
|
317
|
+
* Not the chain index: the carve's filters and an EQ's bands are inside their
|
|
318
|
+
* own modules, so counting raw nodes would leave the visible rack jumping from
|
|
319
|
+
* 02 to 07 and the numbers would look like a bug rather than a position.
|
|
320
|
+
*/
|
|
321
|
+
const positions = useMemo(() => {
|
|
322
|
+
const map = new Map<number, number>();
|
|
323
|
+
let at = (showCarve ? 1 : 0) + eqIds.length;
|
|
324
|
+
for (const { i } of handBuilt) map.set(i, ++at);
|
|
325
|
+
return map;
|
|
326
|
+
}, [handBuilt, eqIds.length, showCarve]);
|
|
327
|
+
const [openEq, setOpenEq] = useState<string | null>(null);
|
|
328
|
+
|
|
329
|
+
const addEq = useCallback(() => {
|
|
330
|
+
clearAudition();
|
|
331
|
+
const { chain: next, eqId } = addAudioEq(chain);
|
|
332
|
+
trackNodeAdded("eq", "eq", null, { trackKind });
|
|
333
|
+
mutate(next.nodes);
|
|
334
|
+
setOpenEq(eqId);
|
|
335
|
+
setAdding(false);
|
|
336
|
+
}, [chain, mutate, clearAudition, trackKind]);
|
|
337
|
+
|
|
338
|
+
// Dragging a fader is heard immediately and written once on release, the same
|
|
339
|
+
// split every other control in the rack uses.
|
|
340
|
+
const previewEqBand = useCallback(
|
|
341
|
+
(eqId: string, band: string, gain: number) =>
|
|
342
|
+
onChainPreview?.(setAudioEqBandGain(chain, eqId, band, gain)),
|
|
343
|
+
[chain, onChainPreview],
|
|
344
|
+
);
|
|
345
|
+
const commitEqBand = useCallback(
|
|
346
|
+
(eqId: string, band: string, gain: number) =>
|
|
347
|
+
mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes),
|
|
348
|
+
[chain, mutate],
|
|
349
|
+
);
|
|
350
|
+
const removeEq = useCallback(
|
|
351
|
+
(eqId: string) => {
|
|
352
|
+
// Batched for the same reason as `removeRun` — this loop had the identical
|
|
353
|
+
// last-write-wins bug and was only unreachable because an EQ band row
|
|
354
|
+
// offers no automation toggle today.
|
|
355
|
+
const ids = chain.nodes
|
|
356
|
+
.filter((node) => node.fromEq === eqId)
|
|
357
|
+
.map((node) => node.id)
|
|
358
|
+
.filter((id): id is string => Boolean(id));
|
|
359
|
+
if (ids.length > 0) onRemoveNodesAutomation?.(ids);
|
|
360
|
+
mutate(removeAudioEq(chain, eqId).nodes);
|
|
361
|
+
},
|
|
362
|
+
[chain, mutate, onRemoveNodesAutomation],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const moveNode = useCallback(
|
|
366
|
+
(index: number, delta: number) => {
|
|
367
|
+
const target = index + delta;
|
|
368
|
+
if (target < 0 || target >= chain.nodes.length) return;
|
|
369
|
+
const next = [...chain.nodes];
|
|
370
|
+
const [moved] = next.splice(index, 1);
|
|
371
|
+
next.splice(target, 0, moved!);
|
|
372
|
+
if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
|
|
373
|
+
mutate(next);
|
|
374
|
+
setOpenNode(target);
|
|
375
|
+
},
|
|
376
|
+
[chain.nodes, mutate, trackKind],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Escape closes whichever menu is open.
|
|
381
|
+
*
|
|
382
|
+
* The first thing anyone reaches for, and on a surface that covers the rack it
|
|
383
|
+
* is the one that needs no discovering. Bound on the section rather than the
|
|
384
|
+
* window: a keystroke aimed at the timeline is not aimed at this.
|
|
385
|
+
*/
|
|
386
|
+
const closeMenus = useCallback(
|
|
387
|
+
(event: KeyboardEvent) => {
|
|
388
|
+
if (event.key !== "Escape" || (!adding && !picking)) return;
|
|
389
|
+
// Stops the panel's own Escape handling from also firing — closing a menu
|
|
390
|
+
// and deselecting the clip on one keystroke loses the author their place.
|
|
391
|
+
event.stopPropagation();
|
|
392
|
+
audition(null);
|
|
393
|
+
onAuditionLevel?.(false);
|
|
394
|
+
setAdding(false);
|
|
395
|
+
setPicking(false);
|
|
396
|
+
},
|
|
397
|
+
[adding, picking, audition, onAuditionLevel],
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
/** Seed a lane for a run's preset amount, or omit the control when one already exists. */
|
|
401
|
+
const presetAutomateHandler = (
|
|
402
|
+
presetId: string | undefined,
|
|
403
|
+
amount: number,
|
|
404
|
+
): (() => void) | undefined => {
|
|
405
|
+
if (!presetId || !onAutomatePreset || presetAutomated.has(presetId)) return undefined;
|
|
406
|
+
return () => {
|
|
407
|
+
trackPresetAutomated(presetId, true, { trackKind });
|
|
408
|
+
onAutomatePreset(presetId, amount);
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/** Delete a run's preset-amount lane, or omit the control when there is none. */
|
|
413
|
+
const presetRemoveAutomationHandler = (
|
|
414
|
+
presetId: string | undefined,
|
|
415
|
+
): (() => void) | undefined => {
|
|
416
|
+
if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
|
|
417
|
+
return () => onRemovePresetAutomation(presetId);
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div
|
|
422
|
+
className="hf-fx-section space-y-2"
|
|
423
|
+
// Focus lives on the buttons and menu items inside, so the keystroke
|
|
424
|
+
// bubbles to here without the section needing focus of its own.
|
|
425
|
+
onKeyDown={closeMenus}
|
|
426
|
+
>
|
|
427
|
+
<FxRackChain
|
|
428
|
+
chain={chain}
|
|
429
|
+
showCarve={showCarve}
|
|
430
|
+
carveNodes={carveNodes}
|
|
431
|
+
carve={carve}
|
|
432
|
+
sourceOptions={sourceOptions}
|
|
433
|
+
automatedTargets={automatedTargets}
|
|
434
|
+
liveAutomationValues={liveAutomationValues}
|
|
435
|
+
carveOpen={carveOpen}
|
|
436
|
+
disabled={disabled}
|
|
437
|
+
analysing={analysing}
|
|
438
|
+
onToggleCarveOpen={() => setCarveOpen((was) => !was)}
|
|
439
|
+
onCarveChange={onCarveChange}
|
|
440
|
+
onCarvePreview={previewCarve}
|
|
441
|
+
eqIds={eqIds}
|
|
442
|
+
openEq={openEq}
|
|
443
|
+
onToggleEq={(eqId) => setOpenEq((was) => (was === eqId ? null : eqId))}
|
|
444
|
+
onPreviewEqBand={previewEqBand}
|
|
445
|
+
onCommitEqBand={commitEqBand}
|
|
446
|
+
onRemoveEq={removeEq}
|
|
447
|
+
handBuiltCount={handBuilt.length}
|
|
448
|
+
runs={runs}
|
|
449
|
+
positions={positions}
|
|
450
|
+
openNode={openNode}
|
|
451
|
+
onToggleOpenNode={(i) => setOpenNode(openNode === i ? null : i)}
|
|
452
|
+
onUpdateNode={updateNode}
|
|
453
|
+
onMoveNode={moveNode}
|
|
454
|
+
onRemoveNode={removeNode}
|
|
455
|
+
onPreviewNode={previewNode}
|
|
456
|
+
trackKind={trackKind}
|
|
457
|
+
collapsedRuns={collapsedRuns}
|
|
458
|
+
onToggleCollapse={(runKey) =>
|
|
459
|
+
setCollapsedRuns((was) => {
|
|
460
|
+
const next = new Set(was);
|
|
461
|
+
if (was.has(runKey)) next.delete(runKey);
|
|
462
|
+
else next.add(runKey);
|
|
463
|
+
return next;
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
onSetRunAmount={setRunAmount}
|
|
467
|
+
onRemoveRun={removeRun}
|
|
468
|
+
onAutomateParam={onAutomateParam}
|
|
469
|
+
onRemoveParamAutomation={onRemoveParamAutomation}
|
|
470
|
+
presetAutomated={presetAutomated}
|
|
471
|
+
presetAutomateHandler={presetAutomateHandler}
|
|
472
|
+
presetRemoveAutomationHandler={presetRemoveAutomationHandler}
|
|
473
|
+
/>
|
|
474
|
+
|
|
475
|
+
{adding ? (
|
|
476
|
+
<FxAddMenu
|
|
477
|
+
disabled={disabled}
|
|
478
|
+
analysing={analysing}
|
|
479
|
+
levelled={levelled}
|
|
480
|
+
auditioningLevel={auditioningLevel}
|
|
481
|
+
onLevel={onLevel}
|
|
482
|
+
onRemoveLevel={onRemoveLevel}
|
|
483
|
+
onEq={addEq}
|
|
484
|
+
onJob={addJob}
|
|
485
|
+
onEffect={addEffect}
|
|
486
|
+
onClose={() => setAdding(false)}
|
|
487
|
+
audition={audition}
|
|
488
|
+
onAuditionLevel={onAuditionLevel}
|
|
489
|
+
withJob={withJob}
|
|
490
|
+
withEffect={withEffect}
|
|
491
|
+
/>
|
|
492
|
+
) : null}
|
|
493
|
+
|
|
494
|
+
{picking ? (
|
|
495
|
+
<FxPresetMenu
|
|
496
|
+
trackKind={trackKind}
|
|
497
|
+
onPick={applyPreset}
|
|
498
|
+
onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
|
|
499
|
+
onAudition={
|
|
500
|
+
onChainPreview
|
|
501
|
+
? (id) => {
|
|
502
|
+
const preset = id ? getAudioFxPreset(id) : null;
|
|
503
|
+
audition(preset ? (base) => applyAudioFxPreset(base, preset) : null);
|
|
504
|
+
}
|
|
505
|
+
: undefined
|
|
506
|
+
}
|
|
507
|
+
/>
|
|
508
|
+
) : null}
|
|
509
|
+
|
|
510
|
+
{/* The buttons stay while their menu is open, and close it — an author who
|
|
511
|
+
opened one and changed their mind had no way back: picking something
|
|
512
|
+
was the only thing that set these false, so the only exits were adding
|
|
513
|
+
an effect they did not want or deselecting the clip. */}
|
|
514
|
+
<div className="flex gap-1">
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
className="hf-fx-preset w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
|
|
518
|
+
aria-expanded={picking}
|
|
519
|
+
disabled={disabled}
|
|
520
|
+
onClick={() => {
|
|
521
|
+
// Leaving the shelf by closing it is still leaving it, and an
|
|
522
|
+
// audition left playing is audible over a chain the document does
|
|
523
|
+
// not have.
|
|
524
|
+
if (picking) audition(null);
|
|
525
|
+
setPicking(!picking);
|
|
526
|
+
setAdding(false);
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
{picking ? "Close" : "Presets"}
|
|
530
|
+
</button>
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
className="hf-fx-add w-full rounded-[4px] border border-dashed border-panel-border-input py-1 text-[11px] text-panel-text-2 hover:text-panel-text-0 disabled:opacity-40"
|
|
534
|
+
aria-expanded={adding}
|
|
535
|
+
disabled={disabled}
|
|
536
|
+
onClick={() => {
|
|
537
|
+
if (adding) {
|
|
538
|
+
audition(null);
|
|
539
|
+
onAuditionLevel?.(false);
|
|
540
|
+
}
|
|
541
|
+
setAdding(!adding);
|
|
542
|
+
setPicking(false);
|
|
543
|
+
}}
|
|
544
|
+
>
|
|
545
|
+
{adding ? "Close" : "Add effect"}
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FxSection`'s prop contract, split out of `propertyPanelFxSection.tsx` so the
|
|
3
|
+
* component file is mostly logic and JSX rather than documentation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
7
|
+
import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
|
|
8
|
+
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
9
|
+
|
|
10
|
+
export interface FxSectionProps {
|
|
11
|
+
chain: HfAudioFxChain;
|
|
12
|
+
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
|
|
13
|
+
automatedTargets?: ReadonlySet<string>;
|
|
14
|
+
/**
|
|
15
|
+
* What each automated target is worth at the playhead, by the same key.
|
|
16
|
+
*
|
|
17
|
+
* An automated parameter's stored number is only the seed the lane replaced, so
|
|
18
|
+
* a rack that shows it stands still while the carve is audibly working. Absent,
|
|
19
|
+
* or missing a key, means there is no playhead over this clip and the stored
|
|
20
|
+
* value is the honest one.
|
|
21
|
+
*/
|
|
22
|
+
liveAutomationValues?: ReadonlyMap<string, number>;
|
|
23
|
+
/** Add a lane for one effect parameter, seeded at its current value. */
|
|
24
|
+
onAutomateParam?(nodeId: string, paramKey: string): void;
|
|
25
|
+
/** Delete one effect parameter's lane. */
|
|
26
|
+
onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
|
|
27
|
+
/** Delete every lane belonging to a node that is being removed. */
|
|
28
|
+
onRemoveNodeAutomation?(nodeId: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Delete the lanes of SEVERAL nodes at once, plus the whole-preset lane when
|
|
31
|
+
* a preset id is given. One call, because each write is computed from the same
|
|
32
|
+
* snapshot and replaces the whole attribute — a loop keeps only its last write.
|
|
33
|
+
*/
|
|
34
|
+
onRemoveNodesAutomation?(nodeIds: readonly string[], presetId?: string): void;
|
|
35
|
+
/** Add a lane for a whole preset's amount, seeded where it sits now. */
|
|
36
|
+
onAutomatePreset?(presetId: string, amount: number): void;
|
|
37
|
+
/** Delete that lane. */
|
|
38
|
+
onRemovePresetAutomation?(presetId: string): void;
|
|
39
|
+
/** Presets whose amount a lane already drives. */
|
|
40
|
+
automatedPresets?: ReadonlySet<string>;
|
|
41
|
+
/** Measure this track and write the levelling lane. Absent when unavailable. */
|
|
42
|
+
onLevel?(): void;
|
|
43
|
+
/** Take the levelling stage and its lane back out. */
|
|
44
|
+
onRemoveLevel?(): void;
|
|
45
|
+
/** Whether a levelling stage is already on the track. */
|
|
46
|
+
levelled?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Hover-audition of the levelling script: measure this track and play the
|
|
49
|
+
* result without persisting it, and put it back on `false`.
|
|
50
|
+
*
|
|
51
|
+
* Separate from `onChainPreview` because it is the one audition that cannot be
|
|
52
|
+
* synthesised from the chain in hand — the numbers do not exist until the
|
|
53
|
+
* audio has been decoded and measured.
|
|
54
|
+
*/
|
|
55
|
+
onAuditionLevel?(on: boolean): void;
|
|
56
|
+
/** Whether that measurement is running, so the button can say so. */
|
|
57
|
+
auditioningLevel?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Start the transport for an audition, and stop it on the way out.
|
|
60
|
+
*
|
|
61
|
+
* An audition is written to the running graph, which is silent while the
|
|
62
|
+
* transport is paused — so without this, hovering a preset does nothing at all
|
|
63
|
+
* for a paused author.
|
|
64
|
+
*/
|
|
65
|
+
onAuditionTransport?(on: boolean): void;
|
|
66
|
+
/** Structural edits and gesture-end writes; this is the one that persists. */
|
|
67
|
+
onChainChange(chain: HfAudioFxChain): void;
|
|
68
|
+
/** Continuous updates while a control is being dragged. */
|
|
69
|
+
onChainPreview?(chain: HfAudioFxChain): void;
|
|
70
|
+
carve: HfCarveSettings | null;
|
|
71
|
+
/** Gesture-end write; this is the one that persists. */
|
|
72
|
+
onCarveChange(carve: HfCarveSettings | null): void;
|
|
73
|
+
/** Continuous updates while a carve slider is dragged. Without this every
|
|
74
|
+
* pointermove patched the source file and resynced the selection. */
|
|
75
|
+
onCarvePreview?(carve: HfCarveSettings): void;
|
|
76
|
+
/**
|
|
77
|
+
* Set when another track's carve listens to this one, naming it. The carve block
|
|
78
|
+
* is then not offered here at all: this track is the voice, not the bed.
|
|
79
|
+
*/
|
|
80
|
+
carvedAgainstBy?: string | null;
|
|
81
|
+
/** Other audio elements that could act as the carve source. */
|
|
82
|
+
sourceOptions: AudioTrackOption[];
|
|
83
|
+
/**
|
|
84
|
+
* What this track reads as, from its id and filename. Passed through to the
|
|
85
|
+
* preset shelf, which hides the Voice family on a track that is plainly music
|
|
86
|
+
* or an effect. Absent means unknown, and unknown keeps everything.
|
|
87
|
+
*/
|
|
88
|
+
trackKind?: HfAudioNameKind;
|
|
89
|
+
analysing?: boolean;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
}
|
|
@@ -58,6 +58,11 @@ export interface PropertyPanelProps {
|
|
|
58
58
|
value: string | null,
|
|
59
59
|
onSettled?: (ok: boolean) => void,
|
|
60
60
|
) => void | Promise<void>;
|
|
61
|
+
/** Persists without reloading the preview, but re-reads the selection after —
|
|
62
|
+
* for attributes the runtime applies to the live graph itself, where a reload
|
|
63
|
+
* would only interrupt playback, and where the panel still has to see the
|
|
64
|
+
* value it just wrote to compute the next edit from. */
|
|
65
|
+
onSetAttributeQuiet?: (attr: string, value: string | null) => void | Promise<void>;
|
|
61
66
|
onApplyColorGradingScope?: (
|
|
62
67
|
scope: "source-file" | "project",
|
|
63
68
|
value: string | null,
|