@hyperframes/studio 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6440 -5514
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +184 -7
- package/src/player/components/TimelineFxButton.tsx +171 -23
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-C6m2nHiX.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
2
|
-
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
3
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
-
import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
|
|
5
|
-
|
|
6
|
-
interface IframeWindow extends Window {
|
|
7
|
-
__hf?: { setAudioSolo?: (ids: readonly string[]) => void };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Pushes the studio's "Hear only this" set into the preview runtime whenever
|
|
12
|
-
* it changes. A dedicated push, not a DOM write: solo is session-only and
|
|
13
|
-
* must never touch an attribute (design doc §2.2 / the export-safety
|
|
14
|
-
* guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
|
|
15
|
-
* the way group mute does — see `window.__hf.setAudioSolo`.
|
|
16
|
-
*/
|
|
17
|
-
export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
|
|
18
|
-
const soloed = usePlayerStore((s) => s.soloed);
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
|
|
21
|
-
win?.__hf?.setAudioSolo?.([...soloed]);
|
|
22
|
-
}, [soloed, previewIframeRef]);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** One soloed id's display label — reads the live preview DOM directly (same
|
|
26
|
-
* approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
|
|
27
|
-
* but the document's own element ids. A group carries its label on
|
|
28
|
-
* `data-label`; anything else falls back to the same label rule the
|
|
29
|
-
* timeline itself uses. */
|
|
30
|
-
function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
|
|
31
|
-
const el = doc?.getElementById(id);
|
|
32
|
-
if (!el) return id;
|
|
33
|
-
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
|
34
|
-
return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
|
|
35
|
-
}
|
|
36
|
-
return getTimelineElementDisplayLabel({
|
|
37
|
-
id,
|
|
38
|
-
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
39
|
-
tag: el.tagName,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The transport bar's "Hear only this" banner text — `null` while nothing is
|
|
45
|
-
* soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
|
|
46
|
-
* (design doc §2.2's banner rule); "your export is not affected" is fixed
|
|
47
|
-
* copy the caller owns, this only resolves the variable half.
|
|
48
|
-
*/
|
|
49
|
-
export function useSoloBannerText(previewIframeRef: {
|
|
50
|
-
current: HTMLIFrameElement | null;
|
|
51
|
-
}): string | null {
|
|
52
|
-
const soloed = usePlayerStore((s) => s.soloed);
|
|
53
|
-
return useMemo(() => {
|
|
54
|
-
if (soloed.size === 0) return null;
|
|
55
|
-
if (soloed.size === 1) {
|
|
56
|
-
const doc = previewIframeRef.current?.contentDocument;
|
|
57
|
-
return resolveSoloLabel(doc, [...soloed][0]);
|
|
58
|
-
}
|
|
59
|
-
return `${soloed.size} tracks`;
|
|
60
|
-
}, [soloed, previewIframeRef]);
|
|
61
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
|
|
3
|
-
* or null when the group is idle/unknown — never zero for "not playing yet".
|
|
4
|
-
*
|
|
5
|
-
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
|
|
6
|
-
* cadence (only while playing — see `postGroupLevels` in init.ts), this just
|
|
7
|
-
* throttles the re-render, it does not add its own polling loop.
|
|
8
|
-
*/
|
|
9
|
-
import { useEffect, useRef, useState } from "react";
|
|
10
|
-
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
|
|
11
|
-
|
|
12
|
-
const THROTTLE_MS = 33;
|
|
13
|
-
|
|
14
|
-
export function useGroupLevel(groupId: string): GroupLevelReading | null {
|
|
15
|
-
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
|
|
16
|
-
const [, forceRender] = useState(0);
|
|
17
|
-
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
|
20
|
-
const unsubscribe = groupLevels.subscribe((levels) => {
|
|
21
|
-
latestRef.current = levels.get(groupId) ?? null;
|
|
22
|
-
if (!timerId) {
|
|
23
|
-
timerId = setTimeout(() => {
|
|
24
|
-
timerId = 0;
|
|
25
|
-
forceRender((v) => v + 1);
|
|
26
|
-
}, THROTTLE_MS);
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
return () => {
|
|
30
|
-
unsubscribe();
|
|
31
|
-
if (timerId) clearTimeout(timerId);
|
|
32
|
-
};
|
|
33
|
-
}, [groupId]);
|
|
34
|
-
|
|
35
|
-
return latestRef.current;
|
|
36
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import React, { act } from "react";
|
|
4
|
-
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
|
7
|
-
import { defaultTimelineTheme } from "./timelineTheme";
|
|
8
|
-
import { groupLevels } from "../store/groupLevels";
|
|
9
|
-
|
|
10
|
-
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
-
|
|
12
|
-
let container: HTMLDivElement;
|
|
13
|
-
let root: Root;
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
container = document.createElement("div");
|
|
17
|
-
document.body.appendChild(container);
|
|
18
|
-
root = createRoot(container);
|
|
19
|
-
groupLevels.notify(new Map());
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
act(() => root.unmount());
|
|
24
|
-
container.remove();
|
|
25
|
-
vi.useRealTimers();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
|
|
29
|
-
const onVolumeChange = vi.fn();
|
|
30
|
-
const onVolumeCommit = vi.fn();
|
|
31
|
-
act(() => {
|
|
32
|
-
root.render(
|
|
33
|
-
<TimelineGroupBusStrip
|
|
34
|
-
groupId="vo"
|
|
35
|
-
volume={1}
|
|
36
|
-
memberLabels={["vo-1", "vo-2"]}
|
|
37
|
-
onVolumeChange={onVolumeChange}
|
|
38
|
-
onVolumeCommit={onVolumeCommit}
|
|
39
|
-
theme={defaultTimelineTheme}
|
|
40
|
-
{...overrides}
|
|
41
|
-
/>,
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
return { onVolumeChange, onVolumeCommit };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function slider(): HTMLInputElement {
|
|
48
|
-
return container.querySelector('input[type="range"]') as HTMLInputElement;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
52
|
-
window.HTMLInputElement.prototype,
|
|
53
|
-
"value",
|
|
54
|
-
)?.set;
|
|
55
|
-
|
|
56
|
-
/** React tracks the DOM's own value setter to detect real changes — a plain
|
|
57
|
-
* `input.value = ...` assignment is invisible to it, so onChange never
|
|
58
|
-
* fires. Go through the native setter, same as this codebase's other
|
|
59
|
-
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
|
|
60
|
-
function setSliderValue(input: HTMLInputElement, value: string) {
|
|
61
|
-
nativeInputValueSetter?.call(input, value);
|
|
62
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe("TimelineGroupBusStrip", () => {
|
|
66
|
-
it('renders "Holds …" from the member labels, comma-joined', () => {
|
|
67
|
-
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
|
|
68
|
-
expect(container.textContent).toContain("Holds vo-1, vo-2");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("falls back to a neutral line when a group has no members yet", () => {
|
|
72
|
-
renderStrip({ memberLabels: [] });
|
|
73
|
-
expect(container.textContent).toContain("Holds nothing yet");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("live-writes on every drag tick, but only commits once on release", () => {
|
|
77
|
-
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
|
|
78
|
-
const input = slider();
|
|
79
|
-
|
|
80
|
-
act(() => setSliderValue(input, "1.2"));
|
|
81
|
-
act(() => setSliderValue(input, "1.5"));
|
|
82
|
-
expect(onVolumeChange).toHaveBeenCalledTimes(2);
|
|
83
|
-
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
|
|
84
|
-
expect(onVolumeCommit).not.toHaveBeenCalled();
|
|
85
|
-
|
|
86
|
-
act(() => {
|
|
87
|
-
input.value = "1.5";
|
|
88
|
-
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
|
89
|
-
});
|
|
90
|
-
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
|
|
91
|
-
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("clamps the volume slider to the 0..2 range", () => {
|
|
95
|
-
const { onVolumeChange } = renderStrip();
|
|
96
|
-
const input = slider();
|
|
97
|
-
expect(input.min).toBe("0");
|
|
98
|
-
expect(input.max).toBe("2");
|
|
99
|
-
|
|
100
|
-
act(() => setSliderValue(input, "2"));
|
|
101
|
-
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
|
|
105
|
-
vi.useFakeTimers();
|
|
106
|
-
renderStrip();
|
|
107
|
-
act(() => {
|
|
108
|
-
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
|
|
109
|
-
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
|
|
110
|
-
});
|
|
111
|
-
expect(container.textContent).not.toContain("Too loud");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
|
|
115
|
-
vi.useFakeTimers();
|
|
116
|
-
renderStrip();
|
|
117
|
-
|
|
118
|
-
act(() => {
|
|
119
|
-
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
|
120
|
-
vi.advanceTimersByTime(40);
|
|
121
|
-
});
|
|
122
|
-
expect(container.textContent).toContain("Too loud");
|
|
123
|
-
|
|
124
|
-
// Clipping stops — the warning must still hold for a couple seconds.
|
|
125
|
-
act(() => {
|
|
126
|
-
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
|
|
127
|
-
vi.advanceTimersByTime(40);
|
|
128
|
-
});
|
|
129
|
-
expect(container.textContent).toContain("Too loud");
|
|
130
|
-
|
|
131
|
-
act(() => {
|
|
132
|
-
vi.advanceTimersByTime(2001);
|
|
133
|
-
});
|
|
134
|
-
expect(container.textContent).not.toContain("Too loud");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
|
|
138
|
-
vi.useFakeTimers();
|
|
139
|
-
renderStrip({ volume: 1.5 });
|
|
140
|
-
act(() => {
|
|
141
|
-
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
|
142
|
-
vi.advanceTimersByTime(40);
|
|
143
|
-
});
|
|
144
|
-
expect(container.textContent ?? "").not.toMatch(/dB/i);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* B7: the group's own volume slider + a living level bar + "Holds …" — the
|
|
3
|
-
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
|
|
4
|
-
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
|
|
5
|
-
* the sound, and the words "Too loud" when it clips.
|
|
6
|
-
*/
|
|
7
|
-
import { useEffect, useRef, useState } from "react";
|
|
8
|
-
import { useGroupLevel } from "../../hooks/useGroupLevel";
|
|
9
|
-
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
|
10
|
-
import type { TimelineTheme } from "./timelineTheme";
|
|
11
|
-
|
|
12
|
-
/** How long "Too loud" stays lit after the last clipped block. */
|
|
13
|
-
const CLIP_HOLD_MS = 2000;
|
|
14
|
-
|
|
15
|
-
function clampVolume(value: number): number {
|
|
16
|
-
return Math.min(2, Math.max(0, value));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface TimelineGroupBusStripProps {
|
|
20
|
-
groupId: string;
|
|
21
|
-
volume: number;
|
|
22
|
-
memberLabels: readonly string[];
|
|
23
|
-
onVolumeChange: (value: number) => void;
|
|
24
|
-
onVolumeCommit: (value: number) => void;
|
|
25
|
-
theme: TimelineTheme;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function TimelineGroupBusStrip({
|
|
29
|
-
groupId,
|
|
30
|
-
volume,
|
|
31
|
-
memberLabels,
|
|
32
|
-
onVolumeChange,
|
|
33
|
-
onVolumeCommit,
|
|
34
|
-
theme,
|
|
35
|
-
}: TimelineGroupBusStripProps) {
|
|
36
|
-
const [dragValue, setDragValue] = useState<number | null>(null);
|
|
37
|
-
const reading = useGroupLevel(groupId);
|
|
38
|
-
const [clipped, setClipped] = useState(false);
|
|
39
|
-
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
if (!reading?.clipped) return;
|
|
43
|
-
setClipped(true);
|
|
44
|
-
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
|
45
|
-
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
|
|
46
|
-
}, [reading?.clipped]);
|
|
47
|
-
|
|
48
|
-
useEffect(
|
|
49
|
-
() => () => {
|
|
50
|
-
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
|
51
|
-
},
|
|
52
|
-
[],
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const shownVolume = dragValue ?? volume;
|
|
56
|
-
const level = Math.min(1, reading?.level ?? 0);
|
|
57
|
-
const holdsText =
|
|
58
|
-
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<div
|
|
62
|
-
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
|
|
63
|
-
style={{ top: TRACK_H, height: STRIP_H }}
|
|
64
|
-
>
|
|
65
|
-
<input
|
|
66
|
-
type="range"
|
|
67
|
-
aria-label="Group volume"
|
|
68
|
-
min={0}
|
|
69
|
-
max={2}
|
|
70
|
-
step={0.01}
|
|
71
|
-
value={shownVolume}
|
|
72
|
-
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
|
|
73
|
-
onChange={(event) => {
|
|
74
|
-
const next = clampVolume(Number(event.currentTarget.value));
|
|
75
|
-
setDragValue(next);
|
|
76
|
-
onVolumeChange(next);
|
|
77
|
-
}}
|
|
78
|
-
onPointerUp={(event) => {
|
|
79
|
-
const next = clampVolume(Number(event.currentTarget.value));
|
|
80
|
-
setDragValue(null);
|
|
81
|
-
onVolumeCommit(next);
|
|
82
|
-
}}
|
|
83
|
-
/>
|
|
84
|
-
<div
|
|
85
|
-
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
|
86
|
-
style={{ background: theme.gutterBorder }}
|
|
87
|
-
aria-hidden="true"
|
|
88
|
-
>
|
|
89
|
-
<div
|
|
90
|
-
className="absolute inset-y-0 left-0 rounded-full"
|
|
91
|
-
style={{
|
|
92
|
-
width: `${level * 100}%`,
|
|
93
|
-
background: clipped ? "#ff5c5c" : "#3CE6AC",
|
|
94
|
-
}}
|
|
95
|
-
/>
|
|
96
|
-
</div>
|
|
97
|
-
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
|
98
|
-
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
|
99
|
-
{holdsText}
|
|
100
|
-
</span>
|
|
101
|
-
</div>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Hear only this" — the `⌗` toggle beside a track's mute control. Session
|
|
3
|
-
* state only (see `audioSoloSlice`): a plain click is exclusive, ⌘/Ctrl-click
|
|
4
|
-
* toggles membership without disturbing the rest of the set.
|
|
5
|
-
*/
|
|
6
|
-
export function TimelineSoloButton({
|
|
7
|
-
isSoloed,
|
|
8
|
-
onToggle,
|
|
9
|
-
}: {
|
|
10
|
-
isSoloed: boolean;
|
|
11
|
-
onToggle: (options?: { add?: boolean }) => void;
|
|
12
|
-
}) {
|
|
13
|
-
return (
|
|
14
|
-
<button
|
|
15
|
-
type="button"
|
|
16
|
-
tabIndex={-1}
|
|
17
|
-
aria-pressed={isSoloed}
|
|
18
|
-
aria-label="Hear only this"
|
|
19
|
-
title="Hear only this"
|
|
20
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
21
|
-
isSoloed ? "text-[#F5C542] hover:text-white" : "text-white/35 hover:text-white/75"
|
|
22
|
-
}`}
|
|
23
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
24
|
-
onClick={(event) => {
|
|
25
|
-
event.stopPropagation();
|
|
26
|
-
onToggle({ add: event.metaKey || event.ctrlKey });
|
|
27
|
-
}}
|
|
28
|
-
>
|
|
29
|
-
<span aria-hidden="true">⌗</span>
|
|
30
|
-
</button>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { usePlayerStore } from "./playerStore";
|
|
5
|
-
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
|
|
6
|
-
import * as studioFileHistory from "../../utils/studioFileHistory";
|
|
7
|
-
|
|
8
|
-
describe("audioSoloSlice", () => {
|
|
9
|
-
it("is exclusive by default, and clicking the only soloed element again clears it", () => {
|
|
10
|
-
usePlayerStore.getState().toggleSolo("a");
|
|
11
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
|
|
12
|
-
usePlayerStore.getState().toggleSolo("b");
|
|
13
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
|
14
|
-
usePlayerStore.getState().toggleSolo("b");
|
|
15
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
|
|
19
|
-
usePlayerStore.getState().toggleSolo("a");
|
|
20
|
-
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
21
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
|
|
22
|
-
usePlayerStore.getState().toggleSolo("a", { add: true });
|
|
23
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("clearSolo empties the set", () => {
|
|
27
|
-
usePlayerStore.getState().toggleSolo("a");
|
|
28
|
-
usePlayerStore.getState().clearSolo();
|
|
29
|
-
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("export-safety: solo never touches an attribute or the save path", () => {
|
|
34
|
-
it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
|
|
35
|
-
usePlayerStore.getState().clearSolo();
|
|
36
|
-
const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
|
|
37
|
-
const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
|
|
38
|
-
|
|
39
|
-
usePlayerStore.getState().toggleSolo("a");
|
|
40
|
-
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
41
|
-
usePlayerStore.getState().toggleSolo("a", { add: true });
|
|
42
|
-
usePlayerStore.getState().clearSolo();
|
|
43
|
-
|
|
44
|
-
expect(setAttributeSpy).not.toHaveBeenCalled();
|
|
45
|
-
expect(removeAttributeSpy).not.toHaveBeenCalled();
|
|
46
|
-
setAttributeSpy.mockRestore();
|
|
47
|
-
removeAttributeSpy.mockRestore();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
|
|
51
|
-
usePlayerStore.getState().clearSolo();
|
|
52
|
-
const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
|
|
53
|
-
|
|
54
|
-
usePlayerStore.getState().toggleSolo("a");
|
|
55
|
-
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
56
|
-
usePlayerStore.getState().clearSolo();
|
|
57
|
-
|
|
58
|
-
expect(saveSpy).not.toHaveBeenCalled();
|
|
59
|
-
saveSpy.mockRestore();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("isAudibleUnderSolo — the three-bullet rule", () => {
|
|
64
|
-
it("everything is audible when no solo is active", () => {
|
|
65
|
-
expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
|
|
66
|
-
expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("a soloed element is audible", () => {
|
|
70
|
-
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("a sibling of a soloed element is silent", () => {
|
|
74
|
-
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("a member of a soloed group is audible (group solo = members solo)", () => {
|
|
78
|
-
expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
|
|
82
|
-
expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
|
|
86
|
-
// clip-a is soloed directly; clip-b shares its group but is not itself
|
|
87
|
-
// soloed and the group itself is not soloed — clip-b stays silent.
|
|
88
|
-
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
|
|
92
|
-
expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
|
|
93
|
-
expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("isGroupHalfLitUnderSolo", () => {
|
|
98
|
-
it("is false when no solo is active", () => {
|
|
99
|
-
expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("is false when the group itself is soloed (fully lit, not half)", () => {
|
|
103
|
-
expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("is true when a member is soloed but the group is not", () => {
|
|
107
|
-
expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("is false when an unrelated element is soloed", () => {
|
|
111
|
-
expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Hear only this" — session-only monitoring override, never persisted.
|
|
3
|
-
*
|
|
4
|
-
* Holds clip ids and group ids (never track numbers, per the design doc: solo
|
|
5
|
-
* is a per-element/per-group concept, not a row-position one). Exclusive by
|
|
6
|
-
* default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
|
|
7
|
-
* member without disturbing the rest. Never written to any attribute or
|
|
8
|
-
* document — the export-safety guarantee this slice exists to hold.
|
|
9
|
-
*/
|
|
10
|
-
import type { StoreApi } from "zustand";
|
|
11
|
-
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
|
|
12
|
-
|
|
13
|
-
export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
|
|
14
|
-
|
|
15
|
-
export interface AudioSoloSlice {
|
|
16
|
-
soloed: ReadonlySet<string>;
|
|
17
|
-
toggleSolo: (id: string, options?: { add?: boolean }) => void;
|
|
18
|
-
clearSolo: () => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function createAudioSoloSlice(
|
|
22
|
-
set: StoreApi<AudioSoloSlice>["setState"],
|
|
23
|
-
get: StoreApi<AudioSoloSlice>["getState"],
|
|
24
|
-
): AudioSoloSlice {
|
|
25
|
-
return {
|
|
26
|
-
soloed: new Set(),
|
|
27
|
-
toggleSolo: (id, options) => {
|
|
28
|
-
const current = get().soloed;
|
|
29
|
-
if (options?.add) {
|
|
30
|
-
const next = new Set(current);
|
|
31
|
-
if (next.has(id)) next.delete(id);
|
|
32
|
-
else next.add(id);
|
|
33
|
-
set({ soloed: next });
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
// Exclusive click: soloing the only-soloed element again clears it;
|
|
37
|
-
// otherwise it replaces the set.
|
|
38
|
-
const isOnlyMember = current.size === 1 && current.has(id);
|
|
39
|
-
set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
|
|
40
|
-
},
|
|
41
|
-
clearSolo: () => set({ soloed: new Set() }),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
|
|
2
|
-
/** A group's live meter reading, or absent when idle/unknown (never zero). */
|
|
3
|
-
export type GroupLevelReading = { level: number; clipped: boolean };
|
|
4
|
-
|
|
5
|
-
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
|
|
6
|
-
const listeners = new Set<Listener>();
|
|
7
|
-
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
|
|
8
|
-
|
|
9
|
-
export const groupLevels = {
|
|
10
|
-
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
|
|
11
|
-
latest = levels;
|
|
12
|
-
listeners.forEach((listener) => listener(levels));
|
|
13
|
-
},
|
|
14
|
-
subscribe: (listener: Listener) => {
|
|
15
|
-
listeners.add(listener);
|
|
16
|
-
return () => listeners.delete(listener);
|
|
17
|
-
},
|
|
18
|
-
get: () => latest,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
|
|
22
|
-
export function parseGroupLevelsMessage(
|
|
23
|
-
data: unknown,
|
|
24
|
-
): ReadonlyMap<string, GroupLevelReading> | null {
|
|
25
|
-
const levels = (data as { levels?: unknown } | null)?.levels;
|
|
26
|
-
if (!Array.isArray(levels)) return null;
|
|
27
|
-
return new Map(
|
|
28
|
-
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
|
|
29
|
-
entry.groupId,
|
|
30
|
-
{ level: entry.level, clipped: entry.clipped },
|
|
31
|
-
]),
|
|
32
|
-
);
|
|
33
|
-
}
|
|
File without changes
|
|
File without changes
|