@hyperframes/studio 0.8.6 → 0.8.7
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-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10336 -7195
- 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 +2 -0
- 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 +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- 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 +7 -7
- 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 +5 -0
- 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.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- 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.ts +150 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useId, memo } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
|
|
2
2
|
import { formatTime, frameToSeconds } from "../lib/time";
|
|
3
3
|
import { Tooltip } from "../../components/ui";
|
|
4
4
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
@@ -19,7 +19,7 @@ const SHORTCUT_SECTIONS = [
|
|
|
19
19
|
],
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
title: "Keyframes",
|
|
22
|
+
title: "Keyframes (when an element is selected)",
|
|
23
23
|
hints: [
|
|
24
24
|
{ key: "K", label: "Add keyframe at playhead" },
|
|
25
25
|
{ key: "Del", label: "Delete selected keyframe" },
|
|
@@ -37,9 +37,10 @@ const SHORTCUT_SECTIONS = [
|
|
|
37
37
|
{ key: "⌘V", label: "Paste element" },
|
|
38
38
|
{ key: "⌘X", label: "Cut element" },
|
|
39
39
|
{ key: "S", label: "Split clip at playhead" },
|
|
40
|
+
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
40
41
|
{ key: "⌘G", label: "Group elements" },
|
|
41
42
|
{ key: "⌘⇧G", label: "Ungroup" },
|
|
42
|
-
{ key: "Del", label: "Delete selected element" },
|
|
43
|
+
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
43
44
|
],
|
|
44
45
|
},
|
|
45
46
|
{
|
|
@@ -112,6 +113,20 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
112
113
|
const shortcutsPanelId = useId();
|
|
113
114
|
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
|
|
114
115
|
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
|
|
116
|
+
const panelBodyRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
118
|
+
|
|
119
|
+
// Move focus into the panel on open so keyboard users can scroll and read
|
|
120
|
+
// it; hand focus back to the trigger on close.
|
|
121
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!showShortcuts) return;
|
|
124
|
+
const trigger = triggerRef.current;
|
|
125
|
+
panelBodyRef.current?.focus();
|
|
126
|
+
return () => {
|
|
127
|
+
trigger?.focus();
|
|
128
|
+
};
|
|
129
|
+
}, [showShortcuts]);
|
|
115
130
|
|
|
116
131
|
const commitJumpFrame = useCallback(() => {
|
|
117
132
|
if (disabled) return;
|
|
@@ -141,6 +156,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
141
156
|
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
|
|
142
157
|
<Tooltip label="Shortcuts and tools">
|
|
143
158
|
<button
|
|
159
|
+
ref={triggerRef}
|
|
144
160
|
type="button"
|
|
145
161
|
onClick={() => setShowShortcuts((v) => !v)}
|
|
146
162
|
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
|
@@ -169,11 +185,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
169
185
|
{showShortcuts && (
|
|
170
186
|
<div
|
|
171
187
|
id={shortcutsPanelId}
|
|
188
|
+
ref={panelBodyRef}
|
|
189
|
+
tabIndex={-1}
|
|
172
190
|
role="dialog"
|
|
191
|
+
aria-label="Keyboard shortcuts and tools"
|
|
173
192
|
// Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
|
|
174
193
|
// not trapped and the rest of the editor stays operable, so claiming
|
|
175
194
|
// modality would make assistive tech treat the whole app as inert.
|
|
176
|
-
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
|
|
195
|
+
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-none"
|
|
177
196
|
style={{
|
|
178
197
|
background: "#161618",
|
|
179
198
|
border: "1px solid rgba(255,255,255,0.08)",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback, memo } from "react";
|
|
2
2
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
3
3
|
import { Tooltip } from "../../components/ui";
|
|
4
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
5
|
|
|
5
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
6
7
|
|
|
@@ -16,23 +17,10 @@ export const SpeedMenu = memo(function SpeedMenu({
|
|
|
16
17
|
disabled,
|
|
17
18
|
}: SpeedMenuProps) {
|
|
18
19
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const handleMouseDown = (e: MouseEvent) => {
|
|
24
|
-
if (
|
|
25
|
-
speedMenuContainerRef.current &&
|
|
26
|
-
!speedMenuContainerRef.current.contains(e.target as Node)
|
|
27
|
-
) {
|
|
28
|
-
setShowSpeedMenu(false);
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
document.addEventListener("mousedown", handleMouseDown);
|
|
32
|
-
return () => {
|
|
33
|
-
document.removeEventListener("mousedown", handleMouseDown);
|
|
34
|
-
};
|
|
35
|
-
}, [showSpeedMenu]);
|
|
20
|
+
const closeMenu = useCallback(() => setShowSpeedMenu(false), []);
|
|
21
|
+
// Ref on the container (trigger + menu) so trigger clicks toggle instead of
|
|
22
|
+
// close-then-reopen; Escape also dismisses.
|
|
23
|
+
const speedMenuContainerRef = useContextMenuDismiss(closeMenu);
|
|
36
24
|
|
|
37
25
|
return (
|
|
38
26
|
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
|
@@ -41,6 +29,9 @@ export const SpeedMenu = memo(function SpeedMenu({
|
|
|
41
29
|
type="button"
|
|
42
30
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
43
31
|
disabled={disabled}
|
|
32
|
+
aria-haspopup="menu"
|
|
33
|
+
aria-expanded={showSpeedMenu}
|
|
34
|
+
aria-label="Playback speed"
|
|
44
35
|
className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
|
|
45
36
|
>
|
|
46
37
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
@@ -48,33 +39,34 @@ export const SpeedMenu = memo(function SpeedMenu({
|
|
|
48
39
|
</Tooltip>
|
|
49
40
|
{showSpeedMenu && (
|
|
50
41
|
<div
|
|
42
|
+
role="menu"
|
|
43
|
+
aria-label="Playback speed options"
|
|
51
44
|
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
|
52
45
|
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
|
|
53
46
|
>
|
|
54
|
-
{SPEED_OPTIONS.map((rate) =>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
))}
|
|
47
|
+
{SPEED_OPTIONS.map((rate) => {
|
|
48
|
+
const isCurrent = rate === playbackRate;
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
key={rate}
|
|
52
|
+
type="button"
|
|
53
|
+
role="menuitemradio"
|
|
54
|
+
aria-checked={isCurrent}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
trackStudioEvent("playback", { action: "speed_change", rate });
|
|
57
|
+
setPlaybackRate(rate);
|
|
58
|
+
setShowSpeedMenu(false);
|
|
59
|
+
}}
|
|
60
|
+
className={`block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors outline-none focus-visible:bg-white/[0.04] ${
|
|
61
|
+
isCurrent
|
|
62
|
+
? "text-neutral-50 bg-white/[0.06]"
|
|
63
|
+
: "text-neutral-500 hover:bg-white/[0.04]"
|
|
64
|
+
}`}
|
|
65
|
+
>
|
|
66
|
+
{rate}x
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
78
70
|
</div>
|
|
79
71
|
)}
|
|
80
72
|
</div>
|
|
@@ -556,11 +556,11 @@ describe("Timeline provider boundary", () => {
|
|
|
556
556
|
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
|
|
557
557
|
// lives in the left column. clip-2 has no keyframes so it never shows one.
|
|
558
558
|
const collapseButton = host.querySelector<HTMLButtonElement>(
|
|
559
|
-
'button[aria-label="
|
|
559
|
+
'button[aria-label="Hide clip-1 lanes"]',
|
|
560
560
|
);
|
|
561
561
|
expect(collapseButton).not.toBeNull();
|
|
562
|
-
expect(host.querySelector('button[aria-label="
|
|
563
|
-
expect(host.querySelector('button[aria-label="
|
|
562
|
+
expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull();
|
|
563
|
+
expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();
|
|
564
564
|
|
|
565
565
|
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
|
|
566
566
|
const row = clip?.parentElement?.parentElement;
|
|
@@ -571,7 +571,7 @@ describe("Timeline provider boundary", () => {
|
|
|
571
571
|
expectTrackExpansion(row, [], TRACK_H);
|
|
572
572
|
|
|
573
573
|
const expandButton = host.querySelector<HTMLButtonElement>(
|
|
574
|
-
'button[aria-label="
|
|
574
|
+
'button[aria-label="Show clip-1 lanes"]',
|
|
575
575
|
);
|
|
576
576
|
expect(expandButton).not.toBeNull();
|
|
577
577
|
act(() => expandButton?.click());
|
|
@@ -603,8 +603,8 @@ describe("Timeline provider boundary", () => {
|
|
|
603
603
|
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
|
|
604
604
|
?.parentElement;
|
|
605
605
|
// A row of several clips is named for the track, so the caret is too.
|
|
606
|
-
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$="
|
|
607
|
-
expect(caret()?.getAttribute("aria-label")).toBe("
|
|
606
|
+
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
|
|
607
|
+
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");
|
|
608
608
|
|
|
609
609
|
act(() => caret()?.click());
|
|
610
610
|
// One shared volume row, and BOTH clips hold it open.
|
|
@@ -647,7 +647,7 @@ describe("Timeline provider boundary", () => {
|
|
|
647
647
|
});
|
|
648
648
|
const root = createRoot(host);
|
|
649
649
|
act(() => root.render(React.createElement(Timeline)));
|
|
650
|
-
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$="
|
|
650
|
+
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
|
|
651
651
|
|
|
652
652
|
const before = [...host.querySelectorAll(".hf-automation-lane")];
|
|
653
653
|
expect(before).toHaveLength(2);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, memo } from "react";
|
|
2
|
-
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
|
-
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
|
2
|
+
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
4
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
4
|
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
|
|
6
5
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
@@ -39,7 +38,6 @@ import {
|
|
|
39
38
|
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
|
40
39
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
41
40
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
42
|
-
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
43
41
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
44
42
|
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
|
|
45
43
|
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
@@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
|
|
|
109
107
|
useMusicBeatAnalysis();
|
|
110
108
|
const rawElements = usePlayerStore((s) => s.elements);
|
|
111
109
|
const expandedElements = useExpandedTimelineElements();
|
|
112
|
-
const
|
|
113
|
-
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
|
|
114
|
-
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
115
|
-
const adjustedBeatAnalysis = useMemo(
|
|
116
|
-
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
|
117
|
-
[beatAnalysis, musicElement, beatEdits],
|
|
118
|
-
);
|
|
110
|
+
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
|
|
119
111
|
const duration = usePlayerStore((s) => s.duration);
|
|
120
112
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
121
113
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
@@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
|
|
|
158
150
|
laneCounts,
|
|
159
151
|
rowGeometry,
|
|
160
152
|
rowGeometryRef,
|
|
153
|
+
groups,
|
|
154
|
+
trackGroupOf,
|
|
161
155
|
} = useTimelineTrackLayout(
|
|
162
156
|
expandedElements,
|
|
163
157
|
gsapAnimations,
|
|
@@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
|
|
|
289
283
|
laneCounts,
|
|
290
284
|
selectedElementId,
|
|
291
285
|
selectedElementIds,
|
|
286
|
+
groups,
|
|
287
|
+
trackGroupOf,
|
|
292
288
|
gsapAnimations,
|
|
293
289
|
elements: expandedElements,
|
|
294
290
|
pixelsPerSecond: pps,
|
|
@@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
|
|
|
509
505
|
trackOrder={trackOrder}
|
|
510
506
|
tracks={tracks}
|
|
511
507
|
trackStyles={trackStyles}
|
|
508
|
+
groups={groups}
|
|
512
509
|
laneCounts={laneCounts}
|
|
513
510
|
selectedElementId={selectedElementId}
|
|
514
511
|
selectedElementIds={selectedElementIds}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
6
|
+
import { TimelineFxButton } from "./TimelineFxButton.js";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
11
|
+
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mount(node: React.ReactElement) {
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
document.body.append(host);
|
|
17
|
+
act(() => createRoot(host).render(node));
|
|
18
|
+
return host;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("TimelineFxButton", () => {
|
|
26
|
+
it("reads FX with no count when the chain is empty", () => {
|
|
27
|
+
const host = mount(
|
|
28
|
+
<TimelineFxButton
|
|
29
|
+
variant="chain"
|
|
30
|
+
fxChainRaw={undefined}
|
|
31
|
+
onChainChange={vi.fn()}
|
|
32
|
+
onOpenRack={vi.fn()}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
expect(byTextButton(host, "FX")?.textContent).toBe("FX");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("counts only enabled nodes", () => {
|
|
39
|
+
const chain = {
|
|
40
|
+
version: 1 as const,
|
|
41
|
+
nodes: [
|
|
42
|
+
{ type: "peaking", params: {}, enabled: true },
|
|
43
|
+
{ type: "gain", params: {}, enabled: false },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
const host = mount(
|
|
47
|
+
<TimelineFxButton
|
|
48
|
+
variant="chain"
|
|
49
|
+
fxChainRaw={serializeAudioFxChain(chain)}
|
|
50
|
+
onChainChange={vi.fn()}
|
|
51
|
+
onOpenRack={vi.fn()}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
expect(byTextButton(host, "FX 1")).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("opens the popover on click, anchored off the button", () => {
|
|
58
|
+
const host = mount(
|
|
59
|
+
<TimelineFxButton
|
|
60
|
+
variant="chain"
|
|
61
|
+
fxChainRaw={undefined}
|
|
62
|
+
onChainChange={vi.fn()}
|
|
63
|
+
onOpenRack={vi.fn()}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
expect(host.querySelector('[role="dialog"]')).toBeNull();
|
|
67
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
68
|
+
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("group-pointer variant offers Group instead of a popover", () => {
|
|
72
|
+
const onGroupClips = vi.fn();
|
|
73
|
+
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
|
|
74
|
+
act(() => byTextButton(host, "FX")?.click());
|
|
75
|
+
const groupButton = document.body.querySelectorAll("button");
|
|
76
|
+
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
|
|
77
|
+
expect(group).toBeDefined();
|
|
78
|
+
act(() => group?.click());
|
|
79
|
+
expect(onGroupClips).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -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
|
+
});
|