@hyperframes/studio 0.8.59 → 0.8.60
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-BBub3ZdI.js → hyperframes-player-Di7GyCOP.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-Dpq2lkOm.js} +111 -111
- package/dist/assets/{index-BxZ5Cw5w.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.html +2 -2
- package/dist/index.js +4439 -4118
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +105 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- package/src/utils/studioUiPreferences.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.60",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/core": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.60",
|
|
55
|
+
"@hyperframes/player": "0.8.60",
|
|
56
|
+
"@hyperframes/sdk": "0.8.60",
|
|
57
|
+
"@hyperframes/parsers": "0.8.60",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.60"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.60"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -5,6 +5,7 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
|
5
5
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
6
|
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
|
7
7
|
import { Dock } from "./dock/Dock";
|
|
8
|
+
import { InspectorIcon } from "./icons/InspectorIcon";
|
|
8
9
|
|
|
9
10
|
export interface StudioHeaderProps {
|
|
10
11
|
captureFrameHref: string;
|
|
@@ -247,19 +248,7 @@ export function StudioHeader({
|
|
|
247
248
|
"h-full rounded-none",
|
|
248
249
|
inspectorButtonActive && "bg-hover text-accent enabled:hover:text-accent",
|
|
249
250
|
)}
|
|
250
|
-
icon={
|
|
251
|
-
<svg
|
|
252
|
-
width="12"
|
|
253
|
-
height="12"
|
|
254
|
-
viewBox="0 0 24 24"
|
|
255
|
-
fill="none"
|
|
256
|
-
stroke="currentColor"
|
|
257
|
-
strokeWidth="2"
|
|
258
|
-
>
|
|
259
|
-
<circle cx="12" cy="12" r="10" />
|
|
260
|
-
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
|
261
|
-
</svg>
|
|
262
|
-
}
|
|
251
|
+
icon={<InspectorIcon size={16} />}
|
|
263
252
|
onClick={() => {
|
|
264
253
|
if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
|
|
265
254
|
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
|
@@ -6,10 +6,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
9
|
+
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
10
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
11
|
+
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
9
12
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
10
13
|
|
|
11
14
|
vi.mock("../contexts/StudioContext", () => ({
|
|
12
15
|
useStudioShellContext: () => ({
|
|
16
|
+
previewIframeRef: { current: null },
|
|
13
17
|
editHistory: { canUndo: false, canRedo: false },
|
|
14
18
|
handleUndo: vi.fn(),
|
|
15
19
|
handleRedo: vi.fn(),
|
|
@@ -172,3 +176,41 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
|
|
|
172
176
|
act(() => root.unmount());
|
|
173
177
|
});
|
|
174
178
|
});
|
|
179
|
+
|
|
180
|
+
describe("TimelineToolbar audio meters", () => {
|
|
181
|
+
it("keeps fresh preferences hidden until the user opts in and persists the choice", () => {
|
|
182
|
+
localStorage.clear();
|
|
183
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
184
|
+
usePlayerStore.setState({
|
|
185
|
+
elements: [{ id: "music", key: "music", tag: "audio", start: 0, duration: 10, track: 1 }],
|
|
186
|
+
});
|
|
187
|
+
const host = document.createElement("div");
|
|
188
|
+
document.body.append(host);
|
|
189
|
+
const root = createRoot(host);
|
|
190
|
+
try {
|
|
191
|
+
act(() =>
|
|
192
|
+
root.render(
|
|
193
|
+
<>
|
|
194
|
+
<TimelineToolbar />
|
|
195
|
+
<AudioMeterStrip />
|
|
196
|
+
</>,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
const button = host.querySelector<HTMLButtonElement>(
|
|
200
|
+
'button[aria-label="Toggle audio meters"]',
|
|
201
|
+
);
|
|
202
|
+
expect(readStudioUiPreferences().audioMetersVisible).toBeUndefined();
|
|
203
|
+
expect(button?.getAttribute("aria-pressed")).toBe("false");
|
|
204
|
+
expect(host.querySelector('[data-testid="audio-meter-strip"]')).toBeNull();
|
|
205
|
+
if (!button) throw new Error("audio meter toggle not rendered");
|
|
206
|
+
act(() => button.click());
|
|
207
|
+
expect(button.getAttribute("aria-pressed")).toBe("true");
|
|
208
|
+
expect(host.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
|
|
209
|
+
expect(readStudioUiPreferences().audioMetersVisible).toBe(true);
|
|
210
|
+
} finally {
|
|
211
|
+
act(() => root.unmount());
|
|
212
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
213
|
+
localStorage.clear();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
3
|
-
Image,
|
|
4
|
-
Magnet,
|
|
5
|
-
MagnifyingGlassMinus,
|
|
6
|
-
MagnifyingGlassPlus,
|
|
7
|
-
SpeakerHigh,
|
|
8
|
-
Waves,
|
|
9
|
-
} from "@phosphor-icons/react";
|
|
2
|
+
import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
|
|
10
3
|
import {
|
|
11
4
|
useEnableKeyframes,
|
|
12
5
|
isPlayheadWithinTween,
|
|
@@ -23,6 +16,8 @@ import {
|
|
|
23
16
|
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
|
24
17
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
25
18
|
import { Tooltip } from "./ui";
|
|
19
|
+
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
20
|
+
import { RippleEditIcon } from "./icons/RippleEditIcon";
|
|
26
21
|
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
|
|
27
22
|
import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
|
|
28
23
|
import { Scissors } from "../icons/SystemIcons";
|
|
@@ -252,7 +247,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
252
247
|
aria-pressed={rippleEditEnabled}
|
|
253
248
|
className={rippleEditEnabled ? flatActive : flatIdle}
|
|
254
249
|
>
|
|
255
|
-
<
|
|
250
|
+
<RippleEditIcon size={16} />
|
|
256
251
|
</button>
|
|
257
252
|
</Tooltip>
|
|
258
253
|
{projectHasAudio && (
|
|
@@ -264,7 +259,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
|
|
|
264
259
|
aria-pressed={audioMetersVisible}
|
|
265
260
|
className={audioMetersVisible ? flatActive : flatIdle}
|
|
266
261
|
>
|
|
267
|
-
<
|
|
262
|
+
<AudioMetersIcon size={16} />
|
|
268
263
|
</button>
|
|
269
264
|
</Tooltip>
|
|
270
265
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { useMemo, useRef, useState } from "react";
|
|
3
|
-
import { Plus,
|
|
3
|
+
import { Plus, X } from "../../icons/SystemIcons";
|
|
4
4
|
import {
|
|
5
5
|
buildDefaultGradientModel,
|
|
6
6
|
insertGradientStop,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
serializeGradient,
|
|
9
9
|
type GradientModel,
|
|
10
10
|
} from "./gradientValue";
|
|
11
|
+
import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
|
|
11
12
|
import { IMAGE_EXT } from "../../utils/mediaTypes";
|
|
12
13
|
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
13
14
|
import {
|
|
@@ -358,7 +359,7 @@ export function GradientField({
|
|
|
358
359
|
}}
|
|
359
360
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
|
|
360
361
|
>
|
|
361
|
-
<
|
|
362
|
+
<ReverseGradientIcon size={16} />
|
|
362
363
|
Reverse
|
|
363
364
|
</button>
|
|
364
365
|
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function AudioMetersIcon({ size = 16 }: { size?: number | string }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width={size}
|
|
5
|
+
height={size}
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
fill="none"
|
|
8
|
+
stroke="currentColor"
|
|
9
|
+
strokeWidth={1.5}
|
|
10
|
+
strokeLinecap="round"
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
>
|
|
13
|
+
<path d="M2.5 13h2m-2-3h2M7 13h2m-2-3h2m-2-3h2m-2-3h2M11.5 13h2m-2-3h2m-2-3h2" />
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function InspectorIcon({ size = 16 }: { size?: number | string }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width={size}
|
|
5
|
+
height={size}
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
fill="none"
|
|
8
|
+
stroke="currentColor"
|
|
9
|
+
strokeWidth={1.5}
|
|
10
|
+
strokeLinecap="round"
|
|
11
|
+
strokeLinejoin="round"
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
>
|
|
14
|
+
<rect x="1.5" y="2" width="13" height="12" rx="1.5" />
|
|
15
|
+
<path d="M4 6h8M6 4.5v3M4 10h8m-2-1.5v3" />
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function ReverseGradientIcon({ size = 16 }: { size?: number | string }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width={size}
|
|
5
|
+
height={size}
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
fill="none"
|
|
8
|
+
stroke="currentColor"
|
|
9
|
+
strokeWidth={1.5}
|
|
10
|
+
strokeLinecap="round"
|
|
11
|
+
strokeLinejoin="round"
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
>
|
|
14
|
+
<path d="M2 3h12m-2-2 2 2-2 2M14 8H2m2-2L2 8l2 2" />
|
|
15
|
+
<g fill="currentColor" stroke="none">
|
|
16
|
+
<path d="M2 12h3v2H2z" opacity="0.25" />
|
|
17
|
+
<path d="M5 12h3v2H5z" opacity="0.5" />
|
|
18
|
+
<path d="M8 12h3v2H8z" opacity="0.75" />
|
|
19
|
+
<path d="M11 12h3v2h-3z" />
|
|
20
|
+
</g>
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function RippleEditIcon({ size = 16 }: { size?: number | string }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
width={size}
|
|
5
|
+
height={size}
|
|
6
|
+
viewBox="0 0 16 16"
|
|
7
|
+
fill="none"
|
|
8
|
+
stroke="currentColor"
|
|
9
|
+
strokeWidth={1.5}
|
|
10
|
+
strokeLinecap="round"
|
|
11
|
+
strokeLinejoin="round"
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
>
|
|
14
|
+
<rect x="1.5" y="8" width="4" height="5" rx="0.5" />
|
|
15
|
+
<rect x="10.5" y="8" width="4" height="5" rx="0.5" />
|
|
16
|
+
<path d="M13 4H6m2-2L6 4l2 2" />
|
|
17
|
+
</svg>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
// fallow-ignore-file code-duplication
|
|
3
3
|
|
|
4
4
|
import { act } from "react";
|
|
5
|
+
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
|
5
6
|
import { createRoot, type Root } from "react-dom/client";
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
8
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
9
|
import type { TimelineElement } from "../../player/store/timelineElement";
|
|
9
10
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
10
|
-
import {
|
|
11
|
+
import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
12
|
+
import {
|
|
13
|
+
AudioMeterStrip,
|
|
14
|
+
evictGoneMeterState,
|
|
15
|
+
followMeterHook,
|
|
16
|
+
stepAndPaintStrips,
|
|
17
|
+
} from "./AudioMeterStrip";
|
|
11
18
|
|
|
12
19
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
20
|
|
|
@@ -17,6 +24,51 @@ vi.mock("../../contexts/StudioContext", () => ({
|
|
|
17
24
|
useStudioShellContext: () => ({ previewIframeRef }),
|
|
18
25
|
}));
|
|
19
26
|
|
|
27
|
+
const onSetAudioGroupAttributeLive = vi.fn();
|
|
28
|
+
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
29
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
30
|
+
useTimelineEditContextOptional: () => ({
|
|
31
|
+
onSetAudioGroupAttributeLive,
|
|
32
|
+
onSetAudioGroupAttributeQuiet,
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
function stubTrackRect(): () => void {
|
|
37
|
+
const original = Element.prototype.getBoundingClientRect;
|
|
38
|
+
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
|
39
|
+
return {
|
|
40
|
+
left: 0,
|
|
41
|
+
top: 0,
|
|
42
|
+
right: 8,
|
|
43
|
+
bottom: 100,
|
|
44
|
+
width: 8,
|
|
45
|
+
height: 100,
|
|
46
|
+
x: 0,
|
|
47
|
+
y: 0,
|
|
48
|
+
toJSON: () => ({}),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
return () => {
|
|
52
|
+
Element.prototype.getBoundingClientRect = original;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function drag(fader: HTMLElement, clientY: number) {
|
|
57
|
+
const restoreRect = stubTrackRect();
|
|
58
|
+
const original = Element.prototype.setPointerCapture;
|
|
59
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
60
|
+
try {
|
|
61
|
+
for (const type of ["pointerdown", "pointermove", "pointerup"]) {
|
|
62
|
+
act(() =>
|
|
63
|
+
fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
Element.prototype.setPointerCapture = original;
|
|
68
|
+
restoreRect();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
20
72
|
const makeHook = (groups: Record<string, { l: number; r: number }> = {}) => ({
|
|
21
73
|
start: vi.fn(),
|
|
22
74
|
stop: vi.fn(),
|
|
@@ -39,8 +91,10 @@ beforeEach(() => {
|
|
|
39
91
|
frames = [];
|
|
40
92
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
|
41
93
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
42
|
-
usePlayerStore.setState({ elements: [] });
|
|
94
|
+
usePlayerStore.setState({ elements: [], audioVolume: 1 });
|
|
43
95
|
useAudioMetersVisible.setState({ visible: true });
|
|
96
|
+
onSetAudioGroupAttributeLive.mockClear();
|
|
97
|
+
onSetAudioGroupAttributeQuiet.mockClear();
|
|
44
98
|
});
|
|
45
99
|
const roots: Root[] = [];
|
|
46
100
|
afterEach(() => {
|
|
@@ -64,7 +118,7 @@ describe("AudioMeterStrip", () => {
|
|
|
64
118
|
expect(mount().host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
|
|
65
119
|
});
|
|
66
120
|
|
|
67
|
-
it("shows one strip per group plus
|
|
121
|
+
it("shows one strip per group plus Monitor, and hides when toggled off", () => {
|
|
68
122
|
usePlayerStore.setState({
|
|
69
123
|
elements: [
|
|
70
124
|
clip({ id: "1", audioGroup: "music", audioGroupLabel: "Music" }),
|
|
@@ -74,7 +128,8 @@ describe("AudioMeterStrip", () => {
|
|
|
74
128
|
const { host } = mount();
|
|
75
129
|
expect(host.textContent).toContain("Music");
|
|
76
130
|
expect(host.textContent).toContain("vo");
|
|
77
|
-
expect(host.textContent).toContain("
|
|
131
|
+
expect(host.textContent).toContain("Monitor");
|
|
132
|
+
expect(host.textContent).not.toContain("Master");
|
|
78
133
|
act(() => useAudioMetersVisible.getState().setVisible(false));
|
|
79
134
|
expect(host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
|
|
80
135
|
});
|
|
@@ -84,12 +139,12 @@ describe("AudioMeterStrip", () => {
|
|
|
84
139
|
const first = makeHook({ vo: { l: 1, r: 1 } });
|
|
85
140
|
setHook(first);
|
|
86
141
|
const { host, root } = mount();
|
|
87
|
-
const
|
|
88
|
-
expect(
|
|
142
|
+
const mask = host.querySelector<HTMLElement>("[data-testid=meter-mask]")!;
|
|
143
|
+
expect(mask.style.height).toBe("100%");
|
|
89
144
|
tick();
|
|
90
145
|
tick();
|
|
91
146
|
expect(first.start).toHaveBeenCalledTimes(1);
|
|
92
|
-
expect(
|
|
147
|
+
expect(mask.style.height).toBe("0%");
|
|
93
148
|
const peak = host.querySelector<HTMLElement>("[data-testid=meter-peak]")!;
|
|
94
149
|
expect(peak.style.bottom).toBe("100%");
|
|
95
150
|
expect(peak.style.transform).toBe("translateY(1px)");
|
|
@@ -121,4 +176,220 @@ describe("AudioMeterStrip", () => {
|
|
|
121
176
|
tick();
|
|
122
177
|
expect(second.read).toHaveBeenCalled();
|
|
123
178
|
});
|
|
179
|
+
|
|
180
|
+
it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
|
|
181
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
|
|
182
|
+
const { host } = mount();
|
|
183
|
+
const fader = host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
184
|
+
expect(Number(fader.getAttribute("aria-valuenow"))).toBeGreaterThan(0);
|
|
185
|
+
expect(Number(fader.getAttribute("aria-valuenow"))).toBeLessThan(100);
|
|
186
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("+6.0 dB");
|
|
187
|
+
expect(parseFloat(fader.querySelector<HTMLElement>("div")!.style.bottom)).toBeGreaterThan(50);
|
|
188
|
+
drag(fader, 50);
|
|
189
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "1");
|
|
190
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
191
|
+
"vo",
|
|
192
|
+
"data-volume",
|
|
193
|
+
"1",
|
|
194
|
+
"Set volume",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("drags a group to the shared +12 dB ceiling", () => {
|
|
199
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
200
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
201
|
+
drag(fader, 0);
|
|
202
|
+
expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(MAX_AUDIO_GAIN, 6);
|
|
203
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "3.981072");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each([0.5, 1, 2, 3.98])(
|
|
207
|
+
"round-trips authored group gain %s through its displayed thumb",
|
|
208
|
+
(gain) => {
|
|
209
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: gain })] });
|
|
210
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
211
|
+
const thumb = fader.firstElementChild as HTMLElement;
|
|
212
|
+
drag(fader, 100 - parseFloat(thumb.style.bottom));
|
|
213
|
+
expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(gain, 6);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
it("preserves low authored gain instead of rounding it to mute", () => {
|
|
218
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
219
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
220
|
+
drag(fader, 85);
|
|
221
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
222
|
+
"vo",
|
|
223
|
+
"data-volume",
|
|
224
|
+
"0.007943",
|
|
225
|
+
"Set volume",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps the Monitor fader within the player store range", () => {
|
|
230
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
231
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="Monitor volume"]')!;
|
|
232
|
+
expect(fader.getAttribute("aria-valuemin")).toBe("-100");
|
|
233
|
+
expect(fader.getAttribute("aria-valuemax")).toBe("0");
|
|
234
|
+
drag(fader, 100);
|
|
235
|
+
expect(usePlayerStore.getState().audioVolume).toBe(0);
|
|
236
|
+
drag(fader, 0);
|
|
237
|
+
expect(usePlayerStore.getState().audioVolume).toBe(1);
|
|
238
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })));
|
|
239
|
+
expect(usePlayerStore.getState().audioVolume).toBe(1);
|
|
240
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("0.0 dB");
|
|
241
|
+
expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it.each(["pointercancel", "lostpointercapture"])(
|
|
245
|
+
"finishes %s once and ignores subsequent hovering",
|
|
246
|
+
(event) => {
|
|
247
|
+
const restoreRect = stubTrackRect();
|
|
248
|
+
const original = Element.prototype.setPointerCapture;
|
|
249
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
250
|
+
try {
|
|
251
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
252
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
253
|
+
for (const [type, clientY] of [
|
|
254
|
+
["pointerdown", 50],
|
|
255
|
+
[event, 0],
|
|
256
|
+
["pointermove", 0],
|
|
257
|
+
["pointerup", 0],
|
|
258
|
+
] as const) {
|
|
259
|
+
act(() =>
|
|
260
|
+
fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledExactlyOnceWith(
|
|
264
|
+
"vo",
|
|
265
|
+
"data-volume",
|
|
266
|
+
"1",
|
|
267
|
+
);
|
|
268
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
269
|
+
"vo",
|
|
270
|
+
"data-volume",
|
|
271
|
+
"1",
|
|
272
|
+
"Set volume",
|
|
273
|
+
);
|
|
274
|
+
} finally {
|
|
275
|
+
Element.prototype.setPointerCapture = original;
|
|
276
|
+
restoreRect();
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it.each([
|
|
282
|
+
["Home", "0"],
|
|
283
|
+
["End", "3.981072"],
|
|
284
|
+
["PageUp", "1.318257"],
|
|
285
|
+
["PageDown", "0.251189"],
|
|
286
|
+
])("supports the %s slider key", (key, gain) => {
|
|
287
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
288
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
289
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
|
|
290
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
291
|
+
"vo",
|
|
292
|
+
"data-volume",
|
|
293
|
+
gain,
|
|
294
|
+
"Set volume",
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("nudges by an equal visual step and exposes the gain readout", () => {
|
|
299
|
+
usePlayerStore.setState({ elements: [clip({})], audioVolume: 1 });
|
|
300
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="Monitor volume"]')!;
|
|
301
|
+
act(() =>
|
|
302
|
+
fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })),
|
|
303
|
+
);
|
|
304
|
+
expect(fader.getAttribute("aria-valuenow")).toBe("-2");
|
|
305
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("-1.2 dB");
|
|
306
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })));
|
|
307
|
+
expect(usePlayerStore.getState().audioVolume).toBeCloseTo(1, 12);
|
|
308
|
+
expect(fader.getAttribute("aria-valuenow")).toBe("0");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("followMeterHook", () => {
|
|
313
|
+
it("leaves the same hook attached", () => {
|
|
314
|
+
const hook = makeHook();
|
|
315
|
+
expect(followMeterHook(hook, hook)).toBe(hook);
|
|
316
|
+
expect(hook.stop).not.toHaveBeenCalled();
|
|
317
|
+
expect(hook.start).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("stops the old hook and starts the new one", () => {
|
|
321
|
+
const first = makeHook();
|
|
322
|
+
const second = makeHook();
|
|
323
|
+
expect(followMeterHook(first, second)).toBe(second);
|
|
324
|
+
expect(first.stop).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("still attaches the new hook when stop() throws", () => {
|
|
329
|
+
const first = makeHook();
|
|
330
|
+
first.stop.mockImplementation(() => {
|
|
331
|
+
throw new Error("dead realm");
|
|
332
|
+
});
|
|
333
|
+
const second = makeHook();
|
|
334
|
+
expect(followMeterHook(first, second)).toBe(second);
|
|
335
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("returns the live hook even when start() throws", () => {
|
|
339
|
+
const live = makeHook();
|
|
340
|
+
live.start.mockImplementation(() => {
|
|
341
|
+
throw new Error("not ready");
|
|
342
|
+
});
|
|
343
|
+
expect(followMeterHook(null, live)).toBe(live);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("evictGoneMeterState", () => {
|
|
348
|
+
it("drops ids that are no longer in the strip list", () => {
|
|
349
|
+
const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
350
|
+
const state = new Map<string | null, typeof rest>([
|
|
351
|
+
["gone", rest],
|
|
352
|
+
["vo", rest],
|
|
353
|
+
[null, rest],
|
|
354
|
+
]);
|
|
355
|
+
evictGoneMeterState(state, new Set(["vo", null]));
|
|
356
|
+
expect([...state.keys()]).toEqual(["vo", null]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("stepAndPaintStrips", () => {
|
|
361
|
+
it("paints a loud group and skips a silent strip already at rest", () => {
|
|
362
|
+
const loudMask = document.createElement("div");
|
|
363
|
+
const restMask = document.createElement("div");
|
|
364
|
+
restMask.style.height = "50%";
|
|
365
|
+
const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
366
|
+
const state = new Map<string | null, typeof rest>([["rest", rest]]);
|
|
367
|
+
const bars = new Map([
|
|
368
|
+
[
|
|
369
|
+
"loud",
|
|
370
|
+
[
|
|
371
|
+
{ mask: loudMask, peak: null },
|
|
372
|
+
{ mask: null, peak: null },
|
|
373
|
+
] as const,
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
"rest",
|
|
377
|
+
[
|
|
378
|
+
{ mask: restMask, peak: null },
|
|
379
|
+
{ mask: null, peak: null },
|
|
380
|
+
] as const,
|
|
381
|
+
],
|
|
382
|
+
]);
|
|
383
|
+
stepAndPaintStrips(
|
|
384
|
+
[{ id: "loud" }, { id: "rest" }],
|
|
385
|
+
state,
|
|
386
|
+
bars as never,
|
|
387
|
+
{ master: { l: 0, r: 0 }, groups: { loud: { l: 1, r: 1 } } },
|
|
388
|
+
0,
|
|
389
|
+
16,
|
|
390
|
+
);
|
|
391
|
+
expect(loudMask.style.height).toBe("0%");
|
|
392
|
+
expect(restMask.style.height).toBe("50%");
|
|
393
|
+
expect(state.has("loud")).toBe(true);
|
|
394
|
+
});
|
|
124
395
|
});
|