@hyperframes/studio 0.8.6 → 0.8.8
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-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FX entry point from the timeline (C1) — a track or group header button
|
|
3
|
+
* that opens the same preset shelf the property panel's rack uses, without
|
|
4
|
+
* requiring a trip through the panel first.
|
|
5
|
+
*
|
|
6
|
+
* Rendered on group rows and on track rows holding exactly one audio clip
|
|
7
|
+
* (see `plans/audio-mixer-groups.md` §1.6 and the execution runbook's C1 step
|
|
8
|
+
* — a track with several ungrouped clips has no single chain to point at, so
|
|
9
|
+
* it gets the grouping pointer instead of the popover).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useRef, useState } from "react";
|
|
13
|
+
import { createPortal } from "react-dom";
|
|
14
|
+
import {
|
|
15
|
+
enabledAudioFxNodes,
|
|
16
|
+
parseAudioFxChain,
|
|
17
|
+
type HfAudioFxChain,
|
|
18
|
+
} from "@hyperframes/core/audio-fx";
|
|
19
|
+
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
|
20
|
+
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
|
|
21
|
+
|
|
22
|
+
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
|
|
23
|
+
if (!raw) return { version: 1, nodes: [] };
|
|
24
|
+
try {
|
|
25
|
+
return parseAudioFxChain(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
return { version: 1, nodes: [] };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TimelineFxButtonChainProps {
|
|
32
|
+
variant?: "chain";
|
|
33
|
+
trackKind?: HfAudioNameKind;
|
|
34
|
+
onOpenRack: () => void;
|
|
35
|
+
fxChainRaw: string | undefined;
|
|
36
|
+
onChainChange: (next: HfAudioFxChain) => void;
|
|
37
|
+
onChainPreview?: (next: HfAudioFxChain) => void;
|
|
38
|
+
onAuditionTransport?: (on: boolean) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TimelineFxButtonGroupPointerProps {
|
|
42
|
+
variant: "group-pointer";
|
|
43
|
+
onGroupClips: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
|
|
47
|
+
|
|
48
|
+
export function TimelineFxButton(props: TimelineFxButtonProps) {
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
51
|
+
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
52
|
+
|
|
53
|
+
const openAt = () => {
|
|
54
|
+
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
|
55
|
+
setOpen(true);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (props.variant === "group-pointer") {
|
|
59
|
+
return (
|
|
60
|
+
<div className="relative shrink-0">
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
tabIndex={-1}
|
|
64
|
+
ref={buttonRef}
|
|
65
|
+
aria-label="Effects — group these clips first"
|
|
66
|
+
title="Group these clips to add effects to all of them"
|
|
67
|
+
className="flex h-6 items-center justify-center rounded border-0 bg-transparent px-1 text-[10px] font-semibold text-white/35 hover:text-white/75"
|
|
68
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
69
|
+
onClick={(event) => {
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
openAt();
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
FX
|
|
75
|
+
</button>
|
|
76
|
+
{open &&
|
|
77
|
+
anchorRect &&
|
|
78
|
+
createPortal(
|
|
79
|
+
<div
|
|
80
|
+
role="dialog"
|
|
81
|
+
aria-label="Group these clips to add effects"
|
|
82
|
+
className="z-50 w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
|
|
83
|
+
style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
|
|
84
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
85
|
+
>
|
|
86
|
+
<p>Group these clips to add effects to all of them.</p>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
|
|
90
|
+
onClick={() => {
|
|
91
|
+
setOpen(false);
|
|
92
|
+
props.onGroupClips();
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
Group
|
|
96
|
+
</button>
|
|
97
|
+
</div>,
|
|
98
|
+
document.body,
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const chain = parseFxChainOrEmpty(props.fxChainRaw);
|
|
105
|
+
const nodeCount = enabledAudioFxNodes(chain).length;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="relative shrink-0">
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
tabIndex={-1}
|
|
112
|
+
ref={buttonRef}
|
|
113
|
+
aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
|
|
114
|
+
title="Effects"
|
|
115
|
+
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
|
|
116
|
+
open || nodeCount > 0 ? "text-[#3CE6AC]" : "text-white/35 hover:text-white/75"
|
|
117
|
+
}`}
|
|
118
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
119
|
+
onClick={(event) => {
|
|
120
|
+
event.stopPropagation();
|
|
121
|
+
openAt();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
FX{nodeCount > 0 ? ` ${nodeCount}` : ""}
|
|
125
|
+
</button>
|
|
126
|
+
{open &&
|
|
127
|
+
anchorRect &&
|
|
128
|
+
createPortal(
|
|
129
|
+
<TimelineFxPopover
|
|
130
|
+
anchorRect={anchorRect}
|
|
131
|
+
chain={chain}
|
|
132
|
+
trackKind={props.trackKind}
|
|
133
|
+
onClose={() => setOpen(false)}
|
|
134
|
+
onChainChange={props.onChainChange}
|
|
135
|
+
onChainPreview={props.onChainPreview}
|
|
136
|
+
onAuditionTransport={props.onAuditionTransport}
|
|
137
|
+
onOpenRack={props.onOpenRack}
|
|
138
|
+
/>,
|
|
139
|
+
document.body,
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
|
2
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
3
|
+
import { TRACK_H } from "./timelineLayout";
|
|
4
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
5
|
+
import { TimelineFxButton } from "./TimelineFxButton";
|
|
6
|
+
|
|
7
|
+
interface TimelineGroupHeaderProps {
|
|
8
|
+
label: string;
|
|
9
|
+
memberCount: number;
|
|
10
|
+
/** Caret: shows/hides the member rows beneath this group (structural). */
|
|
11
|
+
isExpanded: boolean;
|
|
12
|
+
onToggleExpanded: () => void;
|
|
13
|
+
/** `∿`: shows/hides the group's own automation-lane rows. */
|
|
14
|
+
laneCount: number;
|
|
15
|
+
isLaneOpen: boolean;
|
|
16
|
+
onToggleLanes: () => void;
|
|
17
|
+
/** The group element's own `data-hidden` — mutes every member at once. */
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
onToggleHidden: () => void;
|
|
20
|
+
/** This group id is itself in the soloed set (fully lit). */
|
|
21
|
+
isSoloed: boolean;
|
|
22
|
+
/** Not soloed itself, but at least one member is (half-lit). */
|
|
23
|
+
isHalfLitSolo: boolean;
|
|
24
|
+
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
|
25
|
+
onToggleSolo: (options?: { add?: boolean }) => void;
|
|
26
|
+
/** C1: the group's serialized `data-fx-chain`, when set. */
|
|
27
|
+
fxChain?: string;
|
|
28
|
+
onFxChainChange: (next: HfAudioFxChain) => void;
|
|
29
|
+
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
|
30
|
+
onFxAuditionTransport?: (on: boolean) => void;
|
|
31
|
+
onOpenFxRack: () => void;
|
|
32
|
+
columnWidth: number;
|
|
33
|
+
theme: TimelineTheme;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
|
38
|
+
* mute + solo + FX + `∿ n` (lane disclosure).
|
|
39
|
+
*/
|
|
40
|
+
export function TimelineGroupHeader({
|
|
41
|
+
label,
|
|
42
|
+
memberCount,
|
|
43
|
+
isExpanded,
|
|
44
|
+
onToggleExpanded,
|
|
45
|
+
laneCount,
|
|
46
|
+
isLaneOpen,
|
|
47
|
+
onToggleLanes,
|
|
48
|
+
hidden,
|
|
49
|
+
onToggleHidden,
|
|
50
|
+
isSoloed,
|
|
51
|
+
isHalfLitSolo,
|
|
52
|
+
onToggleSolo,
|
|
53
|
+
fxChain,
|
|
54
|
+
onFxChainChange,
|
|
55
|
+
onFxChainPreview,
|
|
56
|
+
onFxAuditionTransport,
|
|
57
|
+
onOpenFxRack,
|
|
58
|
+
columnWidth,
|
|
59
|
+
theme,
|
|
60
|
+
}: TimelineGroupHeaderProps) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
role="rowheader"
|
|
64
|
+
aria-colindex={1}
|
|
65
|
+
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
|
|
66
|
+
style={{
|
|
67
|
+
width: columnWidth,
|
|
68
|
+
height: TRACK_H,
|
|
69
|
+
color: "#ffffff",
|
|
70
|
+
background: theme.gutterBackground,
|
|
71
|
+
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
tabIndex={-1}
|
|
77
|
+
aria-expanded={isExpanded}
|
|
78
|
+
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
|
|
79
|
+
title={`${isExpanded ? "Hide" : "Show"} tracks`}
|
|
80
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
81
|
+
isExpanded ? "text-white" : "text-white/55 hover:text-white"
|
|
82
|
+
}`}
|
|
83
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
84
|
+
onClick={(event) => {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
onToggleExpanded();
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
|
|
90
|
+
▸
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
94
|
+
▤
|
|
95
|
+
</span>
|
|
96
|
+
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
|
|
97
|
+
{label}
|
|
98
|
+
</span>
|
|
99
|
+
<span
|
|
100
|
+
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
title={`${memberCount} tracks`}
|
|
103
|
+
>
|
|
104
|
+
{memberCount}
|
|
105
|
+
</span>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
tabIndex={-1}
|
|
109
|
+
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
|
|
110
|
+
title={hidden ? "Unmute group" : `Mute group ${label}`}
|
|
111
|
+
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
|
|
112
|
+
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
|
|
113
|
+
}`}
|
|
114
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
115
|
+
onClick={(event) => {
|
|
116
|
+
event.stopPropagation();
|
|
117
|
+
onToggleHidden();
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{hidden ? (
|
|
121
|
+
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
|
|
122
|
+
) : (
|
|
123
|
+
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
|
|
124
|
+
)}
|
|
125
|
+
</button>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
tabIndex={-1}
|
|
129
|
+
aria-pressed={isSoloed}
|
|
130
|
+
aria-label="Hear only this"
|
|
131
|
+
title="Hear only this"
|
|
132
|
+
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] ${
|
|
133
|
+
isSoloed
|
|
134
|
+
? "text-[#F5C542] hover:text-white"
|
|
135
|
+
: isHalfLitSolo
|
|
136
|
+
? "text-[#F5C542]/50 hover:text-white"
|
|
137
|
+
: "text-white/35 hover:text-white/75"
|
|
138
|
+
}`}
|
|
139
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
140
|
+
onClick={(event) => {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
onToggleSolo({ add: event.metaKey || event.ctrlKey });
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<span aria-hidden="true">⌗</span>
|
|
146
|
+
</button>
|
|
147
|
+
<TimelineFxButton
|
|
148
|
+
fxChainRaw={fxChain}
|
|
149
|
+
onChainChange={onFxChainChange}
|
|
150
|
+
onChainPreview={onFxChainPreview}
|
|
151
|
+
onAuditionTransport={onFxAuditionTransport}
|
|
152
|
+
onOpenRack={onOpenFxRack}
|
|
153
|
+
/>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
tabIndex={-1}
|
|
157
|
+
aria-expanded={isLaneOpen}
|
|
158
|
+
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
|
|
159
|
+
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
|
|
160
|
+
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
161
|
+
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
162
|
+
}`}
|
|
163
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
164
|
+
onClick={(event) => {
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
onToggleLanes();
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<span aria-hidden="true">∿</span>
|
|
170
|
+
{laneCount > 0 && (
|
|
171
|
+
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|