@hyperframes/studio 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
- 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 +6440 -5514
- 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/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/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/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 +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +184 -7
- package/src/player/components/TimelineFxButton.tsx +171 -23
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- 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/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/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-C6m2nHiX.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
|
@@ -164,43 +164,9 @@ export function FxCarveModule({
|
|
|
164
164
|
onCarveChange(carve: HfCarveSettings): void;
|
|
165
165
|
onCarvePreview(carve: HfCarveSettings): void;
|
|
166
166
|
}) {
|
|
167
|
-
const bands = nodes.filter((n) => n.type === "peaking").length;
|
|
168
|
-
const hasLevel = nodes.some((n) => n.type === "gain");
|
|
169
167
|
const on = carve.enabled;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
*
|
|
173
|
-
* A picker with one entry is a question with one answer: it asks the author to
|
|
174
|
-
* confirm something already decided. So the voice reads out instead.
|
|
175
|
-
*
|
|
176
|
-
* Not when the stored source is some OTHER track, though — a name that no longer
|
|
177
|
-
* classifies as a voice, or a track since renamed. Reading out the one remaining
|
|
178
|
-
* candidate there would quietly claim the carve listens to something it does not,
|
|
179
|
-
* so the picker comes back and shows the mismatch.
|
|
180
|
-
*/
|
|
181
|
-
const soleVoice =
|
|
182
|
-
sourceOptions.length === 1 &&
|
|
183
|
-
(carve.sources.length === 0 ||
|
|
184
|
-
(carve.sources.length === 1 && carve.sources[0] === sourceOptions[0]?.id))
|
|
185
|
-
? sourceOptions[0]
|
|
186
|
-
: null;
|
|
187
|
-
// What the module is worth right now, in the head, so a collapsed card still
|
|
188
|
-
// says whether it is doing anything: the analysis it produced, or why not.
|
|
189
|
-
const summary = !on
|
|
190
|
-
? "off"
|
|
191
|
-
: analysing
|
|
192
|
-
? "analysing…"
|
|
193
|
-
: bands > 0
|
|
194
|
-
? [
|
|
195
|
-
`${bands} band${bands === 1 ? "" : "s"}`,
|
|
196
|
-
...(hasLevel ? ["level"] : []),
|
|
197
|
-
// Worth saying when it is more than one: the cuts follow whoever is
|
|
198
|
-
// speaking, and that is not obvious from a band count.
|
|
199
|
-
...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
|
|
200
|
-
].join(" + ")
|
|
201
|
-
: carve.sources.length > 0
|
|
202
|
-
? "no analysis yet"
|
|
203
|
-
: "pick a voice";
|
|
168
|
+
const soleVoice = soleCarveVoice(sourceOptions, carve.sources);
|
|
169
|
+
const summary = carveSummary({ nodes, carve, analysing });
|
|
204
170
|
// The carve's own colour, used three ways: the module's left edge, the title,
|
|
205
171
|
// and the wash behind it. A preset gets a title treatment because it is a
|
|
206
172
|
// character; the carve gets one because it is the only module in the rack
|
|
@@ -230,6 +196,8 @@ export function FxCarveModule({
|
|
|
230
196
|
// sets `tracking-normal`, and two Tailwind tracking utilities on one
|
|
231
197
|
// element resolve by stylesheet order, not by the order written.
|
|
232
198
|
style={{ color: tint, letterSpacing: "0.16em" }}
|
|
199
|
+
// Truncates in a narrow panel like every other name in the rack.
|
|
200
|
+
title="Voiceover carve"
|
|
233
201
|
aria-expanded={open}
|
|
234
202
|
onClick={onToggleOpen}
|
|
235
203
|
>
|
|
@@ -255,50 +223,13 @@ export function FxCarveModule({
|
|
|
255
223
|
{open && on ? (
|
|
256
224
|
<div className="hf-fx-carve-body border-t border-panel-border-input">
|
|
257
225
|
<div className="hf-fx-carve-controls space-y-0.5 px-1.5 py-1.5">
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
data-carve-source={soleVoice.id}
|
|
266
|
-
>
|
|
267
|
-
{soleVoice.label}
|
|
268
|
-
</span>
|
|
269
|
-
) : (
|
|
270
|
-
/* Every voice, not one of them. A bed usually runs under a whole
|
|
271
|
-
sequence — a narrator, an answer, a second presenter — and they are
|
|
272
|
-
analysed together, so the cuts follow whoever is speaking. Which
|
|
273
|
-
makes this a set of things to include, not a choice between them. */
|
|
274
|
-
<div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
|
|
275
|
-
{sourceOptions.map((o) => (
|
|
276
|
-
<label
|
|
277
|
-
key={o.id}
|
|
278
|
-
className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
|
|
279
|
-
title={`Make room for ${o.label}`}
|
|
280
|
-
>
|
|
281
|
-
<input
|
|
282
|
-
type="checkbox"
|
|
283
|
-
className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
|
|
284
|
-
data-carve-source={o.id}
|
|
285
|
-
checked={carve.sources.includes(o.id)}
|
|
286
|
-
disabled={disabled}
|
|
287
|
-
onChange={(e) =>
|
|
288
|
-
onCarveChange({
|
|
289
|
-
...carve,
|
|
290
|
-
sources: e.target.checked
|
|
291
|
-
? [...carve.sources, o.id]
|
|
292
|
-
: carve.sources.filter((id) => id !== o.id),
|
|
293
|
-
})
|
|
294
|
-
}
|
|
295
|
-
/>
|
|
296
|
-
<span className="truncate">{o.label}</span>
|
|
297
|
-
</label>
|
|
298
|
-
))}
|
|
299
|
-
</div>
|
|
300
|
-
)}
|
|
301
|
-
</div>
|
|
226
|
+
<CarveSourceRow
|
|
227
|
+
carve={carve}
|
|
228
|
+
sourceOptions={sourceOptions}
|
|
229
|
+
soleVoice={soleVoice}
|
|
230
|
+
disabled={disabled}
|
|
231
|
+
onCarveChange={onCarveChange}
|
|
232
|
+
/>
|
|
302
233
|
{/* One knob for the whole effect. Depth, band count, width, the
|
|
303
234
|
intelligibility weighting and both level-match numbers move together
|
|
304
235
|
anyway — a gentle carve is shallow in few bands with little ducking, a
|
|
@@ -330,53 +261,200 @@ export function FxCarveModule({
|
|
|
330
261
|
change re-derives all of them — so leaving them up reads as the
|
|
331
262
|
settings that are in force when they are already history, and the one
|
|
332
263
|
honest thing to say is that the work is happening. */}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
>
|
|
341
|
-
<circle
|
|
342
|
-
className="opacity-25"
|
|
343
|
-
cx="12"
|
|
344
|
-
cy="12"
|
|
345
|
-
r="10"
|
|
346
|
-
stroke="currentColor"
|
|
347
|
-
strokeWidth="4"
|
|
348
|
-
/>
|
|
349
|
-
<path
|
|
350
|
-
className="opacity-75"
|
|
351
|
-
fill="currentColor"
|
|
352
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
353
|
-
/>
|
|
354
|
-
</svg>
|
|
355
|
-
Analysing…
|
|
356
|
-
</p>
|
|
357
|
-
) : nodes.length > 0 ? (
|
|
358
|
-
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
|
|
359
|
-
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
|
|
360
|
-
analysed
|
|
361
|
-
</div>
|
|
362
|
-
{nodes.map((node, i) => (
|
|
363
|
-
<FxCarveMember
|
|
364
|
-
key={node.id ?? `${node.type}-${i}`}
|
|
365
|
-
node={node}
|
|
366
|
-
automatedTargets={automatedTargets}
|
|
367
|
-
liveAutomationValues={liveAutomationValues}
|
|
368
|
-
/>
|
|
369
|
-
))}
|
|
370
|
-
</div>
|
|
371
|
-
) : (
|
|
372
|
-
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
|
|
373
|
-
{carve.sources.length > 0
|
|
374
|
-
? "Nothing analysed yet."
|
|
375
|
-
: "Pick the voices this bed should make room for."}
|
|
376
|
-
</p>
|
|
377
|
-
)}
|
|
264
|
+
<CarveAnalysis
|
|
265
|
+
nodes={nodes}
|
|
266
|
+
analysing={analysing}
|
|
267
|
+
hasSources={carve.sources.length > 0}
|
|
268
|
+
automatedTargets={automatedTargets}
|
|
269
|
+
liveAutomationValues={liveAutomationValues}
|
|
270
|
+
/>
|
|
378
271
|
</div>
|
|
379
272
|
) : null}
|
|
380
273
|
</div>
|
|
381
274
|
);
|
|
382
275
|
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The only track this bed could be listening to, when there is exactly one.
|
|
279
|
+
*
|
|
280
|
+
* A picker with one entry is a question with one answer: it asks the author to
|
|
281
|
+
* confirm something already decided. So the voice reads out instead.
|
|
282
|
+
*
|
|
283
|
+
* Not when the stored source is some OTHER track, though — a name that no longer
|
|
284
|
+
* classifies as a voice, or a track since renamed. Reading out the one remaining
|
|
285
|
+
* candidate there would quietly claim the carve listens to something it does not,
|
|
286
|
+
* so the picker comes back and shows the mismatch.
|
|
287
|
+
*/
|
|
288
|
+
function soleCarveVoice(
|
|
289
|
+
sourceOptions: AudioTrackOption[],
|
|
290
|
+
sources: readonly string[],
|
|
291
|
+
): AudioTrackOption | null {
|
|
292
|
+
if (sourceOptions.length !== 1) return null;
|
|
293
|
+
const only = sourceOptions[0];
|
|
294
|
+
if (!only) return null;
|
|
295
|
+
if (sources.length === 0) return only;
|
|
296
|
+
return sources.length === 1 && sources[0] === only.id ? only : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* What the module is worth right now, for its head, so a collapsed card still
|
|
301
|
+
* says whether it is doing anything: the analysis it produced, or why not.
|
|
302
|
+
*/
|
|
303
|
+
function carveSummary(input: {
|
|
304
|
+
nodes: HfAudioFxNode[];
|
|
305
|
+
carve: HfCarveSettings;
|
|
306
|
+
analysing?: boolean;
|
|
307
|
+
}): string {
|
|
308
|
+
const { nodes, carve, analysing } = input;
|
|
309
|
+
if (!carve.enabled) return "off";
|
|
310
|
+
if (analysing) return "analysing…";
|
|
311
|
+
const bands = nodes.filter((n) => n.type === "peaking").length;
|
|
312
|
+
if (bands === 0) return carve.sources.length > 0 ? "no analysis yet" : "pick a voice";
|
|
313
|
+
return [
|
|
314
|
+
`${bands} band${bands === 1 ? "" : "s"}`,
|
|
315
|
+
...(nodes.some((n) => n.type === "gain") ? ["level"] : []),
|
|
316
|
+
// Worth saying when it is more than one: the cuts follow whoever is
|
|
317
|
+
// speaking, and that is not obvious from a band count.
|
|
318
|
+
...(carve.sources.length > 1 ? [`${carve.sources.length} voices`] : []),
|
|
319
|
+
].join(" + ");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Which voices the bed makes room for: a readout when there is only one to
|
|
323
|
+
* choose, otherwise a set of checkboxes. */
|
|
324
|
+
function CarveSourceRow({
|
|
325
|
+
carve,
|
|
326
|
+
sourceOptions,
|
|
327
|
+
soleVoice,
|
|
328
|
+
disabled,
|
|
329
|
+
onCarveChange,
|
|
330
|
+
}: {
|
|
331
|
+
carve: HfCarveSettings;
|
|
332
|
+
sourceOptions: AudioTrackOption[];
|
|
333
|
+
soleVoice: AudioTrackOption | null;
|
|
334
|
+
disabled?: boolean;
|
|
335
|
+
onCarveChange(carve: HfCarveSettings): void;
|
|
336
|
+
}) {
|
|
337
|
+
return (
|
|
338
|
+
<div className="hf-fx-row flex min-h-6 items-center gap-2">
|
|
339
|
+
{/* Wraps like every other name in this column (see FxParamRow) — one
|
|
340
|
+
truncating row beside wrapping ones reads as a rendering bug. */}
|
|
341
|
+
<span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
|
|
342
|
+
Listen to
|
|
343
|
+
</span>
|
|
344
|
+
{soleVoice ? (
|
|
345
|
+
<span
|
|
346
|
+
className="hf-fx-carve-source min-w-0 flex-1 truncate font-mono text-[10px] text-panel-text-1"
|
|
347
|
+
data-carve-source={soleVoice.id}
|
|
348
|
+
>
|
|
349
|
+
{soleVoice.label}
|
|
350
|
+
</span>
|
|
351
|
+
) : (
|
|
352
|
+
/* Every voice, not one of them. A bed usually runs under a whole
|
|
353
|
+
sequence — a narrator, an answer, a second presenter — and they are
|
|
354
|
+
analysed together, so the cuts follow whoever is speaking. Which
|
|
355
|
+
makes this a set of things to include, not a choice between them. */
|
|
356
|
+
<div className="hf-fx-carve-sources flex min-w-0 flex-1 flex-wrap gap-x-2.5 gap-y-0.5">
|
|
357
|
+
{sourceOptions.map((o) => (
|
|
358
|
+
<label
|
|
359
|
+
key={o.id}
|
|
360
|
+
className="flex min-w-0 items-center gap-1 font-mono text-[9px] text-panel-text-1"
|
|
361
|
+
title={`Make room for ${o.label}`}
|
|
362
|
+
>
|
|
363
|
+
<input
|
|
364
|
+
type="checkbox"
|
|
365
|
+
className="hf-fx-carve-source h-2.5 w-2.5 accent-panel-accent"
|
|
366
|
+
data-carve-source={o.id}
|
|
367
|
+
checked={carve.sources.includes(o.id)}
|
|
368
|
+
disabled={disabled}
|
|
369
|
+
onChange={(e) =>
|
|
370
|
+
onCarveChange({
|
|
371
|
+
...carve,
|
|
372
|
+
sources: e.target.checked
|
|
373
|
+
? [...carve.sources, o.id]
|
|
374
|
+
: carve.sources.filter((id) => id !== o.id),
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
/>
|
|
378
|
+
<span className="truncate">{o.label}</span>
|
|
379
|
+
</label>
|
|
380
|
+
))}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* What the analysis made of all that. Divided rather than boxed: these are parts
|
|
389
|
+
* of one module, and a border around each would read as the separate effects
|
|
390
|
+
* this replaced.
|
|
391
|
+
*
|
|
392
|
+
* While the analysis runs the previous filters are gone rather than stale. Every
|
|
393
|
+
* number in that list is about to be replaced — a strength change re-derives all
|
|
394
|
+
* of them — so leaving them up reads as the settings that are in force when they
|
|
395
|
+
* are already history, and the one honest thing to say is that the work is
|
|
396
|
+
* happening.
|
|
397
|
+
*/
|
|
398
|
+
function CarveAnalysis({
|
|
399
|
+
nodes,
|
|
400
|
+
analysing,
|
|
401
|
+
hasSources,
|
|
402
|
+
automatedTargets,
|
|
403
|
+
liveAutomationValues,
|
|
404
|
+
}: {
|
|
405
|
+
nodes: HfAudioFxNode[];
|
|
406
|
+
analysing?: boolean;
|
|
407
|
+
hasSources: boolean;
|
|
408
|
+
automatedTargets?: ReadonlySet<string>;
|
|
409
|
+
liveAutomationValues?: ReadonlyMap<string, number>;
|
|
410
|
+
}) {
|
|
411
|
+
if (analysing) {
|
|
412
|
+
return (
|
|
413
|
+
<p className="hf-fx-carve-working flex items-center justify-center gap-1.5 border-t border-panel-border-input py-2 text-[10px] text-panel-text-2">
|
|
414
|
+
<svg
|
|
415
|
+
className="hf-fx-carve-spinner h-3 w-3 animate-spin motion-reduce:animate-none"
|
|
416
|
+
viewBox="0 0 24 24"
|
|
417
|
+
fill="none"
|
|
418
|
+
aria-hidden="true"
|
|
419
|
+
>
|
|
420
|
+
<circle
|
|
421
|
+
className="opacity-25"
|
|
422
|
+
cx="12"
|
|
423
|
+
cy="12"
|
|
424
|
+
r="10"
|
|
425
|
+
stroke="currentColor"
|
|
426
|
+
strokeWidth="4"
|
|
427
|
+
/>
|
|
428
|
+
<path
|
|
429
|
+
className="opacity-75"
|
|
430
|
+
fill="currentColor"
|
|
431
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
432
|
+
/>
|
|
433
|
+
</svg>
|
|
434
|
+
Analysing…
|
|
435
|
+
</p>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (nodes.length === 0) {
|
|
439
|
+
return (
|
|
440
|
+
<p className="hf-fx-carve-working border-t border-panel-border-input py-1.5 text-center text-[10px] text-panel-text-2">
|
|
441
|
+
{hasSources ? "Nothing analysed yet." : "Pick the voices this bed should make room for."}
|
|
442
|
+
</p>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return (
|
|
446
|
+
<div className="hf-fx-carve-members divide-y divide-panel-border-input/60 border-t border-panel-border-input">
|
|
447
|
+
<div className="hf-fx-carve-members-label px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
|
|
448
|
+
analysed
|
|
449
|
+
</div>
|
|
450
|
+
{nodes.map((node, i) => (
|
|
451
|
+
<FxCarveMember
|
|
452
|
+
key={node.id ?? `${node.type}-${i}`}
|
|
453
|
+
node={node}
|
|
454
|
+
automatedTargets={automatedTargets}
|
|
455
|
+
liveAutomationValues={liveAutomationValues}
|
|
456
|
+
/>
|
|
457
|
+
))}
|
|
458
|
+
</div>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
@@ -113,3 +113,53 @@ describe("FxParamRow commit", () => {
|
|
|
113
113
|
expect(onCommit).not.toHaveBeenCalled();
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("FxParamRow label tooltip", () => {
|
|
118
|
+
// The label column is a fixed 86px and truncates: "Gap between repeats" reads
|
|
119
|
+
// as "Gap between re…", "How many repeats" as "How many repe…". The row
|
|
120
|
+
// already carried a `title`, but that is the HINT — what the knob does — so a
|
|
121
|
+
// truncated name had no way to be read in full.
|
|
122
|
+
const LONG: HfAudioFxNumberParam = {
|
|
123
|
+
kind: "number",
|
|
124
|
+
key: "time",
|
|
125
|
+
label: "Gap between repeats",
|
|
126
|
+
min: 0,
|
|
127
|
+
max: 1000,
|
|
128
|
+
step: 1,
|
|
129
|
+
default: 250,
|
|
130
|
+
unit: "ms",
|
|
131
|
+
hint: "How long before the echo comes back.",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
it("wraps the full name rather than truncating it, and leaves the hint on the row", () => {
|
|
135
|
+
const host = document.createElement("div");
|
|
136
|
+
document.body.append(host);
|
|
137
|
+
act(() => {
|
|
138
|
+
createRoot(host).render(<FxParamRow param={LONG} value={250} onChange={vi.fn()} />);
|
|
139
|
+
});
|
|
140
|
+
const label = host.querySelector<HTMLElement>(".hf-fx-label");
|
|
141
|
+
// The whole name is present and allowed to wrap. No `title`: a tooltip
|
|
142
|
+
// answers one row on hover, wrapping answers the column at rest.
|
|
143
|
+
expect(label?.textContent).toBe("Gap between repeats");
|
|
144
|
+
expect(label?.className).toContain("break-words");
|
|
145
|
+
expect(label?.className).not.toContain("truncate");
|
|
146
|
+
expect(label?.getAttribute("title")).toBeNull();
|
|
147
|
+
// The hint stays where it was: the two are different questions, and the
|
|
148
|
+
// name is not a substitute for the explanation either.
|
|
149
|
+
expect(host.querySelector<HTMLElement>(".hf-fx-row")?.getAttribute("title")).toBe(
|
|
150
|
+
"How long before the echo comes back.",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("wraps it the same way with no hint to fall back on", () => {
|
|
155
|
+
const host = document.createElement("div");
|
|
156
|
+
document.body.append(host);
|
|
157
|
+
const { hint: _hint, ...noHint } = LONG;
|
|
158
|
+
act(() => {
|
|
159
|
+
createRoot(host).render(<FxParamRow param={noHint} value={250} onChange={vi.fn()} />);
|
|
160
|
+
});
|
|
161
|
+
const label = host.querySelector<HTMLElement>(".hf-fx-label");
|
|
162
|
+
expect(label?.textContent).toBe("Gap between repeats");
|
|
163
|
+
expect(label?.getAttribute("title")).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -188,7 +188,14 @@ export function FxParamRow({
|
|
|
188
188
|
if (param.kind === "enum") {
|
|
189
189
|
return (
|
|
190
190
|
<label className="hf-fx-row flex min-h-6 items-center gap-2" title={param.hint}>
|
|
191
|
-
|
|
191
|
+
{/* Wraps rather than truncating. These names are whole questions — "How
|
|
192
|
+
big the space is" — so 86px of truncation left "How big the sp…", and
|
|
193
|
+
three rows of that read as the same word four times. A title only
|
|
194
|
+
answers it on hover, one row at a time; wrapping answers it for the
|
|
195
|
+
whole column at rest. `break-words` so a long single token breaks
|
|
196
|
+
instead of widening the column. The row keeps `title={param.hint}`:
|
|
197
|
+
the name and the explanation are different questions. */}
|
|
198
|
+
<span className="hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight text-panel-text-2">
|
|
192
199
|
{param.label}
|
|
193
200
|
</span>
|
|
194
201
|
<select
|
|
@@ -227,8 +234,9 @@ export function FxParamRow({
|
|
|
227
234
|
title={param.hint}
|
|
228
235
|
data-automated={automated ? "" : undefined}
|
|
229
236
|
>
|
|
237
|
+
{/* See the enum row above for why the name wraps instead of truncating. */}
|
|
230
238
|
<span
|
|
231
|
-
className={`hf-fx-label w-[86px] flex-shrink-0
|
|
239
|
+
className={`hf-fx-label w-[86px] flex-shrink-0 break-words text-[10px] leading-tight ${
|
|
232
240
|
automated ? "text-panel-accent" : "text-panel-text-2"
|
|
233
241
|
}`}
|
|
234
242
|
>
|
|
@@ -125,6 +125,7 @@ function Fader({
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
export function FxEqModule({
|
|
128
|
+
eqId,
|
|
128
129
|
bands,
|
|
129
130
|
open,
|
|
130
131
|
disabled,
|
|
@@ -141,6 +142,8 @@ export function FxEqModule({
|
|
|
141
142
|
className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-l-2 border-panel-border-input"
|
|
142
143
|
data-fx-node="eq"
|
|
143
144
|
data-fx-family="smart"
|
|
145
|
+
// Scroll anchor for a revealed EQ-band automation lane.
|
|
146
|
+
data-fx-eq={eqId}
|
|
144
147
|
// Smart, with the carve and the leveller: three bands the author sets by
|
|
145
148
|
// ear on a control surface, not three filters they configured.
|
|
146
149
|
style={{ borderLeftColor: fxFamilyTint({ type: "eq", fromEq: "eq" }) }}
|
|
@@ -184,6 +184,9 @@ function FxNodeHeader({
|
|
|
184
184
|
<button
|
|
185
185
|
type="button"
|
|
186
186
|
className={`hf-fx-node-name flex-1 truncate text-left text-[11px] text-panel-text-1 hover:text-panel-text-0 ${family}`}
|
|
187
|
+
// Truncated in the same narrow column as the param labels below, so it
|
|
188
|
+
// needs the same fallback to the full text on hover.
|
|
189
|
+
title={label}
|
|
187
190
|
aria-expanded={open}
|
|
188
191
|
onClick={onToggleOpen}
|
|
189
192
|
>
|
|
@@ -277,6 +280,10 @@ export function FxNodeRow({
|
|
|
277
280
|
className={`hf-fx-node rounded-[4px] border border-l-2 border-panel-border-input${bypassed ? " opacity-50" : ""}`}
|
|
278
281
|
data-fx-node={node.type}
|
|
279
282
|
data-fx-family={fxFamilyOf(node)}
|
|
283
|
+
// The scroll anchor a revealed automation lane lands on. Keyed by node id
|
|
284
|
+
// rather than by parameter: every param of one effect lives in this row,
|
|
285
|
+
// so the row is the smallest thing worth scrolling to.
|
|
286
|
+
data-fx-node-id={node.id}
|
|
280
287
|
// The tint is on the edge rather than the text: the name already carries
|
|
281
288
|
// the family in its lettering, and colouring it too would fight the
|
|
282
289
|
// panel's own tokens for automated and bypassed.
|
|
@@ -304,7 +311,10 @@ export function FxNodeRow({
|
|
|
304
311
|
onRemove={() => onRemove(index)}
|
|
305
312
|
/>
|
|
306
313
|
{summary ? (
|
|
307
|
-
<p
|
|
314
|
+
<p
|
|
315
|
+
className="hf-fx-node-summary truncate px-1.5 pb-1 text-[10px] text-panel-text-2"
|
|
316
|
+
title={summary}
|
|
317
|
+
>
|
|
308
318
|
{summary}
|
|
309
319
|
</p>
|
|
310
320
|
) : null}
|
|
@@ -133,8 +133,18 @@ export function FxPresetMenu({
|
|
|
133
133
|
<span className="hf-fx-preset-problem block truncate text-[10px]">
|
|
134
134
|
{PRESET_PROBLEM[preset.id] ?? preset.description}
|
|
135
135
|
</span>
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
{/* "Clean Voice · 5 effects" — the count is on the row in the
|
|
137
|
+
designs, not hidden in a tooltip. It is doing real work there:
|
|
138
|
+
it tells the author a preset IS a chain they can open and edit,
|
|
139
|
+
rather than an opaque setting they cannot follow. The count is
|
|
140
|
+
its own span so `.hf-fx-preset-name` stays the NAME — several
|
|
141
|
+
tests read it as the preset's identity. */}
|
|
142
|
+
<span className="block truncate font-mono text-[9px] text-panel-text-2">
|
|
143
|
+
<span className="hf-fx-preset-name">{preset.label}</span>
|
|
144
|
+
<span className="hf-fx-preset-count">
|
|
145
|
+
{" · "}
|
|
146
|
+
{preset.nodes.length} effect{preset.nodes.length === 1 ? "" : "s"}
|
|
147
|
+
</span>
|
|
138
148
|
</span>
|
|
139
149
|
{/* Hovering a preset plays it, and playing is otherwise invisible:
|
|
140
150
|
the panel looks identical whether the audition is sounding or
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rack's own signal path: the carve, the Tone EQ modules, and every
|
|
3
|
-
* hand-built effect or preset run in between — bracketed by the
|
|
4
|
-
*
|
|
3
|
+
* hand-built effect or preset run in between — bracketed by the `In` / `Out`
|
|
4
|
+
* labels that say the order is the point. What those two name depends on what
|
|
5
|
+
* is selected; see `audioFxSignalPath`.
|
|
5
6
|
*
|
|
6
7
|
* Split out of `propertyPanelFxSection.tsx`, which owned this whole chain
|
|
7
8
|
* before the file grew past a size where the chain and the add/pick menus
|
|
@@ -15,8 +16,11 @@ import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js";
|
|
|
15
16
|
import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
16
17
|
import { FxEqModule } from "./propertyPanelFxEqModule.js";
|
|
17
18
|
import { FxPresetRun } from "./propertyPanelFxPresetRun.js";
|
|
19
|
+
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
|
|
18
20
|
|
|
19
21
|
export interface FxRackChainProps {
|
|
22
|
+
/** What the `In`/`Out` lines name — a clip's answer differs from a bus's. */
|
|
23
|
+
signalPath: AudioFxSignalPath;
|
|
20
24
|
chain: HfAudioFxChain;
|
|
21
25
|
showCarve: boolean;
|
|
22
26
|
carveNodes: HfAudioFxNode[];
|
|
@@ -103,6 +107,7 @@ export function FxRackChain({
|
|
|
103
107
|
presetAutomated,
|
|
104
108
|
presetAutomateHandler,
|
|
105
109
|
presetRemoveAutomationHandler,
|
|
110
|
+
signalPath,
|
|
106
111
|
}: FxRackChainProps) {
|
|
107
112
|
return (
|
|
108
113
|
<div className="hf-fx-chain space-y-1">
|
|
@@ -111,7 +116,7 @@ export function FxRackChain({
|
|
|
111
116
|
"move up" look cosmetic — it is the most consequential control here. */}
|
|
112
117
|
<p className="hf-fx-term flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
|
|
113
118
|
<span className="hf-fx-term-cap text-panel-text-1">In</span>
|
|
114
|
-
<span>
|
|
119
|
+
<span>{signalPath.inLabel}</span>
|
|
115
120
|
</p>
|
|
116
121
|
{/* Carve leads the rack, which is also where its effects sit in the signal
|
|
117
122
|
path — corrective work before anything the author added. Present
|
|
@@ -151,7 +156,9 @@ export function FxRackChain({
|
|
|
151
156
|
))}
|
|
152
157
|
{handBuiltCount === 0 && eqIds.length === 0 ? (
|
|
153
158
|
<p className="hf-fx-empty py-1 text-[11px] text-panel-text-2">
|
|
154
|
-
{showCarve
|
|
159
|
+
{showCarve
|
|
160
|
+
? `No other effects on this ${signalPath.subject}.`
|
|
161
|
+
: `No effects on this ${signalPath.subject}.`}
|
|
155
162
|
</p>
|
|
156
163
|
) : (
|
|
157
164
|
runs.map((run) => {
|
|
@@ -195,7 +202,7 @@ export function FxRackChain({
|
|
|
195
202
|
)}
|
|
196
203
|
<p className="hf-fx-term hf-fx-term-out flex items-baseline gap-1.5 px-1.5 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
|
|
197
204
|
<span className="hf-fx-term-cap text-panel-text-1">Out</span>
|
|
198
|
-
<span>
|
|
205
|
+
<span>{signalPath.outLabel}</span>
|
|
199
206
|
</p>
|
|
200
207
|
</div>
|
|
201
208
|
);
|
|
@@ -162,6 +162,48 @@ afterEach(() => {
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
describe("FxSection chain", () => {
|
|
165
|
+
it("scrolls again when the same open parameter is revealed twice", () => {
|
|
166
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView");
|
|
167
|
+
const scrollIntoView = vi.fn();
|
|
168
|
+
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
|
169
|
+
configurable: true,
|
|
170
|
+
value: scrollIntoView,
|
|
171
|
+
});
|
|
172
|
+
const chain: HfAudioFxChain = {
|
|
173
|
+
version: 1,
|
|
174
|
+
nodes: [
|
|
175
|
+
{
|
|
176
|
+
type: "lowpass",
|
|
177
|
+
id: "filter-1",
|
|
178
|
+
enabled: true,
|
|
179
|
+
params: defaultAudioFxParams("lowpass"),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
const renderFxSection = (revealNonce: number) => (
|
|
184
|
+
<FxSection
|
|
185
|
+
chain={chain}
|
|
186
|
+
onChainChange={vi.fn()}
|
|
187
|
+
onCarveChange={vi.fn()}
|
|
188
|
+
carve={null}
|
|
189
|
+
sourceOptions={[]}
|
|
190
|
+
revealTarget="fx.filter-1.frequency"
|
|
191
|
+
revealNonce={revealNonce}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const { root } = renderInto(renderFxSection(1));
|
|
197
|
+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
|
198
|
+
|
|
199
|
+
act(() => root.render(renderFxSection(2)));
|
|
200
|
+
expect(scrollIntoView).toHaveBeenCalledTimes(2);
|
|
201
|
+
} finally {
|
|
202
|
+
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
|
|
203
|
+
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
165
207
|
it("says so when the track has no effects", () => {
|
|
166
208
|
const { host } = mount();
|
|
167
209
|
// "other", because the carve module is in the rack whenever a voice exists.
|