@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.
Files changed (96) hide show
  1. package/dist/assets/hyperframes-player-w3Bbv0kd.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B6PGJTzp.js} +1 -1
  3. package/dist/assets/{index-d5DKK15Q.js → index-DE6cZYR5.js} +199 -199
  4. package/dist/assets/{index-8Fgryj7q.js → index-DldknZPp.js} +1 -1
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
  7. package/dist/chunk-KU4F7QZ3.js.map +1 -0
  8. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  9. package/dist/chunk-PQFEGZWM.js.map +1 -0
  10. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  11. package/dist/index.d.ts +206 -160
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2121 -1702
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
  19. package/src/components/TimelineToolPicker.tsx +90 -0
  20. package/src/components/TimelineToolbar.tsx +2 -27
  21. package/src/components/editor/DomEditOverlay.tsx +1 -2
  22. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  23. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  24. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  25. package/src/components/editor/LayersPanel.test.ts +14 -0
  26. package/src/components/editor/LayersPanel.tsx +2 -2
  27. package/src/components/editor/TopologyLens.tsx +2 -2
  28. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  29. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  30. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  31. package/src/components/editor/domEditing.test.ts +29 -0
  32. package/src/components/editor/domEditingDom.ts +1 -0
  33. package/src/components/editor/domEditingTypes.ts +1 -0
  34. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  35. package/src/components/editor/marqueeCommit.ts +13 -1
  36. package/src/components/editor/useColorGradingController.test.ts +24 -0
  37. package/src/components/editor/useColorGradingController.ts +3 -2
  38. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  39. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  40. package/src/components/nle/useCompositionStack.ts +4 -8
  41. package/src/hooks/appHotkeysDispatch.ts +8 -0
  42. package/src/hooks/domStyleCommit.ts +129 -0
  43. package/src/hooks/gsapEditOutcome.ts +2 -2
  44. package/src/hooks/gsapElementMatch.ts +38 -0
  45. package/src/hooks/gsapResizeIntercept.ts +10 -31
  46. package/src/hooks/gsapResizePreflight.ts +56 -0
  47. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  48. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  49. package/src/hooks/useAppHotkeys.test.ts +58 -1
  50. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  51. package/src/hooks/useDomEditCommits.ts +17 -182
  52. package/src/hooks/useDomEditPersist.ts +225 -0
  53. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  54. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  55. package/src/hooks/useDomEditSession.test.tsx +51 -1
  56. package/src/hooks/useDomEditSession.ts +11 -2
  57. package/src/hooks/useDomEditTextCommits.ts +15 -99
  58. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  59. package/src/hooks/useDomStyleCommit.ts +74 -0
  60. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  61. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  62. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  63. package/src/hooks/useGsapAwareEditing.ts +1 -1
  64. package/src/hooks/useGsapTweenCache.ts +2 -48
  65. package/src/index.ts +10 -1
  66. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  67. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  68. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  69. package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
  70. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  71. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  72. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  73. package/src/player/components/TimelineGroupRow.tsx +4 -1
  74. package/src/player/components/TimelineLanes.tsx +5 -5
  75. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  77. package/src/player/components/TimelineTypes.ts +2 -0
  78. package/src/player/components/studioShortcuts.ts +2 -0
  79. package/src/player/components/timelineLaneProps.ts +1 -0
  80. package/src/player/components/timelineMountTestEnv.ts +29 -0
  81. package/src/player/components/useTimelineKeyframeData.ts +14 -0
  82. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  83. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  84. package/src/player/components/useTimelineProviderState.tsx +10 -14
  85. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  86. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  87. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  88. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  89. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  90. package/src/player/sceneSwap.test.ts +95 -0
  91. package/src/player/sceneSwap.ts +53 -0
  92. package/src/player/store/playerStore.ts +16 -0
  93. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  94. package/dist/chunk-3IOCVWB6.js.map +0 -1
  95. package/dist/chunk-7PLTC6SV.js.map +0 -1
  96. /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 label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
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
- <TimelineFxButton
145
- fxChainRaw={fxChain}
146
- onChainChange={onFxChainChange}
147
- onChainPreview={onFxChainPreview}
148
- auditionSpans={auditionSpans}
149
- onOpenRack={onOpenFxRack}
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={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67
- 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)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
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 { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
119
- const groupMemberTracks = useMemo(
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
- {clipCount > 1 &&
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
@@ -33,4 +33,6 @@ export interface TimelineProps
33
33
  onDuplicateClip?: () => Promise<boolean>;
34
34
  canPasteClip?: () => boolean;
35
35
  theme?: Partial<TimelineTheme>;
36
+ showAudioEffects?: boolean;
37
+ showKeyframes?: boolean;
36
38
  }
@@ -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)" },
@@ -29,6 +29,7 @@ export interface TimelineLaneBaseProps {
29
29
  contentGutter: number;
30
30
  trackContentWidth: number;
31
31
  theme: TimelineTheme;
32
+ showAudioEffects?: boolean;
32
33
  displayTrackOrder: number[];
33
34
  rowHeights: readonly number[];
34
35
  rowGeometry: TimelineRowGeometry;
@@ -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
- return { logicalRowsByTrack, groupByAnchor };
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, TRACKS_LEFT_PAD } from "./timelineLayout";
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 = usePlayerStore((s) => s.gsapAnimations);
110
- const labelMode = useMemo(
111
- () => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
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: cancel an in-flight marquee (restores the pre-drag selection);
550
- // otherwise clear any lingering multi-selection.
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
- const marquee = marqueeRef.current;
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
- window.addEventListener("keydown", onKeyDown);
571
- return () => window.removeEventListener("keydown", onKeyDown);
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);