@hyperframes/studio 0.8.11 → 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-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-1vMlv05L.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 +6431 -5515
- 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 +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +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-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -12,7 +12,7 @@ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core
|
|
|
12
12
|
import { TimelineAutomationLane } from "./TimelineAutomationLane";
|
|
13
13
|
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
14
14
|
import { getTimelineLaneTop } from "./timelineLayout";
|
|
15
|
-
import { groupAutomationLanes } from "./automationLaneData";
|
|
15
|
+
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
|
16
16
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
17
17
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
18
18
|
import type { TimelineElement } from "../store/playerStore";
|
|
@@ -100,7 +100,21 @@ function ClipAutomationLanes({
|
|
|
100
100
|
onCommit={bound.onCommit}
|
|
101
101
|
onSelect={bound.onSelect}
|
|
102
102
|
snapTimes={snapTimes}
|
|
103
|
-
|
|
103
|
+
// The carve owns its own envelopes and rewrites them on every
|
|
104
|
+
// re-run, so a drag would be silently discarded — shown, but not
|
|
105
|
+
// editable. Per LANE, not per binding: a carved bed can carry the
|
|
106
|
+
// author's own volume curve beside the carve's bands.
|
|
107
|
+
readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}
|
|
108
|
+
// Two distinct reasons, so two notes. The carve one names the way
|
|
109
|
+
// out — change strength, or switch the carve off and own the chain
|
|
110
|
+
// — because "not editable" without that reads as broken.
|
|
111
|
+
readOnlyNote={
|
|
112
|
+
isCarveLane(lane.target, bound.chain)
|
|
113
|
+
? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
|
|
114
|
+
: bound.readOnly
|
|
115
|
+
? "Read-only here."
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
104
118
|
rangeSelection={
|
|
105
119
|
bound.selection?.target === lane.target
|
|
106
120
|
? {
|
|
@@ -128,6 +142,10 @@ export interface TimelineAutomationLaneSlotProps {
|
|
|
128
142
|
pps: number;
|
|
129
143
|
/** Keyframe lanes already stacked above, which automation sits under. */
|
|
130
144
|
laneCount: number;
|
|
145
|
+
/** Exact y for the first lane, overriding `laneCount`. A group's lanes sit
|
|
146
|
+
* directly under its header row rather than under a stack of keyframe
|
|
147
|
+
* lanes, so it cannot be said in `laneCount`. */
|
|
148
|
+
topOffset?: number;
|
|
131
149
|
accentColor: string;
|
|
132
150
|
/** Composition-time playhead; the slot converts it to clip-local. */
|
|
133
151
|
currentTime: number;
|
|
@@ -151,6 +169,7 @@ export function TimelineAutomationLaneSlot({
|
|
|
151
169
|
lanes,
|
|
152
170
|
pps,
|
|
153
171
|
laneCount,
|
|
172
|
+
topOffset,
|
|
154
173
|
accentColor,
|
|
155
174
|
currentTime,
|
|
156
175
|
beatTimes,
|
|
@@ -165,7 +184,7 @@ export function TimelineAutomationLaneSlot({
|
|
|
165
184
|
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
|
|
166
185
|
}
|
|
167
186
|
});
|
|
168
|
-
const top = getTimelineLaneTop(laneCount);
|
|
187
|
+
const top = topOffset ?? getTimelineLaneTop(laneCount);
|
|
169
188
|
return (
|
|
170
189
|
<>
|
|
171
190
|
{clips.map((element) => (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { act } from "react";
|
|
2
|
+
import React, { act } from "react";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
@@ -68,45 +68,191 @@ describe("TimelineFxButton", () => {
|
|
|
68
68
|
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
// A muted target auditions anyway — the mute is lifted on the running graph
|
|
72
|
+
// for the hover and put back on the way out. The read has to happen on the
|
|
73
|
+
// way IN: the live unmute flows back into this component's props, so a
|
|
74
|
+
// restore that re-read `isMuted` would find it false and never re-mute.
|
|
75
|
+
it("borrows a muted target's mute for the audition and returns it", () => {
|
|
76
|
+
const onSetMutedLive = vi.fn();
|
|
77
|
+
const host = mount(
|
|
78
|
+
<TimelineFxButton
|
|
79
|
+
variant="chain"
|
|
80
|
+
fxChainRaw={undefined}
|
|
81
|
+
isMuted
|
|
82
|
+
onSetMutedLive={onSetMutedLive}
|
|
83
|
+
onChainChange={vi.fn()}
|
|
84
|
+
onChainPreview={vi.fn()}
|
|
85
|
+
onOpenRack={vi.fn()}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
89
|
+
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
|
90
|
+
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
91
|
+
expect(onSetMutedLive).toHaveBeenLastCalledWith(false);
|
|
92
|
+
const shelf = document.querySelector(".hf-fx-preset-menu");
|
|
93
|
+
act(() => shelf?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })));
|
|
94
|
+
expect(onSetMutedLive).toHaveBeenLastCalledWith(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("leaves an unmuted target's mute alone", () => {
|
|
98
|
+
const onSetMutedLive = vi.fn();
|
|
99
|
+
const host = mount(
|
|
100
|
+
<TimelineFxButton
|
|
101
|
+
variant="chain"
|
|
102
|
+
fxChainRaw={undefined}
|
|
103
|
+
onSetMutedLive={onSetMutedLive}
|
|
104
|
+
onChainChange={vi.fn()}
|
|
105
|
+
onChainPreview={vi.fn()}
|
|
106
|
+
onOpenRack={vi.fn()}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
110
|
+
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
|
111
|
+
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
112
|
+
expect(onSetMutedLive).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Hovering writes the preset through the preview channel, and the chain prop
|
|
116
|
+
// is read back from that same live attribute. Applying while a DIFFERENT
|
|
117
|
+
// preset is being auditioned used to save both — heard as the effect running
|
|
118
|
+
// twice — so the apply has to land on the stored chain.
|
|
119
|
+
it("applies onto the stored chain, not the one being auditioned", () => {
|
|
120
|
+
const onChainChange = vi.fn();
|
|
121
|
+
// The write-back the real timeline does: a preview patches the live
|
|
122
|
+
// attribute, and the row re-reads it into `fxChainRaw`. Without this the
|
|
123
|
+
// prop never moves and the bug cannot show.
|
|
124
|
+
function Harness() {
|
|
125
|
+
const [raw, setRaw] = React.useState<string | undefined>(undefined);
|
|
126
|
+
return (
|
|
127
|
+
<TimelineFxButton
|
|
128
|
+
variant="chain"
|
|
129
|
+
fxChainRaw={raw}
|
|
130
|
+
onChainChange={onChainChange}
|
|
131
|
+
onChainPreview={(next) => setRaw(serializeAudioFxChain(next))}
|
|
132
|
+
onOpenRack={vi.fn()}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const host = mount(<Harness />);
|
|
137
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
138
|
+
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".hf-fx-preset-item"));
|
|
139
|
+
const [hovered, clicked] = [items[0], items[1]];
|
|
140
|
+
act(() => hovered?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
141
|
+
act(() => clicked?.click());
|
|
142
|
+
const saved = onChainChange.mock.calls.at(-1)?.[0];
|
|
143
|
+
const presets = new Set(saved?.nodes.map((n: { fromPreset?: string }) => n.fromPreset));
|
|
144
|
+
expect(presets.size).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The design doc calls the sentence this dialog carries "the highest-leverage
|
|
148
|
+
// copy in this plan": it is the concept of a submix bus delivered without the
|
|
149
|
+
// word, to an author who has never met one. The old pointer auto-named the
|
|
150
|
+
// group on one click and never mentioned the shared volume.
|
|
151
|
+
it("group-pointer variant names the group and explains what one is", () => {
|
|
152
|
+
const onGroupClips = vi.fn();
|
|
153
|
+
const host = mount(
|
|
154
|
+
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
|
|
155
|
+
);
|
|
156
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
157
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
158
|
+
expect(dialog?.textContent).toContain(
|
|
159
|
+
"Effects you add to the group apply to both clips at once, and they share one volume.",
|
|
160
|
+
);
|
|
161
|
+
const group = Array.from(document.body.querySelectorAll("button")).find(
|
|
162
|
+
(b) => b.textContent === "Group",
|
|
163
|
+
);
|
|
164
|
+
act(() => group?.click());
|
|
165
|
+
expect(onGroupClips).toHaveBeenCalledWith("Voiceover");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Three or more must not read "both".
|
|
169
|
+
it("counts the clips in the explanation", () => {
|
|
170
|
+
mount(<TimelineFxButton variant="group-pointer" clipCount={3} onGroupClips={vi.fn()} />);
|
|
171
|
+
act(() => byTextButton(document.body as HTMLElement, "FX")?.click());
|
|
172
|
+
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("all 3 clips at once");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// §1.4 keeps groups audio-only in v1, and §5 is explicit that a deliberate
|
|
176
|
+
// limit must be stated: "silent ones just send authors hunting for something
|
|
177
|
+
// that was never built."
|
|
178
|
+
it("states the video limit instead of offering a name field", () => {
|
|
179
|
+
const onGroupClips = vi.fn();
|
|
180
|
+
const host = mount(
|
|
181
|
+
<TimelineFxButton
|
|
182
|
+
variant="group-pointer"
|
|
183
|
+
clipCount={2}
|
|
184
|
+
refusal="Video audio can't be grouped yet — only audio clips can join a group."
|
|
185
|
+
onGroupClips={onGroupClips}
|
|
186
|
+
/>,
|
|
187
|
+
);
|
|
188
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
189
|
+
const dialog = document.querySelector('[role="dialog"]');
|
|
190
|
+
expect(dialog?.textContent).toContain("Video audio can't be grouped yet");
|
|
191
|
+
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
|
|
192
|
+
expect(
|
|
193
|
+
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// #3421: the dialog positioned itself at `anchorRect.bottom + 4` with no flip
|
|
198
|
+
// and no clamp, and this button lives in a track header at the bottom of the
|
|
199
|
+
// studio window — so it opened past the viewport edge and was reported as
|
|
200
|
+
// "the grouping button did nothing". Numbers derive from GROUP_DIALOG_SIZE
|
|
201
|
+
// (256x160) against happy-dom's 1024x768 window and a 12px margin.
|
|
77
202
|
it("flips the group dialog above the anchor when there is no room below", () => {
|
|
78
|
-
const host = mount(
|
|
203
|
+
const host = mount(
|
|
204
|
+
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
|
|
205
|
+
);
|
|
79
206
|
const fx = byTextButton(host, "FX");
|
|
80
|
-
// A track header near the bottom edge of the (1024x768) window.
|
|
81
207
|
fx!.getBoundingClientRect = () =>
|
|
82
208
|
({ left: 300, top: 760, right: 320, bottom: 776, width: 20, height: 16 }) as DOMRect;
|
|
83
209
|
act(() => fx?.click());
|
|
84
210
|
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
85
211
|
expect(dialog).toBeTruthy();
|
|
86
|
-
|
|
87
|
-
|
|
212
|
+
const top = Number.parseFloat(dialog.style.top);
|
|
213
|
+
// Fully inside the viewport on both edges, which the unclamped form was not.
|
|
214
|
+
expect(top).toBeGreaterThanOrEqual(12);
|
|
215
|
+
expect(top + 160).toBeLessThanOrEqual(768 - 12);
|
|
88
216
|
});
|
|
89
217
|
|
|
90
218
|
it("keeps the group dialog inside the right edge of the window", () => {
|
|
91
|
-
const host = mount(
|
|
219
|
+
const host = mount(
|
|
220
|
+
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
|
|
221
|
+
);
|
|
92
222
|
const fx = byTextButton(host, "FX");
|
|
93
|
-
// Hard against the right edge: 224px wide + a 12px margin has to fit.
|
|
94
223
|
fx!.getBoundingClientRect = () =>
|
|
95
224
|
({ left: 1010, top: 100, right: 1024, bottom: 116, width: 14, height: 16 }) as DOMRect;
|
|
96
225
|
act(() => fx?.click());
|
|
97
226
|
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
98
|
-
|
|
99
|
-
expect(
|
|
227
|
+
const left = Number.parseFloat(dialog.style.left);
|
|
228
|
+
expect(left).toBeGreaterThanOrEqual(12);
|
|
229
|
+
expect(left + 256).toBeLessThanOrEqual(1024 - 12);
|
|
100
230
|
});
|
|
101
231
|
|
|
102
|
-
it("
|
|
232
|
+
it("carries the typed name into the group it creates", () => {
|
|
103
233
|
const onGroupClips = vi.fn();
|
|
104
|
-
const host = mount(
|
|
234
|
+
const host = mount(
|
|
235
|
+
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
|
|
236
|
+
);
|
|
105
237
|
act(() => byTextButton(host, "FX")?.click());
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
238
|
+
const input = document.querySelector<HTMLInputElement>('input[aria-label="Group name"]');
|
|
239
|
+
expect(input).not.toBeNull();
|
|
240
|
+
// React tracks the input's value on the node, so assigning `.value`
|
|
241
|
+
// directly is swallowed — the native setter is what makes onChange fire.
|
|
242
|
+
const setValue = Object.getOwnPropertyDescriptor(
|
|
243
|
+
window.HTMLInputElement.prototype,
|
|
244
|
+
"value",
|
|
245
|
+
)?.set;
|
|
246
|
+
act(() => {
|
|
247
|
+
if (input && setValue) {
|
|
248
|
+
setValue.call(input, "SFX");
|
|
249
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
const group = Array.from(document.body.querySelectorAll("button")).find(
|
|
253
|
+
(b) => b.textContent === "Group",
|
|
254
|
+
);
|
|
109
255
|
act(() => group?.click());
|
|
110
|
-
expect(onGroupClips).
|
|
256
|
+
expect(onGroupClips).toHaveBeenCalledWith("SFX");
|
|
111
257
|
});
|
|
112
258
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* it gets the grouping pointer instead of the popover).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { useRef, useState } from "react";
|
|
12
|
+
import { useEffect, useRef, useState } from "react";
|
|
13
13
|
import { createPortal } from "react-dom";
|
|
14
14
|
import {
|
|
15
15
|
enabledAudioFxNodes,
|
|
@@ -19,14 +19,31 @@ import {
|
|
|
19
19
|
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
|
20
20
|
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
|
|
21
21
|
import { resolveFloatingPanelPosition } from "../../components/editor/floatingPanel.js";
|
|
22
|
+
import {
|
|
23
|
+
useAuditionTransport,
|
|
24
|
+
type AuditionSpan,
|
|
25
|
+
} from "../../components/editor/useAuditionTransport.js";
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Naming a group is the moment the whole feature is explained.
|
|
29
|
+
*
|
|
30
|
+
* The design doc calls the sentence below "the highest-leverage copy in this
|
|
31
|
+
* plan and it should be written before the routing is" — it is the concept of a
|
|
32
|
+
* submix bus delivered without the word, to an author who has never met one.
|
|
33
|
+
* The old pointer skipped both the name and the sentence: it made an
|
|
34
|
+
* auto-named group on one click and said only "Group these clips to add effects
|
|
35
|
+
* to all of them", which leaves out the shared volume entirely.
|
|
36
|
+
*/
|
|
37
|
+
// Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-64` is exact, and only
|
|
24
38
|
// the flip decision uses the height — the clamp keeps the dialog on screen
|
|
25
39
|
// either way.
|
|
26
|
-
const GROUP_DIALOG_SIZE = { width:
|
|
40
|
+
const GROUP_DIALOG_SIZE = { width: 256, height: 160 };
|
|
27
41
|
|
|
28
42
|
/** Where the grouping dialog goes: flipped above the anchor when there is no
|
|
29
|
-
* room below, and clamped so neither edge leaves the viewport.
|
|
43
|
+
* room below, and clamped so neither edge leaves the viewport. This button
|
|
44
|
+
* lives in a track header at the BOTTOM of the studio window, so an unclamped
|
|
45
|
+
* `anchorRect.bottom + 4` opened it past the viewport edge and read as "the
|
|
46
|
+
* grouping button did nothing". */
|
|
30
47
|
function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number } {
|
|
31
48
|
const { left, top } = resolveFloatingPanelPosition(
|
|
32
49
|
anchorRect,
|
|
@@ -37,6 +54,94 @@ function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number }
|
|
|
37
54
|
return { left, top };
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
function GroupNameDialog({
|
|
58
|
+
anchorRect,
|
|
59
|
+
clipCount,
|
|
60
|
+
defaultLabel,
|
|
61
|
+
refusal,
|
|
62
|
+
onCancel,
|
|
63
|
+
onConfirm,
|
|
64
|
+
}: {
|
|
65
|
+
anchorRect: DOMRect;
|
|
66
|
+
clipCount: number;
|
|
67
|
+
defaultLabel?: string;
|
|
68
|
+
refusal?: string;
|
|
69
|
+
onCancel: () => void;
|
|
70
|
+
onConfirm: (label: string) => void;
|
|
71
|
+
}) {
|
|
72
|
+
const [label, setLabel] = useState(defaultLabel ?? "Voiceover");
|
|
73
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
74
|
+
// Focused on open so the name can be typed over without a second click —
|
|
75
|
+
// the field is the only thing here that wants input.
|
|
76
|
+
useEffect(() => inputRef.current?.select(), []);
|
|
77
|
+
if (refusal) {
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
role="dialog"
|
|
81
|
+
aria-label="This track cannot be grouped"
|
|
82
|
+
className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] leading-snug text-white/75 shadow-xl"
|
|
83
|
+
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
|
|
84
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
85
|
+
onKeyDown={(event) => {
|
|
86
|
+
if (event.key !== "Escape") return;
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
onCancel();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<p>{refusal}</p>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const confirm = () => onConfirm(label.trim() || defaultLabel || "Voiceover");
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-label="Name this group"
|
|
100
|
+
className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] text-white/75 shadow-xl"
|
|
101
|
+
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
|
|
102
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
103
|
+
onKeyDown={(event) => {
|
|
104
|
+
if (event.key === "Escape") {
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
onCancel();
|
|
107
|
+
}
|
|
108
|
+
if (event.key === "Enter") confirm();
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<p className="mb-1.5 font-medium text-white">Name this group</p>
|
|
112
|
+
<input
|
|
113
|
+
ref={inputRef}
|
|
114
|
+
type="text"
|
|
115
|
+
aria-label="Group name"
|
|
116
|
+
value={label}
|
|
117
|
+
onChange={(event) => setLabel(event.currentTarget.value)}
|
|
118
|
+
className="w-full rounded border border-white/20 bg-black/30 px-1.5 py-1 text-[11px] text-white outline-none focus:border-[#3CE6AC]"
|
|
119
|
+
/>
|
|
120
|
+
{/* The sentence. No jargon, and it names both things a bus does. */}
|
|
121
|
+
<p className="mt-2 leading-snug">
|
|
122
|
+
Effects you add to the group apply to {clipCount === 2 ? "both" : `all ${clipCount}`} clips
|
|
123
|
+
at once, and they share one volume.
|
|
124
|
+
</p>
|
|
125
|
+
<div className="mt-2.5 flex justify-end gap-1.5">
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
className="rounded border border-white/20 px-2 py-1 text-[10px] text-white/75 hover:bg-white/10"
|
|
129
|
+
onClick={onCancel}
|
|
130
|
+
>
|
|
131
|
+
Cancel
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="rounded border border-[#3CE6AC] bg-[#3CE6AC]/15 px-2 py-1 text-[10px] font-semibold text-[#3CE6AC] hover:bg-[#3CE6AC]/25"
|
|
136
|
+
onClick={confirm}
|
|
137
|
+
>
|
|
138
|
+
Group
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
40
145
|
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
|
|
41
146
|
if (!raw) return { version: 1, nodes: [] };
|
|
42
147
|
try {
|
|
@@ -53,12 +158,31 @@ interface TimelineFxButtonChainProps {
|
|
|
53
158
|
fxChainRaw: string | undefined;
|
|
54
159
|
onChainChange: (next: HfAudioFxChain) => void;
|
|
55
160
|
onChainPreview?: (next: HfAudioFxChain) => void;
|
|
56
|
-
|
|
161
|
+
/** The clips this chain is heard through, so an audition starts where they
|
|
162
|
+
* actually sound rather than from a playhead parked before the first. */
|
|
163
|
+
auditionSpans?: readonly AuditionSpan[];
|
|
164
|
+
/** Whether this target is muted right now. */
|
|
165
|
+
isMuted?: boolean;
|
|
166
|
+
/** Set this target's mute on the running graph WITHOUT touching the document,
|
|
167
|
+
* so an audition can lift a mute and put it back. Hovering a preset on a
|
|
168
|
+
* muted bus is a question about the preset, not about the mute. */
|
|
169
|
+
onSetMutedLive?: (muted: boolean) => void;
|
|
57
170
|
}
|
|
58
171
|
|
|
59
172
|
interface TimelineFxButtonGroupPointerProps {
|
|
60
173
|
variant: "group-pointer";
|
|
61
|
-
|
|
174
|
+
/** Create the group under this name. */
|
|
175
|
+
onGroupClips: (label: string) => void;
|
|
176
|
+
/** How many clips are about to be grouped, for the copy that explains it. */
|
|
177
|
+
clipCount: number;
|
|
178
|
+
/** Seeded into the name field — "Voiceover" per the design's own mockup. */
|
|
179
|
+
defaultLabel?: string;
|
|
180
|
+
/** Why this track cannot be grouped at all. Present, the dialog states the
|
|
181
|
+
* limit instead of offering a name field — groups are audio-only in v1
|
|
182
|
+
* (§1.4), and the doc is explicit that a deliberate limit has to be said:
|
|
183
|
+
* "silent ones just send authors hunting for something that was never
|
|
184
|
+
* built." */
|
|
185
|
+
refusal?: string;
|
|
62
186
|
}
|
|
63
187
|
|
|
64
188
|
type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
|
|
@@ -67,6 +191,12 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
67
191
|
const [open, setOpen] = useState(false);
|
|
68
192
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
69
193
|
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
194
|
+
// Owned here rather than threaded from each caller: both timeline call sites
|
|
195
|
+
// want the same thing, and neither passed one, so hovering a preset in this
|
|
196
|
+
// popover was silent unless the transport already happened to be running.
|
|
197
|
+
const transport = useAuditionTransport();
|
|
198
|
+
/** Whether THIS audition lifted a mute, so only it puts one back. */
|
|
199
|
+
const borrowedMute = useRef(false);
|
|
70
200
|
|
|
71
201
|
const openAt = () => {
|
|
72
202
|
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
|
@@ -94,30 +224,17 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
94
224
|
{open &&
|
|
95
225
|
anchorRect &&
|
|
96
226
|
createPortal(
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
>
|
|
109
|
-
<p>Group these clips to add effects to all of them.</p>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
|
|
113
|
-
onClick={() => {
|
|
114
|
-
setOpen(false);
|
|
115
|
-
props.onGroupClips();
|
|
116
|
-
}}
|
|
117
|
-
>
|
|
118
|
-
Group
|
|
119
|
-
</button>
|
|
120
|
-
</div>,
|
|
227
|
+
<GroupNameDialog
|
|
228
|
+
refusal={props.refusal}
|
|
229
|
+
anchorRect={anchorRect}
|
|
230
|
+
clipCount={props.clipCount}
|
|
231
|
+
defaultLabel={props.defaultLabel}
|
|
232
|
+
onCancel={() => setOpen(false)}
|
|
233
|
+
onConfirm={(label) => {
|
|
234
|
+
setOpen(false);
|
|
235
|
+
props.onGroupClips(label);
|
|
236
|
+
}}
|
|
237
|
+
/>,
|
|
121
238
|
document.body,
|
|
122
239
|
)}
|
|
123
240
|
</div>
|
|
@@ -156,7 +273,15 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
|
156
273
|
onClose={() => setOpen(false)}
|
|
157
274
|
onChainChange={props.onChainChange}
|
|
158
275
|
onChainPreview={props.onChainPreview}
|
|
159
|
-
onAuditionTransport={
|
|
276
|
+
onAuditionTransport={(on) => {
|
|
277
|
+
// Read on the way IN and remembered: the live unmute flows back
|
|
278
|
+
// into the row's props, so by the time the audition ends the
|
|
279
|
+
// target no longer looks muted and the mute would never return.
|
|
280
|
+
if (on) borrowedMute.current = props.isMuted === true;
|
|
281
|
+
if (borrowedMute.current) props.onSetMutedLive?.(!on);
|
|
282
|
+
if (!on) borrowedMute.current = false;
|
|
283
|
+
transport(on, props.auditionSpans);
|
|
284
|
+
}}
|
|
160
285
|
onOpenRack={props.onOpenRack}
|
|
161
286
|
/>,
|
|
162
287
|
document.body,
|
|
@@ -23,11 +23,6 @@ function renderHeader(
|
|
|
23
23
|
laneCount={0}
|
|
24
24
|
isLaneOpen={false}
|
|
25
25
|
onToggleLanes={vi.fn()}
|
|
26
|
-
hidden={false}
|
|
27
|
-
onToggleHidden={vi.fn()}
|
|
28
|
-
isSoloed={false}
|
|
29
|
-
isHalfLitSolo={false}
|
|
30
|
-
onToggleSolo={vi.fn()}
|
|
31
26
|
onFxChainChange={vi.fn()}
|
|
32
27
|
onOpenFxRack={vi.fn()}
|
|
33
28
|
columnWidth={220}
|