@hyperframes/studio 0.7.107 → 0.7.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
- package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
- package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
- package/dist/assets/index-CfBGQdrX.js +428 -0
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/index.d.ts +85 -2
- package/dist/index.html +2 -2
- package/dist/index.js +17696 -12512
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/contexts/DomEditContext.tsx +11 -0
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +30 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +38 -0
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.ts +2 -0
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/system.ts +8 -0
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioTelemetry.ts +16 -10
- package/dist/assets/index-dF-CmLZu.js +0 -428
- package/dist/assets/index-zQ4JFwwB.css +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes for the timeline's audio automation lanes.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of TimelineLanes so that component does not grow another concern.
|
|
5
|
+
* Reading lives in `automationLaneData`, shared with the row layout, which needs
|
|
6
|
+
* the lane count to reserve height.
|
|
7
|
+
*
|
|
8
|
+
* Edits go to the *selected* element, because that is what the attribute commit
|
|
9
|
+
* path targets. An unselected clip still draws its envelopes — they are just
|
|
10
|
+
* read only, which is also what stops a stray drag from editing the wrong track.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
14
|
+
import {
|
|
15
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
16
|
+
serializeAutomation,
|
|
17
|
+
type HfAutomation,
|
|
18
|
+
type HfAutomationLane,
|
|
19
|
+
} from "@hyperframes/core/audio-automation";
|
|
20
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
21
|
+
import {
|
|
22
|
+
useDomEditActionsContextOptional,
|
|
23
|
+
useDomEditSelectionContextOptional,
|
|
24
|
+
} from "../../contexts/DomEditContext";
|
|
25
|
+
import { resolveTimelineIdForSelection } from "../../utils/studioHelpers";
|
|
26
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
27
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
28
|
+
import type { AutomationSelection } from "../store/automationSelectionSlice";
|
|
29
|
+
import { elementAutomation, elementFxChain } from "./automationLaneData";
|
|
30
|
+
import { createAutomationGestureKeys } from "./automationGestureKeys";
|
|
31
|
+
|
|
32
|
+
export interface AutomationLaneBinding {
|
|
33
|
+
automation: HfAutomation;
|
|
34
|
+
/** One entry per lane, in draw order — each gets its own row. */
|
|
35
|
+
lanes: HfAutomationLane[];
|
|
36
|
+
chain: HfAudioFxChain | null;
|
|
37
|
+
/** Continuous write while dragging; does not persist. */
|
|
38
|
+
onPreview(next: HfAutomation): void;
|
|
39
|
+
/** Gesture-end write; this is the one that persists and lands in undo. */
|
|
40
|
+
onCommit(next: HfAutomation): void;
|
|
41
|
+
/**
|
|
42
|
+
* Select this clip, which is what makes its lanes editable. A lane calls this
|
|
43
|
+
* instead of writing when it is read-only — pressing the lane is the only
|
|
44
|
+
* route in, since lanes sit below the clip bar where the timeline's own
|
|
45
|
+
* selection handler never sees them.
|
|
46
|
+
*/
|
|
47
|
+
onSelect(): void;
|
|
48
|
+
readOnly: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
|
|
51
|
+
* whatever the dom-edit layer has selected — not necessarily the element this
|
|
52
|
+
* binding was made for (see `onSelect` below). Null outside an edit session or
|
|
53
|
+
* when the dom-edit selection maps to no clip. Resolved exactly the way
|
|
54
|
+
* applyDomSelection resolves it, so in a settled selection it equals the bound
|
|
55
|
+
* element's key; it lags only in the window a non-gesture caller can hit.
|
|
56
|
+
*/
|
|
57
|
+
commitTargetKey: string | null;
|
|
58
|
+
/** This element's active selection box, or null if none / it belongs to a
|
|
59
|
+
* different element. */
|
|
60
|
+
selection: AutomationSelection | null;
|
|
61
|
+
/** Live write while dragging a selection box on the given lane; does not
|
|
62
|
+
* persist — the selection is ephemeral store state, not part of the
|
|
63
|
+
* composition. */
|
|
64
|
+
onRangeSelect(target: string, t0: number, t1: number, v0: number, v1: number): void;
|
|
65
|
+
onRangeClear(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UseAutomationLanesResult {
|
|
69
|
+
bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useAutomationLanes(): UseAutomationLanesResult {
|
|
73
|
+
// One per hook instance, held across renders: a gesture spans many commits and
|
|
74
|
+
// they all have to record under the same key for undo to take the whole drag.
|
|
75
|
+
const gestureKeys = useRef(createAutomationGestureKeys());
|
|
76
|
+
// Optional: the player also runs outside Studio, where there is no edit
|
|
77
|
+
// session. There the lanes render read-only, which is the right fallback.
|
|
78
|
+
const domEdit = useDomEditActionsContextOptional();
|
|
79
|
+
const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
|
|
80
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
81
|
+
const automationSelection = usePlayerStore((s) => s.automationSelection);
|
|
82
|
+
const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
|
|
83
|
+
const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
|
|
84
|
+
|
|
85
|
+
// Read from the SAME render as the commit handlers below: both contexts update
|
|
86
|
+
// in one commit, so a handler and this key can never describe different
|
|
87
|
+
// moments. activeCompPath is not needed — resolveTimelineIdForSelection only
|
|
88
|
+
// uses it as a fallback for a selection with no sourceFile of its own, and
|
|
89
|
+
// DomEditSelection always carries one.
|
|
90
|
+
const commitTargetKey = useMemo(
|
|
91
|
+
() =>
|
|
92
|
+
domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
|
|
93
|
+
[domEditSelection, elements],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const bind = useCallback(
|
|
97
|
+
(element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
|
|
98
|
+
const chain = elementFxChain(element);
|
|
99
|
+
const automation = elementAutomation(element);
|
|
100
|
+
const elementKey = getTimelineElementIdentity(element);
|
|
101
|
+
|
|
102
|
+
const write = (next: HfAutomation, persist: boolean): void => {
|
|
103
|
+
if (!domEdit || !isSelected) return;
|
|
104
|
+
const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
|
|
105
|
+
// Every write of one gesture under one key, so undo takes back the whole
|
|
106
|
+
// drag rather than the last fragment history happened to keep.
|
|
107
|
+
const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
|
|
108
|
+
// Quiet, not the refreshing commit: releasing a dragged point used to
|
|
109
|
+
// reload the preview, which restarts every playing track — the same chop
|
|
110
|
+
// the live write during the drag exists to avoid. Quiet still persists
|
|
111
|
+
// and still resyncs the selection, so the next edit sees this one.
|
|
112
|
+
if (persist) {
|
|
113
|
+
void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
|
|
114
|
+
}
|
|
115
|
+
// Dragging a point writes live: no preview refresh, so the composition
|
|
116
|
+
// does not reload and restart playback on every pixel.
|
|
117
|
+
else {
|
|
118
|
+
// Preview only, because a gesture writes on every pointermove: the
|
|
119
|
+
// preview and the running audio follow the pointer, and the release
|
|
120
|
+
// below is the one write that reaches the file and the undo stack.
|
|
121
|
+
void domEdit.handleDomAttributeLiveCommit(
|
|
122
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
123
|
+
value || null,
|
|
124
|
+
undefined,
|
|
125
|
+
{
|
|
126
|
+
coalesce,
|
|
127
|
+
previewOnly: true,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
automation,
|
|
135
|
+
lanes: automation.lanes,
|
|
136
|
+
chain,
|
|
137
|
+
onPreview: (next) => write(next, false),
|
|
138
|
+
onCommit: (next) => write(next, true),
|
|
139
|
+
// Deliberately not awaited before an edit: the commit handlers close
|
|
140
|
+
// over the selection as it was when they were built, so writing in the
|
|
141
|
+
// same tick would land on whichever element was selected before.
|
|
142
|
+
// Selecting is its own gesture; the lane goes live after it.
|
|
143
|
+
onSelect: () => void domEdit?.handleTimelineElementSelect(element),
|
|
144
|
+
readOnly: !domEdit || !isSelected,
|
|
145
|
+
commitTargetKey: domEdit ? commitTargetKey : null,
|
|
146
|
+
selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
|
|
147
|
+
// Not gated on `isSelected`, unlike the writes above. A selection is
|
|
148
|
+
// ephemeral store state, and the drag that draws one on a read-only lane is
|
|
149
|
+
// the same press that selects the clip — refusing it here meant the first
|
|
150
|
+
// drag on a lane silently did nothing and the author had to drag again.
|
|
151
|
+
// Nothing can be written through it while the lane is read-only: every
|
|
152
|
+
// consumer resolves the binding again and finds `readOnly`.
|
|
153
|
+
onRangeSelect: (target, t0, t1, v0, v1) => {
|
|
154
|
+
if (!domEdit) return;
|
|
155
|
+
setAutomationSelection({ elementKey, target, t0, t1, v0, v1 });
|
|
156
|
+
},
|
|
157
|
+
onRangeClear: () => clearAutomationSelection(),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
[
|
|
161
|
+
domEdit,
|
|
162
|
+
commitTargetKey,
|
|
163
|
+
automationSelection,
|
|
164
|
+
setAutomationSelection,
|
|
165
|
+
clearAutomationSelection,
|
|
166
|
+
],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return useMemo(() => ({ bind }), [bind]);
|
|
170
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing a new selection box over an automation lane's empty background.
|
|
3
|
+
*
|
|
4
|
+
* Its own module for the same reason useAutomationEdgeStretch is: a sixth
|
|
5
|
+
* mutually-exclusive gesture on a hook already at the file's complexity
|
|
6
|
+
* budget, and one with no use for anything the others snapshot.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
10
|
+
import { snapLaneTime } from "./automationLaneGeometry";
|
|
11
|
+
import { capturePointer } from "./automationLanePointer";
|
|
12
|
+
import { SNAP_SEC } from "./automationLaneDragMath";
|
|
13
|
+
|
|
14
|
+
export interface UseAutomationRangeDragInput {
|
|
15
|
+
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
|
16
|
+
xOf(t: number): number;
|
|
17
|
+
yOf(v: number): number;
|
|
18
|
+
duration: number;
|
|
19
|
+
snapTimes?: readonly number[] | undefined;
|
|
20
|
+
onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
|
|
21
|
+
onRangeClear?: (() => void) | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseAutomationRangeDragResult {
|
|
25
|
+
/** Whether a box is being drawn right now. */
|
|
26
|
+
dragging: boolean;
|
|
27
|
+
/** Arms a new box at the press, or declines when no caller wants to hear
|
|
28
|
+
* about one (a read-only lane). */
|
|
29
|
+
arm(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
30
|
+
move(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
31
|
+
/** A sub-threshold press clears the selection rather than leaving a
|
|
32
|
+
* zero-width one behind. */
|
|
33
|
+
finish(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A corner of the selection box under the pointer.
|
|
38
|
+
*
|
|
39
|
+
* Time is clamped to the clip and snaps to the grid, because the box's span is
|
|
40
|
+
* also what a shape insert or a paste acts over and those want beat-aligned
|
|
41
|
+
* edges. The value is taken as it lies: there is no grid on a parameter axis,
|
|
42
|
+
* and rounding a dB bound would move which points the box catches.
|
|
43
|
+
*/
|
|
44
|
+
function boxCornerAt(
|
|
45
|
+
e: ReactPointerEvent<SVGSVGElement>,
|
|
46
|
+
input: Pick<UseAutomationRangeDragInput, "pointAt" | "duration" | "snapTimes">,
|
|
47
|
+
): { t: number; v: number } {
|
|
48
|
+
const raw = input.pointAt(e.clientX, e.clientY);
|
|
49
|
+
const clamped = Math.min(input.duration, Math.max(0, raw.t));
|
|
50
|
+
return {
|
|
51
|
+
t: e.altKey ? clamped : snapLaneTime(clamped, input.snapTimes ?? [], SNAP_SEC),
|
|
52
|
+
v: raw.v,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useAutomationRangeDrag({
|
|
57
|
+
pointAt,
|
|
58
|
+
xOf,
|
|
59
|
+
yOf,
|
|
60
|
+
duration,
|
|
61
|
+
snapTimes,
|
|
62
|
+
onRangeSelect,
|
|
63
|
+
onRangeClear,
|
|
64
|
+
}: UseAutomationRangeDragInput): UseAutomationRangeDragResult {
|
|
65
|
+
const [box, setBox] = useState<{
|
|
66
|
+
from: { t: number; v: number };
|
|
67
|
+
to: { t: number; v: number };
|
|
68
|
+
} | null>(null);
|
|
69
|
+
/** Whether the live drag has crossed the pixel threshold that turns a press
|
|
70
|
+
* into an actual box, rather than a click that should just clear one. */
|
|
71
|
+
const crossed = useRef(false);
|
|
72
|
+
|
|
73
|
+
const arm = useCallback(
|
|
74
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
75
|
+
if (!onRangeSelect) return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
capturePointer(e);
|
|
78
|
+
const corner = boxCornerAt(e, { pointAt, duration, snapTimes });
|
|
79
|
+
crossed.current = false;
|
|
80
|
+
setBox({ from: corner, to: corner });
|
|
81
|
+
},
|
|
82
|
+
[onRangeSelect, pointAt, duration, snapTimes],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const move = useCallback(
|
|
86
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
87
|
+
if (box === null) return;
|
|
88
|
+
const to = boxCornerAt(e, { pointAt, duration, snapTimes });
|
|
89
|
+
const { from } = box;
|
|
90
|
+
setBox({ from, to });
|
|
91
|
+
const travelled = Math.max(
|
|
92
|
+
Math.abs(xOf(to.t) - xOf(from.t)),
|
|
93
|
+
Math.abs(yOf(to.v) - yOf(from.v)),
|
|
94
|
+
);
|
|
95
|
+
if (travelled <= 3) return;
|
|
96
|
+
crossed.current = true;
|
|
97
|
+
onRangeSelect?.(
|
|
98
|
+
Math.min(from.t, to.t),
|
|
99
|
+
Math.max(from.t, to.t),
|
|
100
|
+
Math.min(from.v, to.v),
|
|
101
|
+
Math.max(from.v, to.v),
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
[box, pointAt, duration, snapTimes, xOf, yOf, onRangeSelect],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const finish = useCallback((): void => {
|
|
108
|
+
if (!crossed.current) onRangeClear?.();
|
|
109
|
+
crossed.current = false;
|
|
110
|
+
setBox(null);
|
|
111
|
+
}, [onRangeClear]);
|
|
112
|
+
|
|
113
|
+
return { dragging: box !== null, arm, move, finish };
|
|
114
|
+
}
|
|
@@ -6,8 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
6
6
|
import { afterEach, describe, expect, it } from "vitest";
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { LANE_H, TRACK_H } from "./timelineLayout";
|
|
9
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
9
10
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
10
|
-
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
|
11
|
+
import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
|
11
12
|
|
|
12
13
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
14
|
|
|
@@ -92,3 +93,108 @@ describe("useTimelineTrackLayout", () => {
|
|
|
92
93
|
unmount();
|
|
93
94
|
});
|
|
94
95
|
});
|
|
96
|
+
const audioClip = (id: string, over: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
97
|
+
id,
|
|
98
|
+
key: id,
|
|
99
|
+
tag: "audio",
|
|
100
|
+
start: 0,
|
|
101
|
+
duration: 10,
|
|
102
|
+
track: 10,
|
|
103
|
+
...over,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Clips sharing a row share a lane row per property, so the height they reserve
|
|
108
|
+
* is the track's grouped count — and the row is open when ANY of them is
|
|
109
|
+
* expanded, or clicking a sibling collapsed it.
|
|
110
|
+
*/
|
|
111
|
+
describe("a track several clips share", () => {
|
|
112
|
+
const peaking = (gain: number) =>
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
version: 1,
|
|
115
|
+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }],
|
|
116
|
+
});
|
|
117
|
+
const lanes = (...targets: string[]) =>
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
version: 1,
|
|
120
|
+
lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
|
|
121
|
+
});
|
|
122
|
+
const narration1 = audioClip("narration-1", {
|
|
123
|
+
fxChain: peaking(-3),
|
|
124
|
+
automation: lanes("fx.n1.gain"),
|
|
125
|
+
});
|
|
126
|
+
const narration2 = audioClip("narration-2", {
|
|
127
|
+
start: 10,
|
|
128
|
+
fxChain: peaking(-6),
|
|
129
|
+
automation: lanes("fx.n1.gain", "volume"),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Reserved height for the row, with only narration-1 ever expanded. */
|
|
133
|
+
function rowHeight(selectedElementId: string | null): number {
|
|
134
|
+
usePlayerStore.setState({ expandedClipIds: new Set(["narration-1"]) });
|
|
135
|
+
let height = 0;
|
|
136
|
+
function Probe() {
|
|
137
|
+
height =
|
|
138
|
+
useTimelineTrackLayout([narration1, narration2], new Map(), selectedElementId, new Set())
|
|
139
|
+
.rowHeights[0] ?? 0;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const root = createRoot(document.createElement("div"));
|
|
143
|
+
act(() => root.render(React.createElement(Probe)));
|
|
144
|
+
act(() => root.unmount());
|
|
145
|
+
return height;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it("reserves one row per property, not per clip's lane", () => {
|
|
149
|
+
// Two properties across the two clips — a shared 1 kHz peaking gain and a
|
|
150
|
+
// volume envelope on one of them — so two rows, not three.
|
|
151
|
+
expect(rowHeight("narration-1")).toBe(TRACK_H + 2 * AUTOMATION_LANE_H);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stays open at the same height when the selection moves to a sibling", () => {
|
|
155
|
+
// Expansion is stored per clip but reads as the row's: asking only about the
|
|
156
|
+
// active clip collapsed the row the moment another was clicked.
|
|
157
|
+
expect(rowHeight("narration-2")).toBe(rowHeight("narration-1"));
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("resolveTrackKeyframeClip", () => {
|
|
162
|
+
const none = new Map<string, number>();
|
|
163
|
+
|
|
164
|
+
it("picks an audio clip that has only automation, no tweens", () => {
|
|
165
|
+
// Gating on tweens alone left an audio clip's envelopes unreachable: no
|
|
166
|
+
// clip resolved, so the track got no caret, no height and no lanes.
|
|
167
|
+
const bgm = audioClip("bgm");
|
|
168
|
+
const picked = resolveTrackKeyframeClip([bgm], none, null, new Set(), () => 1);
|
|
169
|
+
expect(picked).toBe(bgm);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("still resolves nothing when a clip has neither", () => {
|
|
173
|
+
expect(resolveTrackKeyframeClip([audioClip("bgm")], none, null, new Set(), () => 0)).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("prefers the selected clip over the one with more to show", () => {
|
|
177
|
+
const a = audioClip("a");
|
|
178
|
+
const b = audioClip("b");
|
|
179
|
+
const picked = resolveTrackKeyframeClip([a, b], new Map([["b", 4]]), "a", new Set(), (e) =>
|
|
180
|
+
e.id === "a" ? 1 : 0,
|
|
181
|
+
);
|
|
182
|
+
expect(picked).toBe(a);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("counts tweens and automation together when breaking a tie", () => {
|
|
186
|
+
const a = audioClip("a");
|
|
187
|
+
const b = audioClip("b");
|
|
188
|
+
const picked = resolveTrackKeyframeClip(
|
|
189
|
+
[a, b],
|
|
190
|
+
new Map([
|
|
191
|
+
["a", 1],
|
|
192
|
+
["b", 1],
|
|
193
|
+
]),
|
|
194
|
+
null,
|
|
195
|
+
new Set(),
|
|
196
|
+
(e) => (e.id === "b" ? 3 : 0),
|
|
197
|
+
);
|
|
198
|
+
expect(picked).toBe(b);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useMemo, useRef } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import { animationLaneGroups } from "./TimelinePropertyLanes";
|
|
4
|
+
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
5
|
+
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
|
|
4
6
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
6
8
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
@@ -14,6 +16,60 @@ import {
|
|
|
14
16
|
|
|
15
17
|
export { getTrackStyle } from "./timelineIcons";
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Whether this track draws the beat-dot strip: only where there are beats to
|
|
21
|
+
* draw, and only on the track the user is working in — the selected clip's, or
|
|
22
|
+
* the music track's when nothing is selected.
|
|
23
|
+
*/
|
|
24
|
+
export function trackShowsBeatStrip(
|
|
25
|
+
els: readonly TimelineElement[],
|
|
26
|
+
beatTimes: readonly number[] | undefined,
|
|
27
|
+
ctx: {
|
|
28
|
+
selectedElementId: string | null;
|
|
29
|
+
isMusicTrack(element: TimelineElement): boolean;
|
|
30
|
+
},
|
|
31
|
+
): boolean {
|
|
32
|
+
if ((beatTimes?.length ?? 0) < 2) return false;
|
|
33
|
+
return ctx.selectedElementId
|
|
34
|
+
? els.some((e) => (e.key ?? e.id) === ctx.selectedElementId)
|
|
35
|
+
: els.some((e) => ctx.isMusicTrack(e));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Automation lanes on one clip, or 0 for anything that is not audio.
|
|
40
|
+
*
|
|
41
|
+
* An audio clip can be worth expanding without carrying a single tween, so this
|
|
42
|
+
* counts toward whether a track has anything to disclose. A function rather than
|
|
43
|
+
* a map so every caller reads the same cached parse and none can drift.
|
|
44
|
+
*/
|
|
45
|
+
function automationLaneCountOf(element: TimelineElement): number {
|
|
46
|
+
return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Automation rows a TRACK reserves: the union over the clips sharing it, since
|
|
51
|
+
* clips on one row share a lane row per property. Counting only the active clip's
|
|
52
|
+
* lanes reserved a height that changed with the selection.
|
|
53
|
+
*/
|
|
54
|
+
function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
|
|
55
|
+
return groupAutomationLanes(elements).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Is this row disclosed? Expansion is stored per clip, but it reads as a property
|
|
60
|
+
* of the ROW: the active clip changes with the selection, so asking only about it
|
|
61
|
+
* collapsed the row the moment you clicked a sibling. Any expanded clip on the
|
|
62
|
+
* track holds the row open — and the caret expands and collapses all of them
|
|
63
|
+
* together (see TimelineLanes), so the two can only disagree on state predating
|
|
64
|
+
* this rule or written by the keyframe auto-expand.
|
|
65
|
+
*/
|
|
66
|
+
export function isTrackRowExpanded(
|
|
67
|
+
elements: readonly TimelineElement[],
|
|
68
|
+
expandedClipIds: ReadonlySet<string>,
|
|
69
|
+
): boolean {
|
|
70
|
+
return elements.some((element) => expandedClipIds.has(element.key ?? element.id));
|
|
71
|
+
}
|
|
72
|
+
|
|
17
73
|
/**
|
|
18
74
|
* The single keyframed element whose property lanes a track shows when expanded.
|
|
19
75
|
* A track can hold several elements (same z-index is common), but keyframes are
|
|
@@ -27,10 +83,15 @@ export function resolveTrackKeyframeClip(
|
|
|
27
83
|
laneCounts: ReadonlyMap<string, number>,
|
|
28
84
|
selectedElementId: string | null,
|
|
29
85
|
selectedElementIds: ReadonlySet<string>,
|
|
86
|
+
automationLaneCount: (element: TimelineElement) => number = automationLaneCountOf,
|
|
30
87
|
): TimelineElement | null {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
88
|
+
// Automation counts toward "has something to disclose". Without it an audio
|
|
89
|
+
// clip carrying envelopes but no tweens resolved to null, so its track got no
|
|
90
|
+
// caret, no reserved height and no lanes — the automation was unreachable for
|
|
91
|
+
// exactly the tracks the feature is for.
|
|
92
|
+
const disclosable = (element: TimelineElement): number =>
|
|
93
|
+
(laneCounts.get(element.key ?? element.id) ?? 0) + automationLaneCount(element);
|
|
94
|
+
const keyframed = elements.filter((element) => disclosable(element) >= 1);
|
|
34
95
|
if (keyframed.length === 0) return null;
|
|
35
96
|
const selected = keyframed.find((element) => {
|
|
36
97
|
const key = element.key ?? element.id;
|
|
@@ -39,8 +100,9 @@ export function resolveTrackKeyframeClip(
|
|
|
39
100
|
if (selected) return selected;
|
|
40
101
|
// Most lanes wins, first one on a tie (same as the old stable sort), but as a
|
|
41
102
|
// reduce over the already non-empty list so there's no index to assert on.
|
|
42
|
-
|
|
43
|
-
|
|
103
|
+
return keyframed.reduce((best, element) =>
|
|
104
|
+
disclosable(element) > disclosable(best) ? element : best,
|
|
105
|
+
);
|
|
44
106
|
}
|
|
45
107
|
|
|
46
108
|
/** Lanes per clip: the count of distinct property groups whose tween contributes
|
|
@@ -74,8 +136,9 @@ function useTimelineRowHeights(
|
|
|
74
136
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
75
137
|
const { laneCounts, rowGeometry } = useMemo(() => {
|
|
76
138
|
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
|
77
|
-
//
|
|
139
|
+
// Keyframe lanes follow only the active clip, so a track with several
|
|
78
140
|
// keyframed elements never reserves empty lanes for the ones not shown.
|
|
141
|
+
// Automation lanes follow the whole row: they are shared per property.
|
|
79
142
|
const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
|
|
80
143
|
const active = resolveTrackKeyframeClip(
|
|
81
144
|
elements,
|
|
@@ -85,7 +148,20 @@ function useTimelineRowHeights(
|
|
|
85
148
|
);
|
|
86
149
|
if (!active) return [];
|
|
87
150
|
const clipId = active.key ?? active.id;
|
|
88
|
-
|
|
151
|
+
// `trackHeights` gates the reserved lanes on this id being expanded, and the
|
|
152
|
+
// row is expanded when ANY of its clips is — so hand it whichever clip holds
|
|
153
|
+
// the row open, while the lane counts stay the active clip's (keyframes) and
|
|
154
|
+
// the track's (automation, shared across the row).
|
|
155
|
+
const holdingOpen = elements.find((element) =>
|
|
156
|
+
expandedClipIds.has(element.key ?? element.id),
|
|
157
|
+
);
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
clipId: holdingOpen ? (holdingOpen.key ?? holdingOpen.id) : clipId,
|
|
161
|
+
laneCount: laneCounts.get(clipId) ?? 0,
|
|
162
|
+
automationLaneCount: trackAutomationLaneCount(elements),
|
|
163
|
+
},
|
|
164
|
+
];
|
|
89
165
|
});
|
|
90
166
|
const rowHeights = trackHeights(heightTracks, expandedClipIds);
|
|
91
167
|
return {
|
|
@@ -152,6 +152,13 @@ function hostElementState(flat: TimelineElement | undefined): Partial<TimelineEl
|
|
|
152
152
|
hidden: flat.hidden,
|
|
153
153
|
timelineLocked: flat.timelineLocked,
|
|
154
154
|
timelineRole: flat.timelineRole,
|
|
155
|
+
// Same reason as the three above: these are read off the host element, which
|
|
156
|
+
// an expanded child is built without. Missing them, an audio child inside a
|
|
157
|
+
// sub-composition reserved no automation height and drew no lanes, while the
|
|
158
|
+
// property panel — reading the live DOM selection rather than this row —
|
|
159
|
+
// still showed the chain and its toggles.
|
|
160
|
+
fxChain: flat.fxChain,
|
|
161
|
+
automation: flat.automation,
|
|
155
162
|
};
|
|
156
163
|
}
|
|
157
164
|
|
|
@@ -389,8 +389,20 @@ export function useTimelinePlayer() {
|
|
|
389
389
|
seek(state.requestedSeekTime);
|
|
390
390
|
usePlayerStore.getState().clearSeekRequest();
|
|
391
391
|
}
|
|
392
|
+
// Play or stop from outside the loop — the FX rack auditioning a preset
|
|
393
|
+
// while paused, which is silent otherwise. `returnTo` puts the playhead
|
|
394
|
+
// back where the request found it: hovering is not an edit.
|
|
395
|
+
const request = state.playbackRequest;
|
|
396
|
+
if (request && request.nonce !== prev.playbackRequest?.nonce) {
|
|
397
|
+
if (request.playing) play();
|
|
398
|
+
else {
|
|
399
|
+
pause();
|
|
400
|
+
if (request.returnTo !== null) seek(request.returnTo);
|
|
401
|
+
}
|
|
402
|
+
usePlayerStore.getState().clearPlaybackRequest();
|
|
403
|
+
}
|
|
392
404
|
});
|
|
393
|
-
}, [seek]);
|
|
405
|
+
}, [seek, play, pause]);
|
|
394
406
|
const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
|
|
395
407
|
usePlaybackKeyboard({
|
|
396
408
|
iframeRef,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { syncStoredAutomationFromPreview } from "./automationStoreSync";
|
|
4
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
|
+
|
|
6
|
+
const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
|
|
7
|
+
const RESTORED =
|
|
8
|
+
'{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1},{"t":4,"v":0}]}]}';
|
|
9
|
+
|
|
10
|
+
const el = (over: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
11
|
+
id: "bgm",
|
|
12
|
+
key: "bgm",
|
|
13
|
+
tag: "audio",
|
|
14
|
+
start: 0,
|
|
15
|
+
duration: 10,
|
|
16
|
+
track: 1,
|
|
17
|
+
domId: "bgm",
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** A stand-in preview document carrying the attributes an undo would have written. */
|
|
22
|
+
function previewWith(attrs: Record<string, string>): Document {
|
|
23
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
24
|
+
const audio = doc.createElement("audio");
|
|
25
|
+
audio.id = "bgm";
|
|
26
|
+
for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
|
|
27
|
+
doc.body.append(audio);
|
|
28
|
+
return doc;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
usePlayerStore.getState().reset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("syncStoredAutomationFromPreview", () => {
|
|
36
|
+
it("reads back an envelope an undo restored on the preview", () => {
|
|
37
|
+
// The bug: a soft undo patches the preview document and re-runs the timeline, but
|
|
38
|
+
// the store keeps its own copy of the attributes and that copy is what a lane
|
|
39
|
+
// draws — so an undone delete stayed invisible until the page was reloaded.
|
|
40
|
+
usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
|
|
41
|
+
syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
|
|
42
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads back an undone FX chain too, since a lane's targets come from it", () => {
|
|
46
|
+
const chain = '{"version":1,"nodes":[{"type":"lowpass","id":"n1","params":{}}]}';
|
|
47
|
+
usePlayerStore.setState({ elements: [el()] });
|
|
48
|
+
syncStoredAutomationFromPreview(previewWith({ "data-fx-chain": chain }));
|
|
49
|
+
expect(usePlayerStore.getState().elements[0]?.fxChain).toBe(chain);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("clears an attribute the undo removed", () => {
|
|
53
|
+
usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
|
|
54
|
+
syncStoredAutomationFromPreview(previewWith({}));
|
|
55
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("finds the node by data-hf-id when the dom id does not match", () => {
|
|
59
|
+
// Studio stamps hf-ids; an element discovered under a suffixed dom id still has
|
|
60
|
+
// to resolve, or the sync silently skips it.
|
|
61
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
62
|
+
const audio = doc.createElement("audio");
|
|
63
|
+
audio.setAttribute("data-hf-id", "hf-snao");
|
|
64
|
+
audio.setAttribute("data-automation", RESTORED);
|
|
65
|
+
doc.body.append(audio);
|
|
66
|
+
usePlayerStore.setState({ elements: [el({ domId: "bgm-2", hfId: "hf-snao" })] });
|
|
67
|
+
syncStoredAutomationFromPreview(doc);
|
|
68
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps the same array when nothing changed, so nothing re-renders", () => {
|
|
72
|
+
usePlayerStore.setState({ elements: [el({ automation: RESTORED })] });
|
|
73
|
+
const before = usePlayerStore.getState().elements;
|
|
74
|
+
syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
|
|
75
|
+
expect(usePlayerStore.getState().elements).toBe(before);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does nothing without a preview document", () => {
|
|
79
|
+
usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
|
|
80
|
+
syncStoredAutomationFromPreview(null);
|
|
81
|
+
expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
|
|
82
|
+
});
|
|
83
|
+
});
|