@hyperframes/studio 0.8.12 → 0.8.14
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-B243szNa.js → hyperframes-player-OIfv_7Ae.js} +1 -1
- package/dist/assets/{index-BZfUJ2He.js → index-6x3VcnTA.js} +179 -179
- package/dist/assets/{index-DQ_lkuqY.js → index-Bem14CzG.js} +1 -1
- package/dist/assets/{index-BTP86sD-.js → index-BtYWYq1P.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +5067 -4851
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/domSelectionTimelineMirror.ts +12 -3
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
- package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +113 -0
- package/src/player/components/TimelineAutomationLane.tsx +41 -25
- package/src/player/components/TimelineGroupLaneLabels.tsx +14 -3
- package/src/player/components/TimelineGroupRow.test.tsx +80 -4
- package/src/player/components/TimelineGroupRow.tsx +18 -8
- package/src/player/components/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- package/src/player/components/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.14",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/core": "0.8.14",
|
|
51
|
+
"@hyperframes/player": "0.8.14",
|
|
52
|
+
"@hyperframes/studio-server": "0.8.14",
|
|
53
|
+
"@hyperframes/sdk": "0.8.14",
|
|
54
|
+
"@hyperframes/parsers": "0.8.14"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vite": "^6.4.2",
|
|
69
69
|
"vitest": "^3.2.4",
|
|
70
70
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.8.
|
|
71
|
+
"@hyperframes/producer": "0.8.14"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"react": "19",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
2
|
+
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
2
3
|
import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
|
|
3
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
5
|
import { logSelect } from "../utils/selectDebug";
|
|
@@ -60,9 +61,17 @@ export function announceTimelineSelection(
|
|
|
60
61
|
anchor,
|
|
61
62
|
anchorPublished: anchor != null && publishedMembers.has(anchor),
|
|
62
63
|
});
|
|
63
|
-
// A canvas target can be editable without owning a timeline row.
|
|
64
|
-
//
|
|
65
|
-
|
|
64
|
+
// A canvas target can be editable without owning a timeline row. Most such
|
|
65
|
+
// targets live inside a clip and must not erase its timeline context. A mixer
|
|
66
|
+
// bus is different: it is itself the editing target, so its title replaces any
|
|
67
|
+
// selected clips even though the bus has no clip row of its own.
|
|
68
|
+
if (!timelineAnchor) {
|
|
69
|
+
if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
|
70
|
+
setTimelineSelectionSet(new Set());
|
|
71
|
+
setSelectedTimelineElementId(null);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
66
75
|
// A late async primary that already belongs to the live set must preserve the
|
|
67
76
|
// group. A fresh single click does not belong to it, so publish the singleton
|
|
68
77
|
// first; otherwise `preserveSet` clears the set and sync wipes the canvas.
|
|
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
describe("useDomSelection — canvas-only targets replace timeline clips", () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
deferreds.clear();
|
|
136
|
+
usePlayerStore.getState().clearSelection();
|
|
137
|
+
});
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
deferreds.clear();
|
|
140
|
+
usePlayerStore.getState().clearSelection();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("deselects every clip when an audio bus is selected", () => {
|
|
144
|
+
const store = usePlayerStore.getState();
|
|
145
|
+
store.setSelectedElementId("voice-1");
|
|
146
|
+
store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
|
|
147
|
+
|
|
148
|
+
const bus = document.createElement("hf-audio-group");
|
|
149
|
+
bus.id = "voiceover";
|
|
150
|
+
const harness = renderHarness({
|
|
151
|
+
rightPanelTab: "design",
|
|
152
|
+
setRightPanelTab: vi.fn(),
|
|
153
|
+
iframe: null,
|
|
154
|
+
timelineElements: [
|
|
155
|
+
{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
|
|
156
|
+
{ id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
|
|
157
|
+
],
|
|
158
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
159
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
|
|
163
|
+
|
|
164
|
+
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
|
165
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
166
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
167
|
+
harness.cleanup();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("lets a bus supersede a clip selection that is still resolving", async () => {
|
|
171
|
+
const iframe = document.createElement("iframe");
|
|
172
|
+
document.body.append(iframe);
|
|
173
|
+
const doc = iframe.contentDocument!;
|
|
174
|
+
const clipNode = doc.createElement("audio");
|
|
175
|
+
clipNode.id = "voice-1";
|
|
176
|
+
const busNode = doc.createElement("hf-audio-group");
|
|
177
|
+
busNode.id = "voiceover";
|
|
178
|
+
doc.body.append(clipNode, busNode);
|
|
179
|
+
|
|
180
|
+
const clip: TimelineElement = {
|
|
181
|
+
id: "voice-1",
|
|
182
|
+
domId: "voice-1",
|
|
183
|
+
tag: "audio",
|
|
184
|
+
start: 0,
|
|
185
|
+
duration: 1,
|
|
186
|
+
track: 0,
|
|
187
|
+
};
|
|
188
|
+
const bus: TimelineElement = {
|
|
189
|
+
id: "voiceover",
|
|
190
|
+
domId: "voiceover",
|
|
191
|
+
tag: "audio",
|
|
192
|
+
start: 0,
|
|
193
|
+
duration: 10,
|
|
194
|
+
track: -0.5,
|
|
195
|
+
};
|
|
196
|
+
const harness = renderHarness({
|
|
197
|
+
rightPanelTab: "design",
|
|
198
|
+
setRightPanelTab: vi.fn(),
|
|
199
|
+
iframe,
|
|
200
|
+
// The bus is a synthetic row target, not a clip in the store.
|
|
201
|
+
timelineElements: [clip],
|
|
202
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
203
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
let pendingClip = Promise.resolve();
|
|
207
|
+
let pendingBus = Promise.resolve();
|
|
208
|
+
act(() => {
|
|
209
|
+
pendingClip = harness.current().handleTimelineElementSelect(clip);
|
|
210
|
+
pendingBus = harness.current().handleTimelineElementSelect(bus);
|
|
211
|
+
});
|
|
212
|
+
await act(async () => {
|
|
213
|
+
deferreds.get("voiceover")?.resolve();
|
|
214
|
+
await pendingBus;
|
|
215
|
+
deferreds.get("voice-1")?.resolve();
|
|
216
|
+
await pendingClip;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
|
220
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
221
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
222
|
+
harness.cleanup();
|
|
223
|
+
iframe.remove();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("preserves clip context for a non-bus canvas-only selection", () => {
|
|
227
|
+
const store = usePlayerStore.getState();
|
|
228
|
+
store.setSelectedElementId("voice-1");
|
|
229
|
+
const decoration = document.createElement("div");
|
|
230
|
+
decoration.id = "decoration";
|
|
231
|
+
const harness = renderHarness({
|
|
232
|
+
rightPanelTab: "design",
|
|
233
|
+
setRightPanelTab: vi.fn(),
|
|
234
|
+
iframe: null,
|
|
235
|
+
timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
|
|
236
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
237
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
|
|
241
|
+
|
|
242
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
|
|
243
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
|
|
244
|
+
harness.cleanup();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
133
248
|
describe("useDomSelection — timeline-select race guard", () => {
|
|
134
249
|
beforeEach(() => deferreds.clear());
|
|
135
250
|
afterEach(() => deferreds.clear());
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The base automation envelope plus the heavier segment grab affordance. */
|
|
2
|
+
|
|
3
|
+
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import { envelopeSegmentPath } from "./automationLaneGeometry";
|
|
5
|
+
|
|
6
|
+
interface AutomationEnvelopePathsProps {
|
|
7
|
+
path: string;
|
|
8
|
+
lane: HfAutomationLane;
|
|
9
|
+
range: AutomationRange;
|
|
10
|
+
accentColor: string;
|
|
11
|
+
activeSegment: number | null;
|
|
12
|
+
xOf(t: number): number;
|
|
13
|
+
yOf(v: number): number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AutomationEnvelopePaths({
|
|
17
|
+
path,
|
|
18
|
+
lane,
|
|
19
|
+
range,
|
|
20
|
+
accentColor,
|
|
21
|
+
activeSegment,
|
|
22
|
+
xOf,
|
|
23
|
+
yOf,
|
|
24
|
+
}: AutomationEnvelopePathsProps) {
|
|
25
|
+
const activePath =
|
|
26
|
+
activeSegment === null
|
|
27
|
+
? null
|
|
28
|
+
: envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<path
|
|
33
|
+
data-automation-envelope=""
|
|
34
|
+
d={path}
|
|
35
|
+
fill="none"
|
|
36
|
+
stroke={accentColor}
|
|
37
|
+
strokeWidth={1.5}
|
|
38
|
+
opacity={lane.points.length === 0 ? 0.35 : 0.95}
|
|
39
|
+
/>
|
|
40
|
+
{activePath ? (
|
|
41
|
+
<path
|
|
42
|
+
data-automation-segment-active={activeSegment ?? undefined}
|
|
43
|
+
d={activePath}
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke={accentColor}
|
|
46
|
+
strokeWidth={3}
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
opacity={1}
|
|
49
|
+
pointerEvents="none"
|
|
50
|
+
/>
|
|
51
|
+
) : null}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
|
|
10
10
|
import {
|
|
11
11
|
normalizeAutomation,
|
|
12
12
|
resolveAutomationRange,
|
|
13
|
+
sampleAutomationLane,
|
|
13
14
|
VOLUME_RANGE,
|
|
14
15
|
type HfAutomation,
|
|
15
16
|
} from "@hyperframes/core/audio-automation";
|
|
@@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
|
|
|
668
669
|
});
|
|
669
670
|
});
|
|
670
671
|
|
|
672
|
+
describe("TimelineAutomationLane segment drag", () => {
|
|
673
|
+
const four: HfAutomation = {
|
|
674
|
+
version: 1,
|
|
675
|
+
lanes: [
|
|
676
|
+
{
|
|
677
|
+
target: "volume",
|
|
678
|
+
points: [
|
|
679
|
+
{ t: 0, v: 1 },
|
|
680
|
+
{ t: 1, v: 0.8 },
|
|
681
|
+
{ t: 2, v: 0.6 },
|
|
682
|
+
{ t: 3.5, v: 0.2 },
|
|
683
|
+
],
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
|
|
689
|
+
props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
|
|
690
|
+
t: number;
|
|
691
|
+
v: number;
|
|
692
|
+
viaX?: number;
|
|
693
|
+
viaY?: number;
|
|
694
|
+
}[];
|
|
695
|
+
|
|
696
|
+
it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
|
|
697
|
+
const { container, svg } = mount(ramp);
|
|
698
|
+
const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
|
|
699
|
+
expect(envelope?.getAttribute("stroke-width")).toBe("1.5");
|
|
700
|
+
|
|
701
|
+
fire(svg, "pointermove", at(2, 0.5));
|
|
702
|
+
const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
|
|
703
|
+
expect(active).not.toBeNull();
|
|
704
|
+
expect(active?.getAttribute("stroke-width")).toBe("3");
|
|
705
|
+
expect(svg.style.cursor).toBe("grab");
|
|
706
|
+
|
|
707
|
+
// Same time span, but far enough above the drawn ramp to be background.
|
|
708
|
+
fire(svg, "pointermove", at(2, 0.9));
|
|
709
|
+
expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
|
|
710
|
+
expect(svg.style.cursor).toBe("crosshair");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("moves both segment endpoints by the same time and value delta", () => {
|
|
714
|
+
const { svg, props } = mount(four);
|
|
715
|
+
// Midpoint of the segment from (1, .8) to (2, .6).
|
|
716
|
+
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
|
717
|
+
fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });
|
|
718
|
+
|
|
719
|
+
const points = previewedPoints(props);
|
|
720
|
+
expect(points[0]).toEqual({ t: 0, v: 1 });
|
|
721
|
+
expect(points[1]!.t).toBeCloseTo(1.5, 2);
|
|
722
|
+
expect(points[2]!.t).toBeCloseTo(2.5, 2);
|
|
723
|
+
expect(points[1]!.v).toBeCloseTo(0.6, 2);
|
|
724
|
+
expect(points[2]!.v).toBeCloseTo(0.4, 2);
|
|
725
|
+
expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("treats a press outside the line's proximity as a background range drag", () => {
|
|
729
|
+
const onRangeSelect = vi.fn();
|
|
730
|
+
const { svg, props } = mount(four, { onRangeSelect });
|
|
731
|
+
fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
|
|
732
|
+
fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
|
|
733
|
+
expect(onRangeSelect).toHaveBeenCalled();
|
|
734
|
+
expect(props.onPreview).not.toHaveBeenCalled();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("preserves the segment's curve while translating its endpoints", () => {
|
|
738
|
+
const curved: HfAutomation = {
|
|
739
|
+
version: 1,
|
|
740
|
+
lanes: [
|
|
741
|
+
{
|
|
742
|
+
target: "volume",
|
|
743
|
+
points: [
|
|
744
|
+
{ t: 0, v: 1 },
|
|
745
|
+
{ t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
|
|
746
|
+
{ t: 2, v: 0.6 },
|
|
747
|
+
{ t: 3.5, v: 0.2 },
|
|
748
|
+
],
|
|
749
|
+
},
|
|
750
|
+
],
|
|
751
|
+
};
|
|
752
|
+
const { svg, props } = mount(curved);
|
|
753
|
+
const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
|
|
754
|
+
fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
|
|
755
|
+
fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
|
|
756
|
+
const points = previewedPoints(props);
|
|
757
|
+
expect(points[1]?.viaX).toBe(0.4);
|
|
758
|
+
expect(points[1]?.viaY).toBe(0.7);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("stops both endpoints together before the next breakpoint", () => {
|
|
762
|
+
const { svg, props } = mount(four);
|
|
763
|
+
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
|
764
|
+
fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
|
|
765
|
+
const points = previewedPoints(props);
|
|
766
|
+
expect(points[2]!.t).toBeLessThan(points[3]!.t);
|
|
767
|
+
expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
|
|
768
|
+
expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("previews every move and persists the segment once on release", () => {
|
|
772
|
+
const { svg, props } = mount(four);
|
|
773
|
+
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
|
|
774
|
+
for (const t of [1.7, 1.9, 2.1]) {
|
|
775
|
+
fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
|
|
776
|
+
}
|
|
777
|
+
expect(props.onPreview).toHaveBeenCalledTimes(3);
|
|
778
|
+
expect(props.onCommit).not.toHaveBeenCalled();
|
|
779
|
+
fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
|
|
780
|
+
expect(props.onCommit).toHaveBeenCalledTimes(1);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
671
784
|
/** `mount`, plus the re-render a real store update causes — the persisted
|
|
672
785
|
* automation and the new selection coming back down as props. */
|
|
673
786
|
const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Breakpoint automation over an audio clip, edited the way a DAW edits it:
|
|
3
3
|
* double-click the line to add a point, drag one to shape it, right-click or
|
|
4
|
-
* Shift+click a point to remove it,
|
|
5
|
-
* it, and double-click a point to type an exact value.
|
|
4
|
+
* Shift+click a point to remove it, drag a line segment to move both endpoints,
|
|
5
|
+
* Alt-drag the line to bend it, and double-click a point to type an exact value.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a
|
|
8
|
+
* segment or ignores the grid during a point drag. Background drags select a
|
|
9
|
+
* set that can be moved, deleted, or shaped together.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* right-click inside the box for shapes over its span.
|
|
14
|
-
*
|
|
15
|
-
* The lane knows nothing about any particular effect. Which parameters it can
|
|
16
|
-
* offer, their ranges, units and whether they read logarithmically all come
|
|
17
|
-
* from the FX registry, so an effect gained upstream needs no change here — the
|
|
18
|
-
* same principle the property panel's controls follow.
|
|
11
|
+
* Effect parameters, ranges, units, and scaling come from the FX registry, so
|
|
12
|
+
* an upstream effect needs no lane-specific code here.
|
|
19
13
|
*/
|
|
20
14
|
|
|
21
15
|
import {
|
|
@@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
|
|
|
42
36
|
import { simplifyPoints } from "./automationSimplify";
|
|
43
37
|
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
|
|
44
38
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
39
|
+
import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths";
|
|
45
40
|
|
|
46
41
|
/**
|
|
47
42
|
* Drawn radius of a breakpoint.
|
|
@@ -102,7 +97,7 @@ function pointCircleStyle(
|
|
|
102
97
|
function laneTitle(readOnly: boolean | undefined): string {
|
|
103
98
|
return readOnly
|
|
104
99
|
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
|
|
105
|
-
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
|
|
100
|
+
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag a line segment to move both endpoints. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
|
|
106
101
|
}
|
|
107
102
|
|
|
108
103
|
/**
|
|
@@ -155,13 +150,19 @@ function pointHandleOpacity(args: {
|
|
|
155
150
|
return !args.readOnly && args.hovered ? 1 : 0;
|
|
156
151
|
}
|
|
157
152
|
|
|
158
|
-
function laneCursor(
|
|
153
|
+
function laneCursor(
|
|
154
|
+
readOnly: boolean | undefined,
|
|
155
|
+
dragging: boolean,
|
|
156
|
+
stretching: boolean,
|
|
157
|
+
segmentHovering: boolean,
|
|
158
|
+
): string {
|
|
159
159
|
// A stretch handle wins over everything it might also sit above: the handle is
|
|
160
160
|
// a few px wide and always overlaps whatever is under the selection edge, so
|
|
161
161
|
// any other cursor there would advertise a gesture the press will not start.
|
|
162
162
|
if (stretching) return "col-resize";
|
|
163
163
|
if (readOnly) return "pointer";
|
|
164
|
-
|
|
164
|
+
if (dragging) return "grabbing";
|
|
165
|
+
return segmentHovering ? "grab" : "crosshair";
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
export interface TimelineAutomationLaneProps {
|
|
@@ -318,7 +319,16 @@ export function TimelineAutomationLane({
|
|
|
318
319
|
duration,
|
|
319
320
|
rangeSelection,
|
|
320
321
|
});
|
|
321
|
-
const {
|
|
322
|
+
const {
|
|
323
|
+
dragIndex,
|
|
324
|
+
curveIndex,
|
|
325
|
+
segmentDragIndex,
|
|
326
|
+
segmentHoverIndex,
|
|
327
|
+
edgeDrag,
|
|
328
|
+
edgeHover,
|
|
329
|
+
hint,
|
|
330
|
+
editing,
|
|
331
|
+
} = gestures;
|
|
322
332
|
|
|
323
333
|
const removeAt = useCallback(
|
|
324
334
|
(index: number): void => {
|
|
@@ -426,8 +436,9 @@ export function TimelineAutomationLane({
|
|
|
426
436
|
height: h,
|
|
427
437
|
cursor: laneCursor(
|
|
428
438
|
readOnly,
|
|
429
|
-
dragIndex !== null || curveIndex !== null,
|
|
439
|
+
dragIndex !== null || curveIndex !== null || segmentDragIndex !== null,
|
|
430
440
|
edgeDrag !== null || edgeHover,
|
|
441
|
+
segmentHoverIndex !== null,
|
|
431
442
|
),
|
|
432
443
|
opacity: readOnly ? 0.55 : 1,
|
|
433
444
|
touchAction: "none",
|
|
@@ -435,7 +446,10 @@ export function TimelineAutomationLane({
|
|
|
435
446
|
width={widthPx + PAD_X * 2}
|
|
436
447
|
height={h}
|
|
437
448
|
onPointerEnter={() => setHovered(true)}
|
|
438
|
-
onPointerLeave={() =>
|
|
449
|
+
onPointerLeave={() => {
|
|
450
|
+
setHovered(false);
|
|
451
|
+
gestures.onPointerLeave();
|
|
452
|
+
}}
|
|
439
453
|
onPointerDown={gestures.onPointerDown}
|
|
440
454
|
onPointerMove={gestures.onPointerMove}
|
|
441
455
|
onPointerUp={gestures.endDrag}
|
|
@@ -478,12 +492,14 @@ export function TimelineAutomationLane({
|
|
|
478
492
|
pointerEvents="none"
|
|
479
493
|
/>
|
|
480
494
|
) : null}
|
|
481
|
-
<
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
495
|
+
<AutomationEnvelopePaths
|
|
496
|
+
path={path}
|
|
497
|
+
lane={lane}
|
|
498
|
+
range={range}
|
|
499
|
+
accentColor={accentColor}
|
|
500
|
+
activeSegment={segmentDragIndex ?? segmentHoverIndex}
|
|
501
|
+
xOf={xOf}
|
|
502
|
+
yOf={yOf}
|
|
487
503
|
/>
|
|
488
504
|
{lane.points.map((p, i) => {
|
|
489
505
|
// Endpoint-inclusive, the same rule Delete uses, so what looks caught by
|
|
@@ -25,6 +25,7 @@ export function TimelineGroupLaneLabels({
|
|
|
25
25
|
columnWidth,
|
|
26
26
|
gutterBackground,
|
|
27
27
|
accentColor,
|
|
28
|
+
onReveal,
|
|
28
29
|
}: {
|
|
29
30
|
/** The group wearing a clip's shape — see `groupAutomationElement`. */
|
|
30
31
|
groupElement: TimelineElement;
|
|
@@ -34,6 +35,8 @@ export function TimelineGroupLaneLabels({
|
|
|
34
35
|
columnWidth: number;
|
|
35
36
|
gutterBackground: string;
|
|
36
37
|
accentColor: string;
|
|
38
|
+
/** Select the bus and reveal this lane's exact parameter in its rack. */
|
|
39
|
+
onReveal?: (target: string) => void;
|
|
37
40
|
}) {
|
|
38
41
|
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
|
|
39
42
|
// on seek, so the readout sat frozen while the curve was audibly working,
|
|
@@ -55,10 +58,13 @@ export function TimelineGroupLaneLabels({
|
|
|
55
58
|
// clip-local rebase here — unlike a clip's lane.
|
|
56
59
|
const value = sampleAutomationLane(lane, currentTime);
|
|
57
60
|
return (
|
|
58
|
-
<
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
tabIndex={-1}
|
|
59
64
|
key={lane.target}
|
|
60
65
|
data-group-lane-label={lane.target}
|
|
61
|
-
|
|
66
|
+
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
|
|
67
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
|
62
68
|
style={{
|
|
63
69
|
top: top + index * AUTOMATION_LANE_H,
|
|
64
70
|
width: columnWidth,
|
|
@@ -67,6 +73,11 @@ export function TimelineGroupLaneLabels({
|
|
|
67
73
|
borderLeft: `2px solid ${accentColor}`,
|
|
68
74
|
}}
|
|
69
75
|
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
|
|
76
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
77
|
+
onClick={(event) => {
|
|
78
|
+
event.stopPropagation();
|
|
79
|
+
onReveal?.(lane.target);
|
|
80
|
+
}}
|
|
70
81
|
>
|
|
71
82
|
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
|
|
72
83
|
▤
|
|
@@ -80,7 +91,7 @@ export function TimelineGroupLaneLabels({
|
|
|
80
91
|
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
|
|
81
92
|
{value.toFixed(2)}
|
|
82
93
|
</span>
|
|
83
|
-
</
|
|
94
|
+
</button>
|
|
84
95
|
);
|
|
85
96
|
})}
|
|
86
97
|
</>
|
|
@@ -6,14 +6,23 @@ import { TimelineGroupRow } from "./TimelineGroupRow";
|
|
|
6
6
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
7
7
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
8
8
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
9
|
-
import type
|
|
9
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
10
10
|
|
|
11
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
12
|
|
|
13
13
|
vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true }));
|
|
14
|
+
const domEditMocks = vi.hoisted(() => ({
|
|
15
|
+
handleTimelineElementSelect: vi.fn(async () => undefined),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
18
|
+
useDomEditSelectionContextOptional: () => null,
|
|
19
|
+
useDomEditActionsContextOptional: () => domEditMocks,
|
|
20
|
+
}));
|
|
14
21
|
|
|
15
22
|
afterEach(() => {
|
|
16
23
|
document.body.innerHTML = "";
|
|
24
|
+
domEditMocks.handleTimelineElementSelect.mockClear();
|
|
25
|
+
usePlayerStore.setState({ revealedAudioFxTarget: null });
|
|
17
26
|
});
|
|
18
27
|
|
|
19
28
|
const member = (id: string, track: number): TimelineElement => ({
|
|
@@ -36,7 +45,10 @@ const GROUP: TimelineTrackGroupInfo = {
|
|
|
36
45
|
hidden: false,
|
|
37
46
|
};
|
|
38
47
|
|
|
39
|
-
function renderRow(
|
|
48
|
+
function renderRow(
|
|
49
|
+
overrides: Partial<TimelineTrackGroupInfo> = {},
|
|
50
|
+
expandedLaneOwnerIds = new Set<string>(),
|
|
51
|
+
) {
|
|
40
52
|
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
41
53
|
const onSetElementAttributeQuiet = vi.fn();
|
|
42
54
|
const host = document.createElement("div");
|
|
@@ -55,10 +67,31 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
|
|
|
55
67
|
contentOrigin={232}
|
|
56
68
|
theme={defaultTimelineTheme}
|
|
57
69
|
collapsedGroupIds={new Set()}
|
|
58
|
-
expandedLaneOwnerIds={
|
|
70
|
+
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
59
71
|
toggleGroupExpanded={vi.fn()}
|
|
60
72
|
toggleLaneOwnerExpanded={vi.fn()}
|
|
61
|
-
lanes={
|
|
73
|
+
lanes={
|
|
74
|
+
{
|
|
75
|
+
bind: (element: TimelineElement) => {
|
|
76
|
+
const automation = element.automation
|
|
77
|
+
? JSON.parse(element.automation)
|
|
78
|
+
: { version: 1, lanes: [] };
|
|
79
|
+
return {
|
|
80
|
+
automation,
|
|
81
|
+
lanes: automation.lanes,
|
|
82
|
+
chain: element.fxChain ? JSON.parse(element.fxChain) : null,
|
|
83
|
+
onPreview: vi.fn(),
|
|
84
|
+
onCommit: vi.fn(),
|
|
85
|
+
onSelect: vi.fn(),
|
|
86
|
+
readOnly: true,
|
|
87
|
+
commitTargetKey: null,
|
|
88
|
+
selection: null,
|
|
89
|
+
onRangeSelect: vi.fn(),
|
|
90
|
+
onRangeClear: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
} as never
|
|
94
|
+
}
|
|
62
95
|
pps={10}
|
|
63
96
|
currentTime={0}
|
|
64
97
|
compositionDuration={60}
|
|
@@ -72,6 +105,49 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
|
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
describe("TimelineGroupRow", () => {
|
|
108
|
+
it("routes the group title through the guarded selection path", () => {
|
|
109
|
+
const { host } = renderRow();
|
|
110
|
+
const title = host.querySelector<HTMLButtonElement>(
|
|
111
|
+
'button[aria-label="Open Voiceover effects"]',
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
act(() => title?.click());
|
|
115
|
+
|
|
116
|
+
expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("opens a group automation lane on its exact rack parameter", async () => {
|
|
122
|
+
const { host } = renderRow(
|
|
123
|
+
{
|
|
124
|
+
fxChain: JSON.stringify({
|
|
125
|
+
version: 1,
|
|
126
|
+
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
127
|
+
}),
|
|
128
|
+
automation: JSON.stringify({
|
|
129
|
+
version: 1,
|
|
130
|
+
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
new Set(["voiceover"]),
|
|
134
|
+
);
|
|
135
|
+
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
laneTitle?.click();
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
|
|
144
|
+
);
|
|
145
|
+
expect(usePlayerStore.getState().revealedAudioFxTarget).toMatchObject({
|
|
146
|
+
elementKey: "voiceover",
|
|
147
|
+
automationTarget: "fx.p1.gain",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
75
151
|
// C1 names this as the step's own definition of done: "opening the popover on
|
|
76
152
|
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
|
|
77
153
|
// on the group element, and zero writes on members". A group IS a bus — a
|