@hyperframes/studio 0.8.85 → 0.8.87

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 (95) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Bus1BKtk.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-CZxhjQpw.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-ClLdmgua.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.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 +207 -161
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +2084 -1705
  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/TimelineToolbar.test.tsx +53 -2
  19. package/src/components/TimelineToolbar.tsx +119 -101
  20. package/src/components/editor/DomEditOverlay.tsx +1 -2
  21. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  23. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  24. package/src/components/editor/LayersPanel.test.ts +14 -0
  25. package/src/components/editor/LayersPanel.tsx +2 -2
  26. package/src/components/editor/TopologyLens.tsx +2 -2
  27. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  28. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  29. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  30. package/src/components/editor/domEditing.test.ts +29 -0
  31. package/src/components/editor/domEditingDom.ts +1 -0
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  34. package/src/components/editor/marqueeCommit.ts +13 -1
  35. package/src/components/editor/useColorGradingController.test.ts +24 -0
  36. package/src/components/editor/useColorGradingController.ts +3 -2
  37. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  38. package/src/components/nle/useCompositionStack.test.tsx +179 -0
  39. package/src/components/nle/useCompositionStack.ts +26 -20
  40. package/src/hooks/domStyleCommit.ts +129 -0
  41. package/src/hooks/gsapEditOutcome.ts +2 -2
  42. package/src/hooks/gsapElementMatch.ts +38 -0
  43. package/src/hooks/gsapResizeIntercept.ts +10 -31
  44. package/src/hooks/gsapResizePreflight.ts +56 -0
  45. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  46. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  47. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  48. package/src/hooks/useDomEditCommits.ts +17 -182
  49. package/src/hooks/useDomEditPersist.ts +225 -0
  50. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  51. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  52. package/src/hooks/useDomEditSession.test.tsx +51 -1
  53. package/src/hooks/useDomEditSession.ts +11 -2
  54. package/src/hooks/useDomEditTextCommits.ts +15 -99
  55. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  56. package/src/hooks/useDomStyleCommit.ts +74 -0
  57. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  59. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  60. package/src/hooks/useGsapAwareEditing.ts +1 -1
  61. package/src/hooks/useGsapTweenCache.ts +2 -48
  62. package/src/index.ts +10 -1
  63. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  64. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  65. package/src/player/components/Timeline.playheadRemount.test.tsx +41 -0
  66. package/src/player/components/Timeline.test.ts +32 -19
  67. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  68. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  69. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  70. package/src/player/components/TimelineGroupRow.tsx +4 -1
  71. package/src/player/components/TimelineLanes.tsx +5 -5
  72. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  73. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  74. package/src/player/components/TimelineTypes.ts +1 -0
  75. package/src/player/components/timelineLaneProps.ts +1 -0
  76. package/src/player/components/timelineLayout.ts +16 -23
  77. package/src/player/components/timelineMountTestEnv.ts +29 -0
  78. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  79. package/src/player/components/useTimelineGeometry.ts +18 -10
  80. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  81. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  82. package/src/player/components/useTimelinePlayhead.ts +1 -1
  83. package/src/player/components/useTimelineProviderState.tsx +6 -12
  84. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  85. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  86. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  87. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  88. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  89. package/src/player/sceneSwap.test.ts +95 -0
  90. package/src/player/sceneSwap.ts +53 -0
  91. package/src/player/store/liveTime.ts +6 -1
  92. package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
  93. package/dist/chunk-3IOCVWB6.js.map +0 -1
  94. package/dist/chunk-7VDQIGOU.js.map +0 -1
  95. /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
@@ -213,7 +213,9 @@ describe("Timeline provider boundary", () => {
213
213
  100,
214
214
  ).start,
215
215
  ).toBe(10);
216
- expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1);
216
+ expect(getTimelineFitPps(640, 11, GUTTER)).toBeCloseTo(
217
+ (640 - GUTTER - 2) / (11 * FIT_ZOOM_HEADROOM),
218
+ );
217
219
 
218
220
  act(() => root.unmount());
219
221
  });
@@ -313,7 +315,7 @@ describe("Timeline provider boundary", () => {
313
315
  expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
314
316
  expect(collapsedHeader.textContent).toContain("Outro");
315
317
  expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
316
- (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S,
318
+ (640 - (LABEL_COL_W + GUTTER) - 2) / (20 * FIT_ZOOM_HEADROOM),
317
319
  );
318
320
  expect(
319
321
  resolveTimelineAssetDrop(
@@ -948,15 +950,13 @@ describe("shouldAutoScrollTimeline", () => {
948
950
  });
949
951
  });
950
952
 
951
- describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
953
+ describe("getTimelineFitPps (fit headroom, 60s floor only without a duration)", () => {
952
954
  const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
953
955
 
954
- it("computes fit pps against the 60s floor for short compositions", () => {
955
- // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
956
- // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
957
- const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD);
958
- expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
959
- expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
956
+ it("fits a short film to the width, not to a 60s ruler", () => {
957
+ const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
958
+ const pps = getTimelineFitPps(viewport, 17, GUTTER + TRACKS_LEFT_PAD);
959
+ expect(17 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
960
960
  });
961
961
 
962
962
  it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
@@ -1000,11 +1000,10 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
1000
1000
  });
1001
1001
 
1002
1002
  describe("getTimelineDisplayContentWidth", () => {
1003
- it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
1004
- // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
1003
+ it("spans MIN_TIMELINE_EXTENT_S seconds while the duration is unknown", () => {
1005
1004
  expect(
1006
1005
  getTimelineDisplayContentWidth({
1007
- trackContentWidth: 200,
1006
+ effectiveDuration: 0,
1008
1007
  viewportWidth: 400,
1009
1008
  contentOrigin: GUTTER,
1010
1009
  pps: 20,
@@ -1012,10 +1011,24 @@ describe("getTimelineDisplayContentWidth", () => {
1012
1011
  ).toBe(MIN_TIMELINE_EXTENT_S * 20);
1013
1012
  });
1014
1013
 
1015
- it("still fills the viewport when that is larger than the 60s floor", () => {
1014
+ it("renders a short film at fit exactly as wide as the viewport, so fit never scrolls", () => {
1015
+ const viewport = 632;
1016
+ const origin = GUTTER + TRACKS_LEFT_PAD;
1017
+ const pps = getTimelineFitPps(viewport, 17, origin);
1018
+ expect(
1019
+ getTimelineDisplayContentWidth({
1020
+ effectiveDuration: 17,
1021
+ viewportWidth: viewport,
1022
+ contentOrigin: origin,
1023
+ pps,
1024
+ }),
1025
+ ).toBeCloseTo(viewport - origin - 2);
1026
+ });
1027
+
1028
+ it("still fills the viewport when that is larger than the fit span", () => {
1016
1029
  expect(
1017
1030
  getTimelineDisplayContentWidth({
1018
- trackContentWidth: 200,
1031
+ effectiveDuration: 40,
1019
1032
  viewportWidth: 2000,
1020
1033
  contentOrigin: GUTTER + TRACKS_LEFT_PAD,
1021
1034
  pps: 5,
@@ -1026,7 +1039,7 @@ describe("getTimelineDisplayContentWidth", () => {
1026
1039
  it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
1027
1040
  expect(
1028
1041
  getTimelineDisplayContentWidth({
1029
- trackContentWidth: 500,
1042
+ effectiveDuration: 100,
1030
1043
  viewportWidth: 400,
1031
1044
  contentOrigin: GUTTER,
1032
1045
  pps: 5,
@@ -1038,7 +1051,7 @@ describe("getTimelineDisplayContentWidth", () => {
1038
1051
  it("tracks a resize (trim) ghost past every other bound (trim-to-extend)", () => {
1039
1052
  expect(
1040
1053
  getTimelineDisplayContentWidth({
1041
- trackContentWidth: 500,
1054
+ effectiveDuration: 100,
1042
1055
  viewportWidth: 400,
1043
1056
  contentOrigin: GUTTER,
1044
1057
  pps: 5,
@@ -1047,15 +1060,15 @@ describe("getTimelineDisplayContentWidth", () => {
1047
1060
  ).toBe(4200);
1048
1061
  });
1049
1062
 
1050
- it("keeps long content authoritative", () => {
1063
+ it("keeps the fit headroom past the end when zoomed in", () => {
1051
1064
  expect(
1052
1065
  getTimelineDisplayContentWidth({
1053
- trackContentWidth: 9000,
1066
+ effectiveDuration: 180,
1054
1067
  viewportWidth: 400,
1055
1068
  contentOrigin: GUTTER,
1056
1069
  pps: 50,
1057
1070
  }),
1058
- ).toBe(9000);
1071
+ ).toBeCloseTo(180 * FIT_ZOOM_HEADROOM * 50);
1059
1072
  });
1060
1073
  });
1061
1074
 
@@ -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,5 @@ export interface TimelineProps
33
33
  onDuplicateClip?: () => Promise<boolean>;
34
34
  canPasteClip?: () => boolean;
35
35
  theme?: Partial<TimelineTheme>;
36
+ showAudioEffects?: boolean;
36
37
  }
@@ -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;
@@ -281,57 +281,51 @@ export function getTimelineInsertBoundaryBand(rowHeight: number): number {
281
281
  * rendered width (see Timeline.tsx displayContentWidth).
282
282
  */
283
283
  export const DRAG_EXTEND_MARGIN_PX = 160;
284
- /**
285
- * The rendered timeline always spans at least this many seconds of ruler +
286
- * track lanes, even when the composition is shorter — the empty space on the
287
- * right is a real, drag/drop-enabled surface (clips can be moved into it; the
288
- * composition grows on commit, content-driven). In fit mode the fit pps is
289
- * derived against this floor, so a 10s comp renders as ~1/6 of the viewport
290
- * with 60s of ruler after it.
291
- */
284
+ /** The span the timeline maps while the composition's duration is unknown or 0. */
292
285
  export const MIN_TIMELINE_EXTENT_S = 60;
293
286
  /**
294
287
  * Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
295
288
  * duration — onto the viewport, so the composition ends at ~83% of the width
296
289
  * and the trailing ~17% stays empty ruler + droppable lane surface (room to
297
290
  * drag clips past the current end without first zooming out). Applied ONLY
298
- * inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
299
- * lanes, playhead, marquee, and drag math all inherit it consistently. Manual
291
+ * inside {@link getTimelineFitSpan}, which fit pps and the drawn width share, so
292
+ * the ruler, lanes, playhead, marquee, and drag math inherit it. Manual
300
293
  * zoom percentages stay defined relative to this fit basis (100% == fit).
301
294
  */
302
295
  export const FIT_ZOOM_HEADROOM = 1.2;
303
296
 
304
297
  /* ── Tick generation ──────────────────────────────────────────────── */
305
298
  /* ── Width / duration derivation ──────────────────────────────────── */
299
+ /** Seconds the timeline maps: the composition plus FIT_ZOOM_HEADROOM, or the floor while its duration is unknown. */
300
+ function getTimelineFitSpan(effectiveDuration: number): number {
301
+ return Number.isFinite(effectiveDuration) && effectiveDuration > 0
302
+ ? effectiveDuration * FIT_ZOOM_HEADROOM
303
+ : MIN_TIMELINE_EXTENT_S;
304
+ }
305
+
306
306
  /**
307
307
  * Fit-mode pixels-per-second: fill the viewport with the composition plus
308
308
  * FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
309
- * into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
310
- * seconds onto it — a short comp takes a fraction of the width and the
311
- * remaining ruler runs to 1:00.
312
- * Manual zoom multiplies this base, so the floor only anchors the default.
309
+ * into the right edge). Manual zoom multiplies this base.
313
310
  */
314
311
  export function getTimelineFitPps(
315
312
  viewportWidth: number,
316
313
  effectiveDuration: number,
317
314
  contentOrigin: number,
318
315
  ): number {
319
- const safeDuration =
320
- Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
321
- const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
322
316
  if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
323
- return (viewportWidth - contentOrigin - 2) / span;
317
+ return (viewportWidth - contentOrigin - 2) / getTimelineFitSpan(effectiveDuration);
324
318
  }
325
319
 
326
320
  /**
327
321
  * The rendered timeline extent in px. Always covers, whichever is largest:
328
- * the actual clip content, the visible viewport (no dead black after short
322
+ * the visible viewport (no dead black after short
329
323
  * content — CapCut-style), a live drag or resize ghost plus the auto-scroll
330
- * margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
324
+ * margin (drag/trim-to-extend), and the fit span at this zoom. Only the
331
325
  * RENDERED extent grows; clip positions/durations are untouched.
332
326
  */
333
327
  export function getTimelineDisplayContentWidth(input: {
334
- trackContentWidth: number;
328
+ effectiveDuration: number;
335
329
  viewportWidth: number;
336
330
  contentOrigin: number;
337
331
  pps: number;
@@ -340,11 +334,10 @@ export function getTimelineDisplayContentWidth(input: {
340
334
  }): number {
341
335
  const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
342
336
  return Math.max(
343
- input.trackContentWidth,
344
337
  input.viewportWidth - input.contentOrigin - 2,
345
338
  input.dragGhostEndPx ?? 0,
346
339
  input.resizeGhostEndPx ?? 0,
347
- MIN_TIMELINE_EXTENT_S * safePps,
340
+ getTimelineFitSpan(input.effectiveDuration) * safePps,
348
341
  );
349
342
  }
350
343
 
@@ -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
+ }