@hyperframes/studio 0.8.86 → 0.8.88
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-w3Bbv0kd.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
- package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +206 -160
- package/dist/index.html +2 -2
- package/dist/index.js +2121 -1702
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +10 -14
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/src/player/store/playerStore.ts +16 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- package/dist/chunk-7PLTC6SV.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
installTimelineMountEnv();
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function mountKeyframed(showKeyframes?: boolean, withLanes = true) {
|
|
19
|
+
usePlayerStore.setState({
|
|
20
|
+
duration: 10,
|
|
21
|
+
currentTime: 0,
|
|
22
|
+
timelineReady: true,
|
|
23
|
+
selectedElementId: "card",
|
|
24
|
+
elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
25
|
+
gsapAnimations: withLanes
|
|
26
|
+
? new Map([
|
|
27
|
+
[
|
|
28
|
+
"card",
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
id: "card-position",
|
|
32
|
+
targetSelector: "#card",
|
|
33
|
+
method: "to",
|
|
34
|
+
position: 0,
|
|
35
|
+
duration: 2,
|
|
36
|
+
properties: {},
|
|
37
|
+
propertyGroup: "position",
|
|
38
|
+
keyframes: {
|
|
39
|
+
format: "percentage",
|
|
40
|
+
keyframes: [
|
|
41
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
42
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
])
|
|
49
|
+
: new Map(),
|
|
50
|
+
keyframeCache: new Map([
|
|
51
|
+
[
|
|
52
|
+
"card",
|
|
53
|
+
{
|
|
54
|
+
format: "percentage",
|
|
55
|
+
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
]),
|
|
59
|
+
});
|
|
60
|
+
const host = document.createElement("div");
|
|
61
|
+
document.body.append(host);
|
|
62
|
+
const root = createRoot(host);
|
|
63
|
+
await act(async () => root.render(<Timeline showKeyframes={showKeyframes} />));
|
|
64
|
+
const diamonds = host.querySelectorAll('button[title="50%"]').length;
|
|
65
|
+
const named = Array.from(host.querySelectorAll("[role=rowheader]")).some((h) =>
|
|
66
|
+
h.textContent?.includes("Hero card"),
|
|
67
|
+
);
|
|
68
|
+
const lanes = host.textContent?.includes("Position") ?? false;
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
return { diamonds, named, lanes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("Timeline showKeyframes", () => {
|
|
74
|
+
it("draws a keyframed clip's diamonds by default", async () => {
|
|
75
|
+
expect((await mountKeyframed(undefined, false)).diamonds).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("draws no keyframe diamonds when the host turns keyframes off", async () => {
|
|
79
|
+
expect((await mountKeyframed(false, false)).diamonds).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("draws the keyframe lanes by default and none when keyframes are off", async () => {
|
|
83
|
+
expect((await mountKeyframed()).lanes).toBe(true);
|
|
84
|
+
expect((await mountKeyframed(false)).lanes).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps each track's name in the header when keyframes are off", async () => {
|
|
88
|
+
expect((await mountKeyframed()).named).toBe(true);
|
|
89
|
+
expect((await mountKeyframed(false)).named).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -24,6 +24,7 @@ interface TimelineGroupHeaderProps {
|
|
|
24
24
|
onOpenFxRack: () => void;
|
|
25
25
|
columnWidth: number;
|
|
26
26
|
theme: TimelineTheme;
|
|
27
|
+
showAudioEffects?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -41,17 +42,19 @@ function GroupNameButton({
|
|
|
41
42
|
label,
|
|
42
43
|
memberCount,
|
|
43
44
|
onOpenFxRack,
|
|
45
|
+
showAudioEffects,
|
|
44
46
|
}: {
|
|
45
47
|
label: string;
|
|
46
48
|
memberCount: number;
|
|
47
49
|
onOpenFxRack: () => void;
|
|
50
|
+
showAudioEffects: boolean;
|
|
48
51
|
}) {
|
|
49
52
|
return (
|
|
50
53
|
<button
|
|
51
54
|
type="button"
|
|
52
55
|
tabIndex={-1}
|
|
53
|
-
aria-label={`Open ${label} effects`}
|
|
54
|
-
title="Open effects"
|
|
56
|
+
aria-label={showAudioEffects ? `Open ${label} effects` : `Select ${label}`}
|
|
57
|
+
title={showAudioEffects ? "Open effects" : "Select group"}
|
|
55
58
|
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
|
|
56
59
|
// claiming it here would push the controls off the right edge — and the
|
|
57
60
|
// count with them, since it rides inside this button.
|
|
@@ -97,6 +100,7 @@ export function TimelineGroupHeader({
|
|
|
97
100
|
onOpenFxRack,
|
|
98
101
|
columnWidth,
|
|
99
102
|
theme,
|
|
103
|
+
showAudioEffects = true,
|
|
100
104
|
}: TimelineGroupHeaderProps) {
|
|
101
105
|
return (
|
|
102
106
|
<div
|
|
@@ -137,17 +141,24 @@ export function TimelineGroupHeader({
|
|
|
137
141
|
▸ sits off-centre in its box because the glyph is not square. */}
|
|
138
142
|
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
|
|
139
143
|
</button>
|
|
140
|
-
<GroupNameButton
|
|
144
|
+
<GroupNameButton
|
|
145
|
+
label={label}
|
|
146
|
+
memberCount={memberCount}
|
|
147
|
+
onOpenFxRack={onOpenFxRack}
|
|
148
|
+
showAudioEffects={showAudioEffects}
|
|
149
|
+
/>
|
|
141
150
|
{/* `ml-auto` absorbs the slack the truncating name leaves, so the controls
|
|
142
151
|
sit on the edge whatever the name's length. */}
|
|
143
152
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
{showAudioEffects && (
|
|
154
|
+
<TimelineFxButton
|
|
155
|
+
fxChainRaw={fxChain}
|
|
156
|
+
onChainChange={onFxChainChange}
|
|
157
|
+
onChainPreview={onFxChainPreview}
|
|
158
|
+
auditionSpans={auditionSpans}
|
|
159
|
+
onOpenRack={onOpenFxRack}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
151
162
|
{/* No lanes, no control: an author who opens it meets an empty row and
|
|
152
163
|
learns nothing. A track header already gates its own `∿` this way
|
|
153
164
|
(`disclosable`); the group's was the one that still offered a
|
|
@@ -63,8 +63,13 @@ export function TimelineGroupLaneLabels({
|
|
|
63
63
|
tabIndex={-1}
|
|
64
64
|
key={lane.target}
|
|
65
65
|
data-group-lane-label={lane.target}
|
|
66
|
-
aria-label={
|
|
67
|
-
|
|
66
|
+
aria-label={
|
|
67
|
+
onReveal
|
|
68
|
+
? `Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`
|
|
69
|
+
: undefined
|
|
70
|
+
}
|
|
71
|
+
disabled={!onReveal}
|
|
72
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] enabled:hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
68
73
|
style={{
|
|
69
74
|
top: top + index * AUTOMATION_LANE_H,
|
|
70
75
|
width: columnWidth,
|
|
@@ -45,9 +45,21 @@ const GROUP: TimelineTrackGroupInfo = {
|
|
|
45
45
|
hidden: false,
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const GAIN_AUTOMATED: Partial<TimelineTrackGroupInfo> = {
|
|
49
|
+
fxChain: JSON.stringify({
|
|
50
|
+
version: 1,
|
|
51
|
+
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
52
|
+
}),
|
|
53
|
+
automation: JSON.stringify({
|
|
54
|
+
version: 1,
|
|
55
|
+
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
48
59
|
function renderRow(
|
|
49
60
|
overrides: Partial<TimelineTrackGroupInfo> = {},
|
|
50
61
|
expandedLaneOwnerIds = new Set<string>(),
|
|
62
|
+
showAudioEffects?: boolean,
|
|
51
63
|
) {
|
|
52
64
|
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
53
65
|
const onSetElementAttributeQuiet = vi.fn();
|
|
@@ -66,6 +78,7 @@ function renderRow(
|
|
|
66
78
|
virtualized={false}
|
|
67
79
|
contentOrigin={232}
|
|
68
80
|
theme={defaultTimelineTheme}
|
|
81
|
+
showAudioEffects={showAudioEffects}
|
|
69
82
|
collapsedGroupIds={new Set()}
|
|
70
83
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
71
84
|
toggleGroupExpanded={vi.fn()}
|
|
@@ -118,19 +131,7 @@ describe("TimelineGroupRow", () => {
|
|
|
118
131
|
});
|
|
119
132
|
|
|
120
133
|
it("opens a group automation lane on its exact rack parameter", async () => {
|
|
121
|
-
const { host } = renderRow(
|
|
122
|
-
{
|
|
123
|
-
fxChain: JSON.stringify({
|
|
124
|
-
version: 1,
|
|
125
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
126
|
-
}),
|
|
127
|
-
automation: JSON.stringify({
|
|
128
|
-
version: 1,
|
|
129
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
130
|
-
}),
|
|
131
|
-
},
|
|
132
|
-
new Set(["voiceover"]),
|
|
133
|
-
);
|
|
134
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]));
|
|
134
135
|
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
135
136
|
|
|
136
137
|
await act(async () => {
|
|
@@ -147,6 +148,14 @@ describe("TimelineGroupRow", () => {
|
|
|
147
148
|
});
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
it("keeps a group lane's name off the rack while audio effects are hidden", () => {
|
|
152
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]), false);
|
|
153
|
+
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
154
|
+
|
|
155
|
+
expect(laneTitle?.disabled).toBe(true);
|
|
156
|
+
expect(laneTitle?.getAttribute("aria-label")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
150
159
|
// C1 names this as the step's own definition of done: "opening the popover on
|
|
151
160
|
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
|
|
152
161
|
// on the group element, and zero writes on members". A group IS a bus — a
|
|
@@ -180,16 +189,7 @@ describe("TimelineGroupRow", () => {
|
|
|
180
189
|
|
|
181
190
|
expect(laneToggle(renderRow().host)).toBeUndefined();
|
|
182
191
|
|
|
183
|
-
const automated = renderRow(
|
|
184
|
-
fxChain: JSON.stringify({
|
|
185
|
-
version: 1,
|
|
186
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
187
|
-
}),
|
|
188
|
-
automation: JSON.stringify({
|
|
189
|
-
version: 1,
|
|
190
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
192
|
+
const automated = renderRow(GAIN_AUTOMATED);
|
|
193
193
|
expect(laneToggle(automated.host)).toBeDefined();
|
|
194
194
|
});
|
|
195
195
|
|
|
@@ -197,16 +197,7 @@ describe("TimelineGroupRow", () => {
|
|
|
197
197
|
// one right-anchored group. It was two lines — name, then controls — which is
|
|
198
198
|
// what let a stray child overflow the 48px box on the track side.
|
|
199
199
|
it("keeps the caret, the name and every control on one line", () => {
|
|
200
|
-
const { host } = renderRow(
|
|
201
|
-
fxChain: JSON.stringify({
|
|
202
|
-
version: 1,
|
|
203
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
204
|
-
}),
|
|
205
|
-
automation: JSON.stringify({
|
|
206
|
-
version: 1,
|
|
207
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
208
|
-
}),
|
|
209
|
-
});
|
|
200
|
+
const { host } = renderRow(GAIN_AUTOMATED);
|
|
210
201
|
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
|
|
211
202
|
// Caret, name, control group — no second line.
|
|
212
203
|
expect(header?.children).toHaveLength(3);
|
|
@@ -33,6 +33,7 @@ interface TimelineGroupRowProps {
|
|
|
33
33
|
virtualized: boolean;
|
|
34
34
|
contentOrigin: number;
|
|
35
35
|
theme: TimelineTheme;
|
|
36
|
+
showAudioEffects?: boolean;
|
|
36
37
|
rovingTargetId?: string | null;
|
|
37
38
|
collapsedGroupIds: ReadonlySet<string>;
|
|
38
39
|
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
@@ -59,6 +60,7 @@ export function TimelineGroupRow({
|
|
|
59
60
|
virtualized,
|
|
60
61
|
contentOrigin,
|
|
61
62
|
theme,
|
|
63
|
+
showAudioEffects = true,
|
|
62
64
|
rovingTargetId = null,
|
|
63
65
|
collapsedGroupIds,
|
|
64
66
|
expandedLaneOwnerIds,
|
|
@@ -173,6 +175,7 @@ export function TimelineGroupRow({
|
|
|
173
175
|
// stays pinned there through horizontal scroll.
|
|
174
176
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
175
177
|
theme={theme}
|
|
178
|
+
showAudioEffects={showAudioEffects}
|
|
176
179
|
/>
|
|
177
180
|
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
|
178
181
|
clip's: a lane is editable once the group is selected, which clicking
|
|
@@ -187,7 +190,7 @@ export function TimelineGroupRow({
|
|
|
187
190
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
188
191
|
gutterBackground={theme.gutterBackground}
|
|
189
192
|
accentColor={GROUP_LANE_ACCENT}
|
|
190
|
-
onReveal={openGroupFxRack}
|
|
193
|
+
onReveal={showAudioEffects ? openGroupFxRack : undefined}
|
|
191
194
|
/>
|
|
192
195
|
)}
|
|
193
196
|
</div>
|
|
@@ -41,6 +41,7 @@ export function TimelineLanes({
|
|
|
41
41
|
contentGutter,
|
|
42
42
|
trackContentWidth,
|
|
43
43
|
theme,
|
|
44
|
+
showAudioEffects,
|
|
44
45
|
displayTrackOrder,
|
|
45
46
|
rowGeometry,
|
|
46
47
|
virtualRows,
|
|
@@ -115,11 +116,8 @@ export function TimelineLanes({
|
|
|
115
116
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
116
117
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
117
118
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
118
|
-
const
|
|
119
|
-
const groupMemberTracks =
|
|
120
|
-
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
121
|
-
[groups],
|
|
122
|
-
);
|
|
119
|
+
const rowIndexes = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
120
|
+
const { logicalRowsByTrack, groupByAnchor, groupMemberTracks } = rowIndexes;
|
|
123
121
|
const {
|
|
124
122
|
toggleRowExpanded: toggleRowExpandedTracked,
|
|
125
123
|
toggleClipExpanded: toggleClipExpandedTracked,
|
|
@@ -168,6 +166,7 @@ export function TimelineLanes({
|
|
|
168
166
|
virtualized={rowsVirtualized}
|
|
169
167
|
contentOrigin={contentOrigin}
|
|
170
168
|
theme={theme}
|
|
169
|
+
showAudioEffects={showAudioEffects}
|
|
171
170
|
rovingTargetId={keyboard.rovingTargetId}
|
|
172
171
|
collapsedGroupIds={collapsedGroupIds}
|
|
173
172
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
@@ -292,6 +291,7 @@ export function TimelineLanes({
|
|
|
292
291
|
isAudioTrack={isAudioTrack}
|
|
293
292
|
isGroupMember={groupMemberTracks.has(trackNum)}
|
|
294
293
|
theme={theme}
|
|
294
|
+
showAudioEffects={showAudioEffects}
|
|
295
295
|
onToggleClipExpanded={() => {
|
|
296
296
|
const keys = els.map(getTimelineElementIdentity);
|
|
297
297
|
if (keys.length > 0) toggleRowExpandedTracked(keys);
|
|
@@ -75,6 +75,7 @@ interface RenderHeaderOptions {
|
|
|
75
75
|
isAudioTrack?: boolean;
|
|
76
76
|
isGroupMember?: boolean;
|
|
77
77
|
isTrackHidden?: boolean;
|
|
78
|
+
showAudioEffects?: boolean;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -119,6 +120,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
119
120
|
isAudioTrack={next.isAudioTrack}
|
|
120
121
|
isGroupMember={next.isGroupMember}
|
|
121
122
|
theme={defaultTimelineTheme}
|
|
123
|
+
showAudioEffects={next.showAudioEffects}
|
|
122
124
|
onToggleClipExpanded={vi.fn()}
|
|
123
125
|
onToggleTrackHidden={next.onToggleTrackHidden}
|
|
124
126
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
@@ -583,6 +585,31 @@ describe("TimelineTrackHeader", () => {
|
|
|
583
585
|
}),
|
|
584
586
|
} as TimelineElement;
|
|
585
587
|
|
|
588
|
+
it("offers the effect rack from an effect lane only while audio effects are shown", () => {
|
|
589
|
+
const clip = { ...BED, domId: "bed" } as TimelineElement;
|
|
590
|
+
const gainLabel = (showAudioEffects?: boolean) => {
|
|
591
|
+
const { host, root } = renderHeader({
|
|
592
|
+
keyframeClip: clip,
|
|
593
|
+
animations: [],
|
|
594
|
+
showAudioEffects,
|
|
595
|
+
});
|
|
596
|
+
const button = host
|
|
597
|
+
.querySelector('[data-automation-lane-label="Peaking EQ 1.6 kHz · Gain"]')
|
|
598
|
+
?.querySelector("button");
|
|
599
|
+
const state = {
|
|
600
|
+
label: button?.getAttribute("aria-label") ?? null,
|
|
601
|
+
disabled: button?.disabled,
|
|
602
|
+
};
|
|
603
|
+
act(() => root.unmount());
|
|
604
|
+
return state;
|
|
605
|
+
};
|
|
606
|
+
expect(gainLabel()).toEqual({
|
|
607
|
+
label: "Show Peaking EQ 1.6 kHz · Gain in the effect rack",
|
|
608
|
+
disabled: false,
|
|
609
|
+
});
|
|
610
|
+
expect(gainLabel(false)).toEqual({ label: null, disabled: true });
|
|
611
|
+
});
|
|
612
|
+
|
|
586
613
|
it("names every envelope in the label column", () => {
|
|
587
614
|
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
|
|
588
615
|
const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
|
|
@@ -72,6 +72,7 @@ interface TimelineTrackHeaderProps {
|
|
|
72
72
|
isGroupMember?: boolean;
|
|
73
73
|
rovingTargetId?: string | null;
|
|
74
74
|
theme: TimelineTheme;
|
|
75
|
+
showAudioEffects?: boolean;
|
|
75
76
|
onToggleClipExpanded: () => void;
|
|
76
77
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
77
78
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
@@ -98,6 +99,7 @@ export function TimelineTrackHeader({
|
|
|
98
99
|
isAudioTrack,
|
|
99
100
|
isGroupMember = false,
|
|
100
101
|
theme,
|
|
102
|
+
showAudioEffects = true,
|
|
101
103
|
onToggleClipExpanded,
|
|
102
104
|
onToggleTrackHidden,
|
|
103
105
|
onTogglePropertyGroupKeyframe,
|
|
@@ -316,7 +318,7 @@ export function TimelineTrackHeader({
|
|
|
316
318
|
// On the control line rather than a third row of its own.
|
|
317
319
|
trailing={
|
|
318
320
|
<>
|
|
319
|
-
{singleAudioClip && (
|
|
321
|
+
{showAudioEffects && singleAudioClip && (
|
|
320
322
|
<TimelineFxButton
|
|
321
323
|
variant="chain"
|
|
322
324
|
fxChainRaw={singleAudioClip.fxChain}
|
|
@@ -340,7 +342,8 @@ export function TimelineTrackHeader({
|
|
|
340
342
|
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
341
343
|
/>
|
|
342
344
|
)}
|
|
343
|
-
{
|
|
345
|
+
{showAudioEffects &&
|
|
346
|
+
clipCount > 1 &&
|
|
344
347
|
!isTrackGrouped &&
|
|
345
348
|
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
|
|
346
349
|
<TimelineFxButton
|
|
@@ -455,7 +458,7 @@ export function TimelineTrackHeader({
|
|
|
455
458
|
// element, so a shared row's other envelopes belong to clips it is
|
|
456
459
|
// not showing and there would be nothing to reveal.
|
|
457
460
|
onReveal={
|
|
458
|
-
revealTarget && revealElementId && keyframeClip
|
|
461
|
+
showAudioEffects && revealTarget && revealElementId && keyframeClip
|
|
459
462
|
? () => {
|
|
460
463
|
// Select FIRST: the rack is the property panel's view of
|
|
461
464
|
// the selected element, so a reveal aimed at an unselected
|
|
@@ -52,6 +52,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
52
52
|
{ key: "⌘X", label: "Cut element" },
|
|
53
53
|
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
|
|
54
54
|
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
55
|
+
{ key: "[", label: "Select clips starting before the playhead" },
|
|
56
|
+
{ key: "]", label: "Select clips starting at or after the playhead" },
|
|
55
57
|
{ key: "⌘G", label: "Group elements" },
|
|
56
58
|
{ key: "⌘⇧G", label: "Ungroup" },
|
|
57
59
|
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterAll, beforeAll } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mounting a whole <Timeline> in happy-dom needs a width to lay out against and a size report.
|
|
4
|
+
class MockResizeObserver {
|
|
5
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
6
|
+
observe(target: Element) {
|
|
7
|
+
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
|
8
|
+
}
|
|
9
|
+
unobserve() {}
|
|
10
|
+
disconnect() {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function installTimelineMountEnv(): void {
|
|
14
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
15
|
+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
18
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get: () => 900,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
25
|
+
if (originalClientWidth)
|
|
26
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { usePlayerStore, type KeyframeCacheEntry } from "../store/playerStore";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
|
|
4
|
+
const HIDDEN = {
|
|
5
|
+
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
|
6
|
+
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** The keyframe data the timeline draws, or none when the host hides keyframes. */
|
|
10
|
+
export function useTimelineKeyframeData(showKeyframes: boolean) {
|
|
11
|
+
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
12
|
+
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
13
|
+
return showKeyframes ? { gsapAnimations, keyframeCache } : HIDDEN;
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
|
5
|
+
import { timelineNeedsLabelColumn } from "./timelineViewModel";
|
|
6
|
+
|
|
7
|
+
export function useTimelineLabelColumn(
|
|
8
|
+
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
|
9
|
+
elements: readonly TimelineElement[],
|
|
10
|
+
) {
|
|
11
|
+
const labelMode = useMemo(
|
|
12
|
+
() => timelineNeedsLabelColumn(animationsByElement, elements),
|
|
13
|
+
[animationsByElement, elements],
|
|
14
|
+
);
|
|
15
|
+
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
16
|
+
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
17
|
+
return { labelMode, contentOrigin };
|
|
18
|
+
}
|
|
@@ -34,5 +34,10 @@ export function useTimelineLaneRowIndexes(
|
|
|
34
34
|
[groups],
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const groupMemberTracks = useMemo(
|
|
38
|
+
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
39
|
+
[groups],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return { logicalRowsByTrack, groupByAnchor, groupMemberTracks };
|
|
38
43
|
}
|
|
@@ -19,7 +19,9 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
|
|
19
19
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
|
20
20
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
21
21
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
22
|
-
import { GUTTER, LABEL_COL_W
|
|
22
|
+
import { GUTTER, LABEL_COL_W } from "./timelineLayout";
|
|
23
|
+
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
|
|
24
|
+
import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
|
|
23
25
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
24
26
|
import { ClipContentOnceShown } from "./timelineClipChildren";
|
|
25
27
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
|
@@ -33,11 +35,7 @@ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
|
33
35
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
34
36
|
import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
35
37
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
36
|
-
import {
|
|
37
|
-
getEffectiveTimelineDuration,
|
|
38
|
-
getTimelinePreviewElement,
|
|
39
|
-
timelineNeedsLabelColumn,
|
|
40
|
-
} from "./timelineViewModel";
|
|
38
|
+
import { getEffectiveTimelineDuration, getTimelinePreviewElement } from "./timelineViewModel";
|
|
41
39
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
42
40
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
43
41
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -70,6 +68,8 @@ export function useTimelineProviderState({
|
|
|
70
68
|
onDuplicateClip,
|
|
71
69
|
canPasteClip,
|
|
72
70
|
theme: themeOverrides,
|
|
71
|
+
showAudioEffects = true,
|
|
72
|
+
showKeyframes = true,
|
|
73
73
|
sessionEpoch = 0,
|
|
74
74
|
previewIframeRef,
|
|
75
75
|
onZIndexReorder,
|
|
@@ -106,13 +106,9 @@ export function useTimelineProviderState({
|
|
|
106
106
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
107
107
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
108
108
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
109
|
-
const gsapAnimations =
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
[gsapAnimations, timelineElements],
|
|
113
|
-
);
|
|
114
|
-
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
115
|
-
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
109
|
+
const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
|
|
110
|
+
const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
111
|
+
const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
|
|
116
112
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
117
113
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
118
114
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -137,7 +133,6 @@ export function useTimelineProviderState({
|
|
|
137
133
|
() => getEffectiveTimelineDuration(duration, timelineElements),
|
|
138
134
|
[duration, timelineElements],
|
|
139
135
|
);
|
|
140
|
-
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
141
136
|
useAutoExpandKeyframedClips(gsapAnimations);
|
|
142
137
|
const {
|
|
143
138
|
tracks,
|
|
@@ -448,6 +443,7 @@ export function useTimelineProviderState({
|
|
|
448
443
|
laneGapStrips,
|
|
449
444
|
dropPreview: assetDrop.dropPreview,
|
|
450
445
|
theme,
|
|
446
|
+
showAudioEffects,
|
|
451
447
|
displayTrackOrder: displayLayout.displayTrackOrder,
|
|
452
448
|
rowHeights: displayLayout.displayRowHeights,
|
|
453
449
|
rowGeometry: displayLayout.rowGeometry,
|
|
@@ -546,29 +546,30 @@ export function useTimelineRangeSelection({
|
|
|
546
546
|
[cancelActiveGesture, isGestureSessionCurrent],
|
|
547
547
|
);
|
|
548
548
|
|
|
549
|
-
// Escape
|
|
550
|
-
// otherwise
|
|
549
|
+
// Escape cancels an in-flight band, captured so a host's own Escape never also fires;
|
|
550
|
+
// otherwise it clears any lingering multi-selection.
|
|
551
551
|
useEffect(() => {
|
|
552
|
+
const onBandEscape = (e: KeyboardEvent) => {
|
|
553
|
+
if (e.key !== "Escape" || !(marqueeRef.current || isRangeSelecting.current)) return;
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
cancelActiveGesture(true, true);
|
|
557
|
+
};
|
|
552
558
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
553
559
|
if (e.key !== "Escape") return;
|
|
554
560
|
const store = usePlayerStore.getState();
|
|
555
|
-
|
|
556
|
-
if (marquee) {
|
|
557
|
-
cancelActiveGesture(true, true);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (isRangeSelecting.current || isDragging.current) {
|
|
561
|
+
if (isDragging.current) {
|
|
561
562
|
cancelActiveGesture(true, true);
|
|
562
563
|
return;
|
|
563
564
|
}
|
|
564
|
-
// Escape with no marquee clears the whole selection — primary AND set.
|
|
565
|
-
// setSelectedElementId(null) also collapses the multi-select set.
|
|
566
565
|
if (store.selectedElementId || store.selectedElementIds.size > 0) {
|
|
567
566
|
store.setSelectedElementId(null);
|
|
568
567
|
}
|
|
569
568
|
};
|
|
570
|
-
|
|
571
|
-
|
|
569
|
+
const listeners = new AbortController();
|
|
570
|
+
window.addEventListener("keydown", onBandEscape, { capture: true, signal: listeners.signal });
|
|
571
|
+
window.addEventListener("keydown", onKeyDown, { signal: listeners.signal });
|
|
572
|
+
return () => listeners.abort();
|
|
572
573
|
}, [cancelActiveGesture, isDragging]);
|
|
573
574
|
|
|
574
575
|
const previousSessionEpochRef = useRef(sessionEpoch);
|