@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { useShallow } from "zustand/react/shallow";
|
|
4
4
|
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
|
|
5
5
|
import { slugifyDesignInput } from "../../utils/designInputTracking";
|
|
@@ -7,15 +7,18 @@ import { isTextEditableSelection } from "./domEditing";
|
|
|
7
7
|
import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
|
|
8
8
|
import { formatPxMetricValue } from "./propertyPanelHelpers";
|
|
9
9
|
import { audioFxSummary } from "./audioFxSummary";
|
|
10
|
+
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
10
11
|
import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
|
|
11
12
|
import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
|
|
13
|
+
import { closedGroupHeader, isSelectionHidden } from "./propertyPanelFlatClosedGroup";
|
|
12
14
|
import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
|
|
13
15
|
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
14
16
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
|
15
17
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
|
16
|
-
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
|
18
|
+
import { FlatMotionSection, motionSectionLabel } from "./propertyPanelFlatMotionSection";
|
|
17
19
|
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
|
18
20
|
import { useVolumeAutomation } from "./useVolumeAutomation";
|
|
21
|
+
import { useAudioFxRevealSection } from "./useAudioFxRevealSection";
|
|
19
22
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
|
20
23
|
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
|
|
21
24
|
import { createGsapLivePreview } from "./gsapLivePreview";
|
|
@@ -40,6 +43,7 @@ import {
|
|
|
40
43
|
EMPTY_GSAP_EFFECT_HANDLERS,
|
|
41
44
|
type FlatGroupDescriptor,
|
|
42
45
|
} from "./propertyPanelFlatDescriptors";
|
|
46
|
+
import { isAudioDomElement } from "../../utils/timelineInspector";
|
|
43
47
|
|
|
44
48
|
/** The flat inspector shell with one shared open-group state. */
|
|
45
49
|
// fallow-ignore-next-line complexity
|
|
@@ -135,7 +139,13 @@ export function PropertyPanelFlat({
|
|
|
135
139
|
? "style"
|
|
136
140
|
: sections.media
|
|
137
141
|
? "media"
|
|
138
|
-
:
|
|
142
|
+
: // An `<hf-audio-group>` has no style, no layout and no media — its
|
|
143
|
+
// chain is the only reason to select one. Without this the fallback
|
|
144
|
+
// landed on "layout", a section a bus does not render, so opening the
|
|
145
|
+
// rack on a group produced a panel with everything collapsed.
|
|
146
|
+
sections.audioFx
|
|
147
|
+
? "audio-fx"
|
|
148
|
+
: "layout",
|
|
139
149
|
);
|
|
140
150
|
|
|
141
151
|
// Tracks which group(s) are actively transitioning this toggle cycle, so
|
|
@@ -161,6 +171,7 @@ export function PropertyPanelFlat({
|
|
|
161
171
|
timelineSessionEpoch: state.timelineSessionEpoch,
|
|
162
172
|
})),
|
|
163
173
|
);
|
|
174
|
+
const storeElements = usePlayerStore((state) => state.elements);
|
|
164
175
|
// Identity of the element THIS panel actually renders (not the store's
|
|
165
176
|
// selectedElementId, which flips synchronously on selection while the panel
|
|
166
177
|
// still renders the previous element during async DOM-selection resolution):
|
|
@@ -187,6 +198,25 @@ export function PropertyPanelFlat({
|
|
|
187
198
|
if (focusesThisPanel) setOpenGroupId("motion");
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
/**
|
|
202
|
+
* A lane's reveal request opens the Audio FX section, the same way a focused
|
|
203
|
+
* ease segment opens Motion.
|
|
204
|
+
*
|
|
205
|
+
* Without this the request reached a collapsed section: the rack — and the
|
|
206
|
+
* module the request names — is not mounted while it is closed, so the click
|
|
207
|
+
* selected the clip and then appeared to do nothing.
|
|
208
|
+
*/
|
|
209
|
+
const hiddenNow = isSelectionHidden(selectedElementHidden, element);
|
|
210
|
+
|
|
211
|
+
const reveal = useAudioFxRevealSection({
|
|
212
|
+
elementId: element?.id,
|
|
213
|
+
hasAudioFxSection: Boolean(sections.audioFx),
|
|
214
|
+
});
|
|
215
|
+
if (reveal.revealNonce !== null) {
|
|
216
|
+
reveal.consume(reveal.revealNonce);
|
|
217
|
+
setOpenGroupId("audio-fx");
|
|
218
|
+
}
|
|
219
|
+
|
|
190
220
|
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
|
|
191
221
|
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
192
222
|
const panelBodyRef = useRef<HTMLDivElement>(null);
|
|
@@ -254,11 +284,30 @@ export function PropertyPanelFlat({
|
|
|
254
284
|
onSetAllKeyframeEases,
|
|
255
285
|
}
|
|
256
286
|
: null;
|
|
257
|
-
const
|
|
287
|
+
const audioSelection = isAudioDomElement(element.element);
|
|
288
|
+
// Handlers being wired is necessary but not sufficient: App.tsx always passes
|
|
289
|
+
// them, so this alone showed the tween editor for every selection — including
|
|
290
|
+
// an `<audio>` clip and an `<hf-audio-group>` bus, neither of which has a
|
|
291
|
+
// transform, an opacity or a box for a tween to move. Gated on the TAG, not on
|
|
292
|
+
// `sections.animation` (`animationCount > 0`): a div with no tweens yet must
|
|
293
|
+
// still offer "+ Add", so "has none" and "can have none" are different
|
|
294
|
+
// questions and only the second one belongs here.
|
|
295
|
+
const showMotionEffects = gsapEffectHandlers !== null && !audioSelection;
|
|
258
296
|
const showMotionGroup = showMotionTiming || showMotionEffects;
|
|
259
297
|
|
|
260
298
|
const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
|
|
261
299
|
|
|
300
|
+
// The group this clip belongs to, if any — the Audio FX summary reads
|
|
301
|
+
// "in Voiceover" for a member (see `audioFxSummary`). Membership lives on the
|
|
302
|
+
// members, so resolve the owning label from the live document.
|
|
303
|
+
const audioGroupLabel = useMemo((): string | undefined => {
|
|
304
|
+
const doc = element.element?.ownerDocument;
|
|
305
|
+
const id = element.id;
|
|
306
|
+
if (!doc || !id) return undefined;
|
|
307
|
+
return resolveAudioGroups(doc).find((group) => group.memberIds.includes(id))?.label;
|
|
308
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- store replacement signals live group membership changed
|
|
309
|
+
}, [element, storeElements]);
|
|
310
|
+
|
|
262
311
|
const groups: FlatGroupDescriptor[] = [];
|
|
263
312
|
if (isTextEditable) {
|
|
264
313
|
groups.push({
|
|
@@ -346,8 +395,12 @@ export function PropertyPanelFlat({
|
|
|
346
395
|
if (showMotionGroup) {
|
|
347
396
|
groups.push({
|
|
348
397
|
id: "motion",
|
|
349
|
-
|
|
350
|
-
|
|
398
|
+
...motionSectionLabel({
|
|
399
|
+
timingOnly: audioSelection,
|
|
400
|
+
start: elStart,
|
|
401
|
+
duration: elDuration,
|
|
402
|
+
effectCount: gsapAnimations.length,
|
|
403
|
+
}),
|
|
351
404
|
content: (
|
|
352
405
|
<FlatMotionSection
|
|
353
406
|
element={element}
|
|
@@ -431,7 +484,7 @@ export function PropertyPanelFlat({
|
|
|
431
484
|
groups.push({
|
|
432
485
|
id: "audio-fx",
|
|
433
486
|
title: "Audio FX",
|
|
434
|
-
summary: audioFxSummary(element),
|
|
487
|
+
summary: audioFxSummary(element, audioGroupLabel),
|
|
435
488
|
content: (
|
|
436
489
|
<AudioFxGroup
|
|
437
490
|
element={element}
|
|
@@ -466,17 +519,8 @@ export function PropertyPanelFlat({
|
|
|
466
519
|
const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex);
|
|
467
520
|
const openGroup = openIndex === -1 ? null : groups[openIndex];
|
|
468
521
|
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
|
|
469
|
-
const renderClosedGroup = (group: FlatGroupDescriptor) =>
|
|
470
|
-
|
|
471
|
-
<FlatGroupHeader
|
|
472
|
-
title={group.title}
|
|
473
|
-
isOpen={false}
|
|
474
|
-
onToggleOpen={() => toggleOpen(group.id)}
|
|
475
|
-
summary={group.summary}
|
|
476
|
-
animateEntrance={justToggledIds.includes(group.id)}
|
|
477
|
-
/>
|
|
478
|
-
</DesignPanelInputProvider>
|
|
479
|
-
);
|
|
522
|
+
const renderClosedGroup = (group: FlatGroupDescriptor) =>
|
|
523
|
+
closedGroupHeader(group, toggleOpen, justToggledIds);
|
|
480
524
|
|
|
481
525
|
return (
|
|
482
526
|
<DesignPanelInputProvider ui="flat">
|
|
@@ -486,10 +530,27 @@ export function PropertyPanelFlat({
|
|
|
486
530
|
name={element.label}
|
|
487
531
|
meta={`${sourceLabel} · ${element.tagName}`}
|
|
488
532
|
elementKind={elementKind}
|
|
489
|
-
hidden={
|
|
533
|
+
hidden={hiddenNow}
|
|
534
|
+
// Audio gets no hide control here. On an audio track "hidden" and
|
|
535
|
+
// "muted" are not similar operations, they are the SAME operation
|
|
536
|
+
// with two names (groups doc §2.1) — which is why the timeline's eye
|
|
537
|
+
// BECAME the mute rather than growing a sibling. A second copy in
|
|
538
|
+
// the panel, still called "Hide element", is exactly what that step
|
|
539
|
+
// set out to remove: "Two controls that silence a track, sitting
|
|
540
|
+
// next to each other, differing only in a distinction the author
|
|
541
|
+
// cannot see." An `<hf-audio-group>` has no visual to hide at all.
|
|
542
|
+
//
|
|
543
|
+
// EXCEPT while it is already hidden — the same door-from-the-inside
|
|
544
|
+
// the timeline's eye keeps for an audio track
|
|
545
|
+
// (`TimelineTrackPlainHeader`). Withholding it unconditionally
|
|
546
|
+
// withheld the only way back: a `data-hidden` group is silent in
|
|
547
|
+
// preview (the bus's mute gain) and absent from the render (every
|
|
548
|
+
// member dropped), and the group header carries no visibility
|
|
549
|
+
// control of its own now that mute and solo are gone. Only
|
|
550
|
+
// hand-editing the HTML brought the audio back.
|
|
490
551
|
onToggleHidden={
|
|
491
|
-
selectedElementId && onToggleElementHidden
|
|
492
|
-
? () => void onToggleElementHidden(selectedElementId, !
|
|
552
|
+
selectedElementId && onToggleElementHidden && (!audioSelection || hiddenNow)
|
|
553
|
+
? () => void onToggleElementHidden(selectedElementId, !hiddenNow)
|
|
493
554
|
: undefined
|
|
494
555
|
}
|
|
495
556
|
copied={clipboardCopied}
|
|
@@ -76,4 +76,25 @@ describe("PropertyPanelFlatHeader", () => {
|
|
|
76
76
|
const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() });
|
|
77
77
|
expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
// The panel's hide control is withheld for audio by its caller — on an audio
|
|
81
|
+
// track "hidden" and "muted" are the same operation with two names (groups
|
|
82
|
+
// doc §2.1), and the timeline already carries it, correctly labelled. This
|
|
83
|
+
// pins the header's half of that contract: no handler, no button.
|
|
84
|
+
it("renders no visibility control when its caller withholds the handler", () => {
|
|
85
|
+
const { host } = renderHeader({ onToggleHidden: undefined });
|
|
86
|
+
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
|
|
87
|
+
b.getAttribute("aria-label"),
|
|
88
|
+
);
|
|
89
|
+
expect(labels).not.toContain("Hide element");
|
|
90
|
+
expect(labels).not.toContain("Show element");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders it when the handler is supplied", () => {
|
|
94
|
+
const { host } = renderHeader({ onToggleHidden: vi.fn() });
|
|
95
|
+
const labels = Array.from(host.querySelectorAll("button")).map((b) =>
|
|
96
|
+
b.getAttribute("aria-label"),
|
|
97
|
+
);
|
|
98
|
+
expect(labels).toContain("Hide element");
|
|
99
|
+
});
|
|
79
100
|
});
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
|
|
12
12
|
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
13
13
|
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
|
14
|
+
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
14
15
|
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
|
15
16
|
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
|
|
16
17
|
import { useFxAudition } from "./useFxAudition.js";
|
|
18
|
+
import { trackPresetAuditioned } from "./audioFxTelemetry.js";
|
|
17
19
|
|
|
18
20
|
const POPOVER_WIDTH = 260;
|
|
19
21
|
const VIEWPORT_MARGIN = 8;
|
|
@@ -80,6 +82,13 @@ export interface TimelineFxPopoverProps {
|
|
|
80
82
|
onOpenRack: () => void;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** A preset applied for AUDITION only: no telemetry, since nothing was chosen.
|
|
86
|
+
* `applyPresetToChain` is the tracked path and belongs to `onPick`. */
|
|
87
|
+
function auditionPresetChain(base: HfAudioFxChain, presetId: string): HfAudioFxChain {
|
|
88
|
+
const preset = getAudioFxPreset(presetId);
|
|
89
|
+
return preset ? applyAudioFxPreset(base, preset) : base;
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
export function TimelineFxPopover({
|
|
84
93
|
anchorRect,
|
|
85
94
|
chain,
|
|
@@ -91,7 +100,11 @@ export function TimelineFxPopover({
|
|
|
91
100
|
onOpenRack,
|
|
92
101
|
}: TimelineFxPopoverProps) {
|
|
93
102
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
94
|
-
const { audition, clearAudition } = useFxAudition(
|
|
103
|
+
const { audition, clearAudition, storedChain } = useFxAudition(
|
|
104
|
+
chain,
|
|
105
|
+
onChainPreview,
|
|
106
|
+
onAuditionTransport,
|
|
107
|
+
);
|
|
95
108
|
|
|
96
109
|
// Outside click dismisses like any other popover; the button itself is
|
|
97
110
|
// excluded by pointerdown timing (the button's own click hasn't happened yet).
|
|
@@ -104,7 +117,8 @@ export function TimelineFxPopover({
|
|
|
104
117
|
}, [onClose]);
|
|
105
118
|
|
|
106
119
|
const applyPreset = (id: string) => {
|
|
107
|
-
|
|
120
|
+
// The stored chain, not the auditioned one — see `storedChain`.
|
|
121
|
+
const next = applyPresetToChain(storedChain(), id, trackKind);
|
|
108
122
|
if (!next) return;
|
|
109
123
|
clearAudition();
|
|
110
124
|
onChainChange(next);
|
|
@@ -137,12 +151,18 @@ export function TimelineFxPopover({
|
|
|
137
151
|
<FxPresetMenu
|
|
138
152
|
trackKind={trackKind}
|
|
139
153
|
onPick={applyPreset}
|
|
154
|
+
// The RAW apply, not `applyPresetToChain` — that helper fires
|
|
155
|
+
// `trackPresetApplied` on every call, so auditioning a 12-preset shelf
|
|
156
|
+
// by hover or arrow key emitted 12 `preset_applied` events and the
|
|
157
|
+
// numbers could not tell an audition from a decision. `FxSection`
|
|
158
|
+
// makes exactly this split, with `onAuditionTracked` carrying the
|
|
159
|
+
// honest event.
|
|
140
160
|
onAudition={
|
|
141
161
|
onChainPreview
|
|
142
|
-
? (id) =>
|
|
143
|
-
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
|
|
162
|
+
? (id) => audition(id ? (base) => auditionPresetChain(base, id) : null)
|
|
144
163
|
: undefined
|
|
145
164
|
}
|
|
165
|
+
onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
|
|
146
166
|
/>
|
|
147
167
|
</div>
|
|
148
168
|
<div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { audioFxRevealTarget } from "./audioFxRevealTarget";
|
|
3
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
4
|
+
|
|
5
|
+
const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
|
|
6
|
+
|
|
7
|
+
describe("audioFxRevealTarget", () => {
|
|
8
|
+
it("points a hand-built effect's lane at its own row, by chain index", () => {
|
|
9
|
+
const c = chain([
|
|
10
|
+
{ type: "highpass", id: "n1", params: {} },
|
|
11
|
+
{ type: "peaking", id: "n2", params: {} },
|
|
12
|
+
]);
|
|
13
|
+
expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// The case that made this a resolver rather than an index lookup: a carve's
|
|
17
|
+
// bands are filtered out of the rack's node list, so opening `openNode` on
|
|
18
|
+
// one opens nothing. The carve module is what renders them.
|
|
19
|
+
it("points a carve band's lane at the carve module", () => {
|
|
20
|
+
const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]);
|
|
21
|
+
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("points an EQ band's lane at its EQ module", () => {
|
|
25
|
+
const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]);
|
|
26
|
+
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("points a preset node's lane at its run, keyed like collapsedRuns", () => {
|
|
30
|
+
const c = chain([
|
|
31
|
+
{ type: "highpass", id: "n1", params: {} },
|
|
32
|
+
{ type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} },
|
|
33
|
+
{ type: "gain", id: "n3", fromPreset: "clean-voice", params: {} },
|
|
34
|
+
]);
|
|
35
|
+
// Keyed by the run's FIRST node, not the automated one.
|
|
36
|
+
expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({
|
|
37
|
+
kind: "preset",
|
|
38
|
+
runKey: "clean-voice-1",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("resolves a preset-level lane through the preset it names", () => {
|
|
43
|
+
const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]);
|
|
44
|
+
expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({
|
|
45
|
+
kind: "preset",
|
|
46
|
+
runKey: "clean-voice-0",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("treats the track's own volume as the rack itself", () => {
|
|
51
|
+
expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => {
|
|
55
|
+
expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull();
|
|
56
|
+
expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull();
|
|
57
|
+
expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where in the rack an automation lane's parameter actually lives.
|
|
3
|
+
*
|
|
4
|
+
* A lane names `fx.<nodeId>.<param>`, but the rack does not show one flat list
|
|
5
|
+
* of nodes: the carve is one module standing for the filters it compiled, EQ
|
|
6
|
+
* bands are folded into their own module, and preset runs are collapsible
|
|
7
|
+
* groups. A node id therefore resolves to one of several surfaces, and the
|
|
8
|
+
* caller has to open the RIGHT one — opening `openNode` on a carve band, whose
|
|
9
|
+
* row is filtered out of `handBuilt`, would open nothing at all and read as the
|
|
10
|
+
* click doing nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { parseAutomationTarget } from "@hyperframes/core/audio-automation";
|
|
14
|
+
import { escapeCssString } from "./domEditingDom";
|
|
15
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
16
|
+
|
|
17
|
+
export type AudioFxRevealTarget =
|
|
18
|
+
/** A hand-built effect, addressed by its index in the chain. */
|
|
19
|
+
| { kind: "node"; index: number; nodeId: string }
|
|
20
|
+
/** An EQ module, addressed by the id its bands share. */
|
|
21
|
+
| { kind: "eq"; eqId: string }
|
|
22
|
+
/** A preset run, addressed the way `collapsedRuns` keys it. */
|
|
23
|
+
| { kind: "preset"; runKey: string }
|
|
24
|
+
/** The carve module, which owns every `fromCarve` node collectively. */
|
|
25
|
+
| { kind: "carve" }
|
|
26
|
+
/** The track's own volume — no rack row; the reveal is the rack itself. */
|
|
27
|
+
| { kind: "volume" };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a lane target to the surface that shows it, or null when the chain
|
|
31
|
+
* does not contain it (a stale lane, or one whose effect was removed).
|
|
32
|
+
*/
|
|
33
|
+
export function audioFxRevealTarget(
|
|
34
|
+
target: string,
|
|
35
|
+
chain: HfAudioFxChain | null,
|
|
36
|
+
): AudioFxRevealTarget | null {
|
|
37
|
+
const parsed = parseAutomationTarget(target);
|
|
38
|
+
if (!parsed) return null;
|
|
39
|
+
if (parsed.kind === "volume") return { kind: "volume" };
|
|
40
|
+
if (parsed.kind === "preset") {
|
|
41
|
+
// A preset-level lane names the preset, not a node inside it: find its run
|
|
42
|
+
// by the first node that belongs to it, which is how `runKey` is built.
|
|
43
|
+
const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId);
|
|
44
|
+
return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null;
|
|
45
|
+
}
|
|
46
|
+
const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId);
|
|
47
|
+
const node = index >= 0 ? chain?.nodes[index] : undefined;
|
|
48
|
+
if (!node) return null;
|
|
49
|
+
// Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and
|
|
50
|
+
// the carve module is the one that actually renders it.
|
|
51
|
+
if (node.fromCarve) return { kind: "carve" };
|
|
52
|
+
if (node.fromEq) return { kind: "eq", eqId: node.fromEq };
|
|
53
|
+
if (node.fromPreset) {
|
|
54
|
+
const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset);
|
|
55
|
+
return { kind: "preset", runKey: `${node.fromPreset}-${first}` };
|
|
56
|
+
}
|
|
57
|
+
return { kind: "node", index, nodeId: parsed.nodeId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The DOM selector for the row a reveal target lives in, or null when the target
|
|
62
|
+
* names no surface this panel renders.
|
|
63
|
+
*
|
|
64
|
+
* A preset run is keyed by the preset id the run element actually exposes, not by
|
|
65
|
+
* `runKey`, which is the collapse map's key and carries an index suffix.
|
|
66
|
+
*/
|
|
67
|
+
function revealRowSelector(where: AudioFxRevealTarget | null): string | null {
|
|
68
|
+
if (!where) return null;
|
|
69
|
+
switch (where.kind) {
|
|
70
|
+
case "node":
|
|
71
|
+
return where.nodeId ? `[data-fx-node-id="${escapeCssString(where.nodeId)}"]` : null;
|
|
72
|
+
case "eq":
|
|
73
|
+
return `[data-fx-eq="${escapeCssString(where.eqId)}"]`;
|
|
74
|
+
case "carve":
|
|
75
|
+
return ".hf-fx-carve-module";
|
|
76
|
+
case "preset":
|
|
77
|
+
return `[data-fx-preset="${escapeCssString(where.runKey.replace(/-\d+$/, ""))}"]`;
|
|
78
|
+
default:
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scroll the row that owns `target` into view, and report whether it was found.
|
|
85
|
+
*
|
|
86
|
+
* The target is resolved against the chain again rather than remembered: which
|
|
87
|
+
* surface owns a parameter is a fact about the chain, and the chain may have
|
|
88
|
+
* been edited between the reveal request and the pass that can act on it. A
|
|
89
|
+
* false return means the row has not mounted yet, so the caller keeps the
|
|
90
|
+
* request pending.
|
|
91
|
+
*/
|
|
92
|
+
export function scrollRevealedRowIntoView(
|
|
93
|
+
root: HTMLElement | null,
|
|
94
|
+
target: string,
|
|
95
|
+
chain: HfAudioFxChain,
|
|
96
|
+
): boolean {
|
|
97
|
+
const selector = revealRowSelector(audioFxRevealTarget(target, chain));
|
|
98
|
+
// `querySelector` throws SyntaxError on a malformed selector, and this runs
|
|
99
|
+
// inside a render-phase effect — an unescapable id would take the whole
|
|
100
|
+
// property panel down instead of leaving the request pending, which is what
|
|
101
|
+
// returning false means. `parseAudioFxNode` accepts any non-empty string as
|
|
102
|
+
// an id and `parseAutomationTarget` only splits on `.`, so a hand- or
|
|
103
|
+
// LLM-authored chain can carry one.
|
|
104
|
+
let row: HTMLElement | null = null;
|
|
105
|
+
try {
|
|
106
|
+
row = selector ? (root?.querySelector<HTMLElement>(selector) ?? null) : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (!row) return false;
|
|
111
|
+
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
|
|
3
|
+
import { audioFxSignalPath } from "./audioFxSignalPath";
|
|
4
|
+
|
|
5
|
+
const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
|
|
6
|
+
id: "voiceover",
|
|
7
|
+
label: "Voiceover",
|
|
8
|
+
memberIds: ["vo-1", "vo-2"],
|
|
9
|
+
volume: 1,
|
|
10
|
+
hidden: false,
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("audioFxSignalPath", () => {
|
|
15
|
+
// The design doc's §5 mockup, both columns.
|
|
16
|
+
// Copy taken from the rendered designs, which are more specific than the
|
|
17
|
+
// ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
|
|
18
|
+
// Voiceover", not "vo-1, vo-2" / "to Voiceover".
|
|
19
|
+
it("names what a group sums, and sends it to the mix", () => {
|
|
20
|
+
expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({
|
|
21
|
+
inLabel: "vo-1 and vo-2, together",
|
|
22
|
+
outLabel: "to mix",
|
|
23
|
+
subject: "group",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("names the group a member feeds, so routing reads from either end", () => {
|
|
28
|
+
expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({
|
|
29
|
+
inLabel: "this track",
|
|
30
|
+
outLabel: "into Voiceover",
|
|
31
|
+
subject: "track",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("leaves an ungrouped clip on the shipped clip labels", () => {
|
|
36
|
+
expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({
|
|
37
|
+
inLabel: "this track",
|
|
38
|
+
outLabel: "to mix",
|
|
39
|
+
subject: "track",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The state an author is in the instant after making a group. It must not
|
|
44
|
+
// read as a failure to resolve.
|
|
45
|
+
it("says a memberless group holds nothing yet", () => {
|
|
46
|
+
expect(
|
|
47
|
+
audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]),
|
|
48
|
+
).toMatchObject({ inLabel: "nothing yet", subject: "group" });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the rack's `IN` and `OUT` lines say, per selected element.
|
|
3
|
+
*
|
|
4
|
+
* The rack brackets its chain with the signal path because the ORDER is the
|
|
5
|
+
* point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a
|
|
6
|
+
* clip's answer — "in this track", "out to mix" — which is wrong at both ends
|
|
7
|
+
* once groups exist, and the design doc's §5 mockup spells out both:
|
|
8
|
+
*
|
|
9
|
+
* GROUP: Voiceover CLIP: vo-1
|
|
10
|
+
* IN vo-1, vo-2 IN this track
|
|
11
|
+
* OUT to mix OUT to Voiceover
|
|
12
|
+
*
|
|
13
|
+
* A group's IN names what it sums, which is the only thing on screen that says
|
|
14
|
+
* a bus is a sum rather than a copy of the chain on each member. A member's OUT
|
|
15
|
+
* names the group it feeds, "so the routing is readable from either end".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { HfAudioGroup } from "@hyperframes/core/audio-groups";
|
|
19
|
+
|
|
20
|
+
export interface AudioFxSignalPath {
|
|
21
|
+
/** After the word "In". */
|
|
22
|
+
inLabel: string;
|
|
23
|
+
/** After the word "Out". */
|
|
24
|
+
outLabel: string;
|
|
25
|
+
/** The thing the empty-state sentence is about: "No effects on this …". */
|
|
26
|
+
subject: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a plain, ungrouped clip has always said, and the default everywhere. */
|
|
30
|
+
export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
|
|
31
|
+
inLabel: "this track",
|
|
32
|
+
outLabel: "to mix",
|
|
33
|
+
subject: "track",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `groups` is the resolved set from the composition; `elementId` and `tag` come
|
|
38
|
+
* from the selection. Pure so the labels can be asserted without a DOM.
|
|
39
|
+
*/
|
|
40
|
+
/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
|
|
41
|
+
function joinNatural(items: readonly string[]): string {
|
|
42
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
43
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function audioFxSignalPath(
|
|
47
|
+
tag: string | undefined,
|
|
48
|
+
elementId: string | undefined,
|
|
49
|
+
groups: readonly HfAudioGroup[],
|
|
50
|
+
): AudioFxSignalPath {
|
|
51
|
+
if (tag === "hf-audio-group") {
|
|
52
|
+
const group = groups.find((g) => g.id === elementId);
|
|
53
|
+
// A group with no members yet still reads as a group — "nothing yet" is the
|
|
54
|
+
// honest answer, and it is also the state the author is in right after
|
|
55
|
+
// making one, so it must not look like a bug.
|
|
56
|
+
const members = group?.memberIds ?? [];
|
|
57
|
+
return {
|
|
58
|
+
// "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
|
|
59
|
+
// comma list. The trailing "together" is the point: it says the group is
|
|
60
|
+
// ONE signal hearing both, which is the thing two separate copies of a
|
|
61
|
+
// chain cannot do, and it says it without "sum" or "bus".
|
|
62
|
+
inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "nothing yet",
|
|
63
|
+
outLabel: "to mix",
|
|
64
|
+
subject: "group",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined;
|
|
68
|
+
// "into Voiceover", not "to" — a member feeds the group, and the design uses
|
|
69
|
+
// the preposition that says so.
|
|
70
|
+
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
|
|
71
|
+
}
|
|
@@ -8,6 +8,14 @@ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
|
|
|
8
8
|
const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
|
|
9
9
|
|
|
10
10
|
describe("audioFxSummary", () => {
|
|
11
|
+
// The designs give a member's rack the summary "in Voiceover" — it answers
|
|
12
|
+
// "where does this go?" before anything is opened, the same job the rack's
|
|
13
|
+
// OUT does from the other end, and it outranks the effect count because a
|
|
14
|
+
// member with no effects of its own is still in the group.
|
|
15
|
+
it("names the group a clip belongs to, ahead of any effect count", () => {
|
|
16
|
+
expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover");
|
|
17
|
+
});
|
|
18
|
+
|
|
11
19
|
it("counts a carve as one module, not as the filters behind it", () => {
|
|
12
20
|
// Six bands and a level stage reading "7 effects" is the misreading the
|
|
13
21
|
// grouping exists to prevent.
|
|
@@ -10,27 +10,39 @@
|
|
|
10
10
|
import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
11
11
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
else handBuilt += 1;
|
|
24
|
-
}
|
|
25
|
-
} catch {
|
|
26
|
-
return "unreadable";
|
|
13
|
+
/** Enabled nodes split by who authored them, or null when the chain won't parse. */
|
|
14
|
+
function countEnabledNodes(raw: string | undefined): { handBuilt: number; carve: number } | null {
|
|
15
|
+
if (!raw) return { handBuilt: 0, carve: 0 };
|
|
16
|
+
try {
|
|
17
|
+
let handBuilt = 0;
|
|
18
|
+
let carve = 0;
|
|
19
|
+
for (const node of parseAudioFxChain(raw).nodes) {
|
|
20
|
+
if (node.enabled === false) continue;
|
|
21
|
+
if (node.fromCarve) carve += 1;
|
|
22
|
+
else handBuilt += 1;
|
|
27
23
|
}
|
|
24
|
+
return { handBuilt, carve };
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
28
27
|
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string {
|
|
31
|
+
// A clip inside a group reads "in Voiceover" — the designs use this line to
|
|
32
|
+
// answer "where does this go?" before the author opens anything, which is
|
|
33
|
+
// the same job the rack's OUT does from the other end. It outranks the effect
|
|
34
|
+
// count: a member with no effects of its own is still IN the group, and that
|
|
35
|
+
// is the more useful thing to say about it.
|
|
36
|
+
if (groupLabel) return `in ${groupLabel}`;
|
|
37
|
+
const counts = countEnabledNodes(element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]);
|
|
38
|
+
if (!counts) return "unreadable";
|
|
29
39
|
const parts: string[] = [];
|
|
30
|
-
if (handBuilt > 0)
|
|
40
|
+
if (counts.handBuilt > 0) {
|
|
41
|
+
parts.push(`${counts.handBuilt} effect${counts.handBuilt === 1 ? "" : "s"}`);
|
|
42
|
+
}
|
|
31
43
|
// One name for the module however many filters are behind it. Named when the
|
|
32
44
|
// carve is switched on at all, because the control is in this section whether or
|
|
33
45
|
// not it has compiled to anything yet.
|
|
34
|
-
if (
|
|
46
|
+
if (counts.carve > 0 || element.dataAttributes?.["fx-carve"]) parts.push("carve");
|
|
35
47
|
return parts.length > 0 ? parts.join(" + ") : "none";
|
|
36
48
|
}
|