@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.
Files changed (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +3 -5
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +8 -8
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +8 -1
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. 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
+ }