@hyperframes/studio 0.8.11 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6431 -5515
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The visibility control's accessible contract.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* The wording ran behind the `audio-track-mute` canary at 0%, so it had never
|
|
7
|
+
* rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
|
|
8
|
+
* state rather than the action, and dropped the track suffix so every audio row
|
|
9
|
+
* shared one accessible name. Pinned here because the label, the icon and the
|
|
10
|
+
* callback's arguments are the whole identity of this control.
|
|
11
|
+
*
|
|
12
|
+
* The mute presentation itself is gone (`remove mute and solo from tracks and
|
|
13
|
+
* groups`): the control is the visibility eye it always was, and an audio row
|
|
14
|
+
* renders it only when already hidden, which is the way back out.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import React, { act } from "react";
|
|
@@ -24,7 +27,7 @@ afterEach(() => {
|
|
|
24
27
|
|
|
25
28
|
function renderButton(props: {
|
|
26
29
|
hidden: boolean;
|
|
27
|
-
|
|
30
|
+
visible?: boolean;
|
|
28
31
|
trackDisplayNumber: number | null;
|
|
29
32
|
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
|
30
33
|
const host = document.createElement("div");
|
|
@@ -37,8 +40,7 @@ function renderButton(props: {
|
|
|
37
40
|
hidden: props.hidden,
|
|
38
41
|
trackNumber: 7,
|
|
39
42
|
trackDisplayNumber: props.trackDisplayNumber,
|
|
40
|
-
visible: true,
|
|
41
|
-
isAudioTrack: props.isAudioTrack,
|
|
43
|
+
visible: props.visible ?? true,
|
|
42
44
|
onToggle,
|
|
43
45
|
}),
|
|
44
46
|
),
|
|
@@ -49,31 +51,7 @@ function renderButton(props: {
|
|
|
49
51
|
const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
|
|
50
52
|
|
|
51
53
|
describe("VisibilityButton", () => {
|
|
52
|
-
it("names the action, not the state
|
|
53
|
-
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
54
|
-
expect(labelOf(view.host)).toBe("Mute track 2");
|
|
55
|
-
view.unmount();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// The half that was wrong: a muted row read "Muted", so nothing told a
|
|
59
|
-
// screen-reader user that activating it would unmute.
|
|
60
|
-
it("promises the un-mute when the audio track is already muted", () => {
|
|
61
|
-
const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
|
|
62
|
-
expect(labelOf(view.host)).toBe("Unmute track 2");
|
|
63
|
-
view.unmount();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
|
|
67
|
-
const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
|
|
68
|
-
const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
|
|
69
|
-
expect(labelOf(first.host)).toBe("Mute track 1");
|
|
70
|
-
expect(labelOf(second.host)).toBe("Mute track 3");
|
|
71
|
-
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
|
72
|
-
first.unmount();
|
|
73
|
-
second.unmount();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("still says Hide/Show on a visual track", () => {
|
|
54
|
+
it("names the action, not the state", () => {
|
|
77
55
|
const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
|
78
56
|
expect(labelOf(shown.host)).toBe("Hide track 2");
|
|
79
57
|
shown.unmount();
|
|
@@ -82,15 +60,34 @@ describe("VisibilityButton", () => {
|
|
|
82
60
|
hiddenRow.unmount();
|
|
83
61
|
});
|
|
84
62
|
|
|
63
|
+
it("keeps each row's name unique, so two tracks are distinguishable", () => {
|
|
64
|
+
const first = renderButton({ hidden: false, trackDisplayNumber: 1 });
|
|
65
|
+
const second = renderButton({ hidden: false, trackDisplayNumber: 3 });
|
|
66
|
+
expect(labelOf(first.host)).toBe("Hide track 1");
|
|
67
|
+
expect(labelOf(second.host)).toBe("Hide track 3");
|
|
68
|
+
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
|
69
|
+
first.unmount();
|
|
70
|
+
second.unmount();
|
|
71
|
+
});
|
|
72
|
+
|
|
85
73
|
// The callback acts on the REAL track key; the display row rides along so the
|
|
86
74
|
// undo-history label announces the same row this button just did, instead of
|
|
87
75
|
// re-deriving it from an ordering that has no group anchors in it.
|
|
88
76
|
it("toggles the real track number and passes the row it announced", () => {
|
|
89
|
-
const view = renderButton({ hidden: false,
|
|
77
|
+
const view = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
|
90
78
|
view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
91
79
|
expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
|
|
92
80
|
view.unmount();
|
|
93
81
|
});
|
|
82
|
+
|
|
83
|
+
// `visible={false}` renders a SPACER, not nothing: every row's control
|
|
84
|
+
// columns have to line up whether or not this row offers the control.
|
|
85
|
+
it("holds the column with a spacer when withheld", () => {
|
|
86
|
+
const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
|
|
87
|
+
expect(view.host.querySelector("button")).toBeNull();
|
|
88
|
+
expect(view.host.querySelector("span")).toBeTruthy();
|
|
89
|
+
view.unmount();
|
|
90
|
+
});
|
|
94
91
|
});
|
|
95
92
|
|
|
96
93
|
describe("PlainTrackHeader", () => {
|
|
@@ -98,7 +95,6 @@ describe("PlainTrackHeader", () => {
|
|
|
98
95
|
const host = document.createElement("div");
|
|
99
96
|
document.body.append(host);
|
|
100
97
|
const root = createRoot(host);
|
|
101
|
-
const onToggleSolo = vi.fn();
|
|
102
98
|
act(() =>
|
|
103
99
|
root.render(
|
|
104
100
|
React.createElement(PlainTrackHeader, {
|
|
@@ -110,52 +106,34 @@ describe("PlainTrackHeader", () => {
|
|
|
110
106
|
isAudioTrack: true,
|
|
111
107
|
onToggleTrackHidden: vi.fn(),
|
|
112
108
|
showTrackLabel: true,
|
|
113
|
-
isGroupMuted: false,
|
|
114
|
-
isSoloed: false,
|
|
115
|
-
onToggleSolo,
|
|
116
109
|
...overrides,
|
|
117
110
|
}),
|
|
118
111
|
),
|
|
119
112
|
);
|
|
120
|
-
return { host, unmount: () => act(() => root.unmount())
|
|
113
|
+
return { host, unmount: () => act(() => root.unmount()) };
|
|
121
114
|
}
|
|
122
115
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const view = renderHeader({ isSoloed: true });
|
|
129
|
-
expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
|
|
130
|
-
view.unmount();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("withholds solo from a visual track", () => {
|
|
134
|
-
const view = renderHeader({ isAudioTrack: false });
|
|
135
|
-
expect(soloButton(view.host)).toBeNull();
|
|
116
|
+
// A row that says what it is with a speaker does not also need the hide
|
|
117
|
+
// affordance sitting in the eye's slot.
|
|
118
|
+
it("withholds the eye from an audible audio track", () => {
|
|
119
|
+
const view = renderHeader();
|
|
120
|
+
expect(view.host.querySelector("button")).toBeNull();
|
|
136
121
|
view.unmount();
|
|
137
122
|
});
|
|
138
123
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
124
|
+
// Withholding it unconditionally withheld the only way back: `data-hidden`
|
|
125
|
+
// silences the clip in preview and drops it from the render, and nothing else
|
|
126
|
+
// writes it, so a track hidden by hand or by "Hide all" was silent with no
|
|
127
|
+
// control anywhere to restore it.
|
|
128
|
+
it("offers it back once the audio track is hidden", () => {
|
|
143
129
|
const view = renderHeader({ isTrackHidden: true });
|
|
144
|
-
expect(
|
|
130
|
+
expect(labelOf(view.host)).toBe("Show track 1");
|
|
145
131
|
view.unmount();
|
|
146
132
|
});
|
|
147
133
|
|
|
148
|
-
it("
|
|
149
|
-
const view = renderHeader({
|
|
150
|
-
expect(
|
|
151
|
-
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
|
|
152
|
-
view.unmount();
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("leaves an audible row unstruck", () => {
|
|
156
|
-
const view = renderHeader();
|
|
157
|
-
expect(labelSpan(view.host)?.className).not.toContain("line-through");
|
|
158
|
-
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
|
|
134
|
+
it("keeps the eye on a visual track", () => {
|
|
135
|
+
const view = renderHeader({ isAudioTrack: false });
|
|
136
|
+
expect(labelOf(view.host)).toBe("Hide track 1");
|
|
159
137
|
view.unmount();
|
|
160
138
|
});
|
|
161
139
|
});
|
|
@@ -1,29 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
2
3
|
import { Music } from "../../icons/SystemIcons";
|
|
3
|
-
import { TimelineSoloButton } from "./TimelineSoloButton";
|
|
4
4
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
5
|
import { TrackClipCount } from "./TrackClipCount";
|
|
6
6
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* Action-phrased and per-track, like the visual branch: `hidden` here is the
|
|
12
|
-
* CURRENT state, so the name has to promise the opposite. It read "Muted" /
|
|
13
|
-
* "Mute" until the rollout, which named the state instead of the action (a
|
|
14
|
-
* screen-reader user could not tell that activating an already-muted row would
|
|
15
|
-
* unmute it) and dropped `suffix`, so every audio row shared one accessible
|
|
16
|
-
* name — music plus VO being the ordinary case. The wording matches the undo
|
|
17
|
-
* entry `timelineTrackVisibility` writes for the same click, where `hidden` is
|
|
18
|
-
* the INCOMING state and the two therefore read inverted.
|
|
19
|
-
*/
|
|
20
|
-
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
|
21
|
-
if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
|
|
8
|
+
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
|
|
9
|
+
// this is the visibility eye it always was, and audio rows do not render it.
|
|
10
|
+
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
|
|
22
11
|
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
23
12
|
}
|
|
24
13
|
|
|
25
|
-
function visibilityButtonIcon(
|
|
26
|
-
const Icon =
|
|
14
|
+
function visibilityButtonIcon(hidden: boolean) {
|
|
15
|
+
const Icon = hidden ? EyeSlash : Eye;
|
|
27
16
|
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
|
28
17
|
}
|
|
29
18
|
|
|
@@ -32,22 +21,19 @@ export function VisibilityButton({
|
|
|
32
21
|
trackNumber,
|
|
33
22
|
trackDisplayNumber,
|
|
34
23
|
visible,
|
|
35
|
-
isAudioTrack,
|
|
36
24
|
onToggle,
|
|
37
25
|
}: {
|
|
38
26
|
hidden: boolean;
|
|
39
27
|
trackNumber: number;
|
|
40
28
|
trackDisplayNumber: number | null;
|
|
41
29
|
visible: boolean;
|
|
42
|
-
isAudioTrack?: boolean;
|
|
43
30
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
44
31
|
}) {
|
|
45
32
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
46
33
|
// Display number in the text, real key in the callback. The two must not be
|
|
47
34
|
// conflated in either direction.
|
|
48
35
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
49
|
-
const
|
|
50
|
-
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
|
36
|
+
const label = visibilityButtonLabel(hidden, suffix);
|
|
51
37
|
return (
|
|
52
38
|
<button
|
|
53
39
|
type="button"
|
|
@@ -59,10 +45,12 @@ export function VisibilityButton({
|
|
|
59
45
|
onPointerDown={(event) => event.stopPropagation()}
|
|
60
46
|
onClick={(event) => {
|
|
61
47
|
event.stopPropagation();
|
|
48
|
+
// Display number alongside the real key: the undo-history label must
|
|
49
|
+
// announce the same row this button just did (see `onToggleTrackHidden`).
|
|
62
50
|
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
|
63
51
|
}}
|
|
64
52
|
>
|
|
65
|
-
{visibilityButtonIcon(
|
|
53
|
+
{visibilityButtonIcon(hidden)}
|
|
66
54
|
</button>
|
|
67
55
|
);
|
|
68
56
|
}
|
|
@@ -77,10 +65,8 @@ export function PlainTrackHeader({
|
|
|
77
65
|
showTrackLabel,
|
|
78
66
|
isTrackHidden,
|
|
79
67
|
isAudioTrack,
|
|
80
|
-
isGroupMuted,
|
|
81
|
-
isSoloed,
|
|
82
|
-
onToggleSolo,
|
|
83
68
|
onToggleTrackHidden,
|
|
69
|
+
trailing,
|
|
84
70
|
}: {
|
|
85
71
|
trackNumber: number;
|
|
86
72
|
trackDisplayNumber: number | null;
|
|
@@ -90,37 +76,61 @@ export function PlainTrackHeader({
|
|
|
90
76
|
isAudioTrack: boolean;
|
|
91
77
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
92
78
|
showTrackLabel: boolean;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
/** Trailing controls that belong on the control line — the FX entry points,
|
|
80
|
+
* which the caller owns because only it knows the clip they act on. */
|
|
81
|
+
trailing?: React.ReactNode;
|
|
96
82
|
}) {
|
|
97
83
|
return (
|
|
98
84
|
<>
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
85
|
+
{/* One line: the name, then every control pushed to the right edge. The
|
|
86
|
+
two-line split this replaced existed to stop four controls truncating
|
|
87
|
+
the name — but the name already truncates on its own (`min-w-0` plus
|
|
88
|
+
`truncate`), and the controls are `shrink-0`, so they hold the edge
|
|
89
|
+
and the name gives way instead. */}
|
|
90
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
91
|
+
{isAudioTrack && (
|
|
92
|
+
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
|
93
|
+
)}
|
|
94
|
+
{/* No `flex-1`: the name takes only the width it needs, so the clip
|
|
95
|
+
count sits against it rather than being pushed out to meet the
|
|
96
|
+
controls. The slack goes to the `ml-auto` group below instead.
|
|
97
|
+
|
|
98
|
+
Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
+
read at all, and a tooltip is no use to a name you are scanning a
|
|
100
|
+
column of. `break-words` so a long single token breaks instead of
|
|
101
|
+
forcing the column wider. */}
|
|
102
|
+
{showTrackLabel && (
|
|
103
|
+
<span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
|
|
104
|
+
)}
|
|
105
|
+
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
|
|
106
|
+
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
|
|
107
|
+
truncating name leaves, so the controls sit on the edge whatever the
|
|
108
|
+
name's length. */}
|
|
109
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
110
|
+
{/* Not on an audio track. The control is the old visibility eye, and
|
|
111
|
+
on audio it silences rather than hides — but a row that already says
|
|
112
|
+
what it is with a speaker does not also need the hide affordance
|
|
113
|
+
sitting in the eye's slot. `visible={false}` rather than omitting the
|
|
114
|
+
element, so the spacer keeps every row's control columns aligned.
|
|
115
|
+
|
|
116
|
+
EXCEPT when the audio track is ALREADY hidden. Withholding the
|
|
117
|
+
control unconditionally withheld the only way back: `data-hidden`
|
|
118
|
+
silences the clip in preview and drops it from the render, the
|
|
119
|
+
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
|
|
120
|
+
else writes it — so a track hidden before this rule (or by "Hide
|
|
121
|
+
all", or by hand) was silent with no control anywhere to restore it.
|
|
122
|
+
Offering the eye only in that state keeps the affordance off a normal
|
|
123
|
+
audio row while leaving the door open from the inside. */}
|
|
124
|
+
<VisibilityButton
|
|
125
|
+
hidden={isTrackHidden}
|
|
126
|
+
trackNumber={trackNumber}
|
|
127
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
128
|
+
visible={!isAudioTrack || isTrackHidden}
|
|
129
|
+
onToggle={onToggleTrackHidden}
|
|
130
|
+
/>
|
|
131
|
+
{trailing}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
124
134
|
</>
|
|
125
135
|
);
|
|
126
136
|
}
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
automationLaneLabel,
|
|
5
5
|
automationLaneLabelParts,
|
|
6
6
|
elementAutomation,
|
|
7
|
+
elementAutomationLanes,
|
|
7
8
|
groupAutomationLanes,
|
|
8
9
|
laneGroupKey,
|
|
9
10
|
elementFxChain,
|
|
11
|
+
isCarveLane,
|
|
10
12
|
} from "./automationLaneData";
|
|
11
13
|
import type { TimelineElement } from "../store/timelineElement";
|
|
12
14
|
|
|
@@ -268,3 +270,95 @@ describe("groupAutomationLanes", () => {
|
|
|
268
270
|
expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
|
|
269
271
|
});
|
|
270
272
|
});
|
|
273
|
+
|
|
274
|
+
// A carve writes its own filter bands AND the lanes that drive them, and
|
|
275
|
+
// `withoutCarveLanes` replaces every one of them on each re-run — so a drag on
|
|
276
|
+
// one is discarded the next time the carve analyses. The rack also counts the
|
|
277
|
+
// carve as ONE module rather than the filters it compiles to, so a lane per
|
|
278
|
+
// band reads as "automation on effects I removed", which is exactly how it was
|
|
279
|
+
// reported.
|
|
280
|
+
describe("carve-generated lanes", () => {
|
|
281
|
+
const carved = (): TimelineElement => ({
|
|
282
|
+
id: "vo",
|
|
283
|
+
tag: "audio",
|
|
284
|
+
start: 0,
|
|
285
|
+
duration: 5,
|
|
286
|
+
track: 0,
|
|
287
|
+
fxChain: JSON.stringify({
|
|
288
|
+
version: 1,
|
|
289
|
+
nodes: [
|
|
290
|
+
{
|
|
291
|
+
type: "peaking",
|
|
292
|
+
id: "n1",
|
|
293
|
+
fromCarve: true,
|
|
294
|
+
params: { frequency: 160, gain: -6, q: 1.4 },
|
|
295
|
+
},
|
|
296
|
+
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -3, q: 1 } },
|
|
297
|
+
],
|
|
298
|
+
}),
|
|
299
|
+
automation: JSON.stringify({
|
|
300
|
+
version: 1,
|
|
301
|
+
lanes: [
|
|
302
|
+
{
|
|
303
|
+
target: "fx.n1.gain",
|
|
304
|
+
points: [
|
|
305
|
+
{ t: 0, v: 0 },
|
|
306
|
+
{ t: 1, v: -4 },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
target: "fx.n2.gain",
|
|
311
|
+
points: [
|
|
312
|
+
{ t: 0, v: 0 },
|
|
313
|
+
{ t: 1, v: -2 },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
target: "volume",
|
|
318
|
+
points: [
|
|
319
|
+
{ t: 0, v: 1 },
|
|
320
|
+
{ t: 1, v: 0.5 },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// These lanes used to be withheld. A bed whose EVERY lane is the carve's —
|
|
328
|
+
// which is what a plain voiceover carve produces — then showed no automation
|
|
329
|
+
// at all and no control to reveal any, so the carve looked like it had done
|
|
330
|
+
// nothing. The ducking curve is what a carve IS; it is shown, and marked
|
|
331
|
+
// read-only because the carve rewrites it on each analysis.
|
|
332
|
+
it("draws the carve's lanes alongside the author's", () => {
|
|
333
|
+
const targets = elementAutomationLanes(carved()).map((lane) => lane.target);
|
|
334
|
+
expect(targets).toContain("fx.n1.gain");
|
|
335
|
+
expect(targets).toContain("fx.n2.gain");
|
|
336
|
+
expect(targets).toContain("volume");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("counts a carve band as a timeline row, so the row reserves its height", () => {
|
|
340
|
+
const rows = groupAutomationLanes([carved()]);
|
|
341
|
+
expect(rows).toHaveLength(3);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("tells a carve's lane from the author's on the same element", () => {
|
|
345
|
+
const chain = elementFxChain(carved());
|
|
346
|
+
expect(isCarveLane("fx.n1.gain", chain)).toBe(true);
|
|
347
|
+
// n2 is a hand-built peaking band with the same parameter — only the
|
|
348
|
+
// `fromCarve` tag separates them.
|
|
349
|
+
expect(isCarveLane("fx.n2.gain", chain)).toBe(false);
|
|
350
|
+
expect(isCarveLane("volume", chain)).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("treats every lane as the author's when nothing is carve-tagged", () => {
|
|
354
|
+
const plain = {
|
|
355
|
+
...carved(),
|
|
356
|
+
fxChain: JSON.stringify({
|
|
357
|
+
version: 1,
|
|
358
|
+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 160, gain: -6, q: 1.4 } }],
|
|
359
|
+
}),
|
|
360
|
+
};
|
|
361
|
+
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
|
|
362
|
+
expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -91,7 +91,30 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Is this lane one the CARVE wrote, rather than the author?
|
|
96
|
+
*
|
|
97
|
+
* The carve compiles to tagged nodes and rewrites their envelopes on every
|
|
98
|
+
* re-run (`withoutCarveLanes` replaces each one), so a drag on such a lane is
|
|
99
|
+
* silently discarded the next time it analyses. Read-only rather than hidden:
|
|
100
|
+
* the ducking curve is what a carve IS, and the whole reason to look at a
|
|
101
|
+
* carved bed in the timeline is to see where it makes room. Hiding them left a
|
|
102
|
+
* bed whose every lane was the carve's showing no automation at all and no
|
|
103
|
+
* control to reveal any — which reads as the carve having done nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function isCarveLane(target: string, chain: HfAudioFxChain | null): boolean {
|
|
106
|
+
return (chain?.nodes ?? []).some(
|
|
107
|
+
(node) => node.fromCarve && node.id && target.startsWith(`fx.${node.id}.`),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
/** Lanes in the order they are drawn, one row each. */
|
|
112
|
+
/**
|
|
113
|
+
* The lanes a TIMELINE row should draw.
|
|
114
|
+
*
|
|
115
|
+
* Every lane the element carries, the carve's included — see `isCarveLane` for
|
|
116
|
+
* why those are shown read-only instead of withheld.
|
|
117
|
+
*/
|
|
95
118
|
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
|
|
96
119
|
return elementAutomation(element).lanes;
|
|
97
120
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { groupAutomationElement } from "./groupAutomationElement";
|
|
3
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
4
|
+
|
|
5
|
+
const GROUP = {
|
|
6
|
+
id: "voiceover",
|
|
7
|
+
label: "Voiceover",
|
|
8
|
+
anchorKey: 1.5,
|
|
9
|
+
automation: JSON.stringify({
|
|
10
|
+
version: 1,
|
|
11
|
+
lanes: [
|
|
12
|
+
{
|
|
13
|
+
target: "volume",
|
|
14
|
+
points: [
|
|
15
|
+
{ t: 0, v: 1 },
|
|
16
|
+
{ t: 5, v: 0.4 },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("groupAutomationElement", () => {
|
|
24
|
+
// §1.3: "A group's automation clock is COMPOSITION time — decide it, do not
|
|
25
|
+
// inherit it." A clip-local span would land the group's fade at a different
|
|
26
|
+
// moment in preview than the render bakes it.
|
|
27
|
+
it("spans the whole composition from zero, so lane times are composition time", () => {
|
|
28
|
+
const el = groupAutomationElement(GROUP, 60);
|
|
29
|
+
expect(el.start).toBe(0);
|
|
30
|
+
expect(el.duration).toBe(60);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The lane machinery filters with `isAudioTimelineElement`; a group that does
|
|
34
|
+
// not pass it renders nothing at all, silently.
|
|
35
|
+
it("is admitted by the lane machinery and yields the group's own lanes", () => {
|
|
36
|
+
const lanes = groupAutomationLanes([groupAutomationElement(GROUP, 60)]);
|
|
37
|
+
expect(lanes).toHaveLength(1);
|
|
38
|
+
expect(lanes[0]?.name).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The write has to land on `<hf-audio-group>`, not on a member clip.
|
|
42
|
+
it("carries the group's DOM id, so a lane edit addresses the group element", () => {
|
|
43
|
+
expect(groupAutomationElement(GROUP, 60).domId).toBe("voiceover");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A group with no automation draws no lanes — and must not throw doing it.
|
|
47
|
+
it("yields no lanes when the group automates nothing", () => {
|
|
48
|
+
const bare = { id: "sfx", label: "SFX", anchorKey: 2.5 };
|
|
49
|
+
expect(groupAutomationLanes([groupAutomationElement(bare, 60)])).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group, as the automation lanes see it.
|
|
3
|
+
*
|
|
4
|
+
* Lanes are keyed by `TimelineElement` everywhere — the slot, the binder, the
|
|
5
|
+
* identity used for selection. A group is not one: it has no clip id, no start
|
|
6
|
+
* and no duration, which is §1.9's "a group is the first real audio entity in
|
|
7
|
+
* the system" problem showing up in the row model. Rather than give lanes a
|
|
8
|
+
* second, parallel path, the group borrows the shape it does not have.
|
|
9
|
+
*
|
|
10
|
+
* `start: 0` and `duration` = the composition's is not a placeholder, it is the
|
|
11
|
+
* clock: the design doc fixes a group's automation clock as COMPOSITION time
|
|
12
|
+
* (§1.3), and a missing `data-start` parses as 0, which is exactly that. So a
|
|
13
|
+
* lane drawn against this element lands at the same seconds the render bakes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
17
|
+
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
18
|
+
|
|
19
|
+
/** The synthetic element's track number — the group row's own anchor. */
|
|
20
|
+
export function groupAutomationElement(
|
|
21
|
+
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
|
|
22
|
+
compositionDuration: number,
|
|
23
|
+
): TimelineElement {
|
|
24
|
+
return {
|
|
25
|
+
id: group.id,
|
|
26
|
+
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
|
|
27
|
+
domId: group.id,
|
|
28
|
+
label: group.label,
|
|
29
|
+
// Audio, so `isAudioTimelineElement` admits it and the lanes render at all.
|
|
30
|
+
tag: "audio",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: compositionDuration,
|
|
33
|
+
track: group.anchorKey,
|
|
34
|
+
...(group.automation ? { automation: group.automation } : {}),
|
|
35
|
+
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
|
|
|
73
73
|
* two different orderings: the header's comes from the group-aware row list
|
|
74
74
|
* (synthetic anchor rows, members pulled contiguous), the history's from a
|
|
75
75
|
* plain ascending sort of element-bearing keys. Once a group exists those
|
|
76
|
-
* disagree, so the same click said "
|
|
76
|
+
* disagree, so the same click said "Hide track 2" and recorded "Hide track 1".
|
|
77
77
|
* Passing the rendered number keeps one answer instead of two derivations.
|
|
78
78
|
*/
|
|
79
79
|
onToggleTrackHidden?: (
|
|
@@ -93,7 +93,11 @@ export interface TimelineEditCallbacks {
|
|
|
93
93
|
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
94
94
|
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
95
95
|
* carve auto-grouping uses. */
|
|
96
|
-
onGroupClips?: (
|
|
96
|
+
onGroupClips?: (
|
|
97
|
+
clipIds: readonly string[],
|
|
98
|
+
groupId: string,
|
|
99
|
+
groupLabel?: string,
|
|
100
|
+
) => Promise<void>;
|
|
97
101
|
/** C1's single-clip FX write: addressed by the clip itself rather than the
|
|
98
102
|
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
|
|
99
103
|
onSetElementAttributeLive?: (
|
|
@@ -58,7 +58,7 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
|
|
|
58
58
|
selectedElementId: "active",
|
|
59
59
|
selectedElementIds: new Set(),
|
|
60
60
|
expandedClipIds: new Set(["active"]),
|
|
61
|
-
|
|
61
|
+
collapsedGroupIds: new Set(),
|
|
62
62
|
expandedLaneOwnerIds: new Set(),
|
|
63
63
|
groups: [],
|
|
64
64
|
trackGroupOf: new Map(),
|
|
@@ -248,7 +248,7 @@ describe("resolveTimelineNavigationTarget", () => {
|
|
|
248
248
|
selectedElementId: null,
|
|
249
249
|
selectedElementIds: new Set(),
|
|
250
250
|
expandedClipIds: new Set(),
|
|
251
|
-
|
|
251
|
+
collapsedGroupIds: new Set(),
|
|
252
252
|
expandedLaneOwnerIds: new Set(),
|
|
253
253
|
groups: [],
|
|
254
254
|
trackGroupOf: new Map(),
|