@hyperframes/studio 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6440 -5514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.10",
51
- "@hyperframes/parsers": "0.8.10",
52
- "@hyperframes/player": "0.8.10",
53
- "@hyperframes/sdk": "0.8.10",
54
- "@hyperframes/studio-server": "0.8.10"
50
+ "@hyperframes/parsers": "0.8.12",
51
+ "@hyperframes/sdk": "0.8.12",
52
+ "@hyperframes/player": "0.8.12",
53
+ "@hyperframes/studio-server": "0.8.12",
54
+ "@hyperframes/core": "0.8.12"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.10"
71
+ "@hyperframes/producer": "0.8.12"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -76,7 +76,7 @@
76
76
  "zustand": "^4.0.0 || ^5.0.0"
77
77
  },
78
78
  "scripts": {
79
- "dev": "vite",
79
+ "dev": "bun --bun ./node_modules/.bin/vite --host 127.0.0.1",
80
80
  "build": "vite build && tsup",
81
81
  "typecheck": "tsc --noEmit",
82
82
  "test": "vitest run",
package/src/App.tsx CHANGED
@@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast";
38
38
  import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
39
39
  import { useStudioUrlState } from "./hooks/useStudioUrlState";
40
40
  import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
41
- import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
42
41
  import {
43
42
  buildStudioContextValue,
44
43
  useGlobalFileDrop,
@@ -82,7 +81,6 @@ export function StudioApp() {
82
81
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
83
82
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
84
83
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
85
- useAudioSoloBridge(previewIframeRef);
86
84
  const activeCompPathRef = useRef(activeCompPath);
87
85
  activeCompPathRef.current = activeCompPath;
88
86
  const leftSidebarRef = useRef<LeftSidebarHandle>(null);
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef } from "react";
1
+ import { useCallback } from "react";
2
2
  import type { StudioRightPanelProps } from "./StudioRightPanel.types";
3
3
 
4
4
  export type { StudioRightPanelProps };
@@ -20,15 +20,15 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
20
20
  import { useFileManagerContext } from "../contexts/FileManagerContext";
21
21
  import { useDomEditContext } from "../contexts/DomEditContext";
22
22
  import { usePlayerStore } from "../player";
23
- import { waitForMediaJob } from "./studioMediaJobs";
24
23
  import {
25
24
  applyColorGradingScopeUpdate,
26
25
  EMPTY_COLOR_GRADING_SCOPE_RESULT,
27
26
  type ColorGradingScope,
28
27
  } from "./studioColorGradingScope";
29
- import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
30
28
  import { timelineKeysForSelections } from "../utils/studioHelpers";
29
+ import { canHideSelections } from "../utils/timelineInspector";
31
30
  import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
31
+ import { useRemoveBackground } from "../hooks/useRemoveBackground";
32
32
 
33
33
  // fallow-ignore-next-line complexity
34
34
  export function StudioRightPanel({
@@ -164,14 +164,6 @@ export function StudioRightPanel({
164
164
  handleInspectorSplitResizeMove,
165
165
  handleInspectorSplitResizeEnd,
166
166
  } = useInspectorSplitResize();
167
- const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
168
-
169
- useEffect(
170
- () => () => {
171
- backgroundRemovalAbortRef.current?.abort();
172
- },
173
- [],
174
- );
175
167
 
176
168
  const renderJobs = renderQueue.jobs as RenderJob[];
177
169
  const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -238,52 +230,7 @@ export function StudioRightPanel({
238
230
  ],
239
231
  );
240
232
 
241
- const handleRemoveBackground = useCallback(
242
- // fallow-ignore-next-line complexity
243
- async (
244
- inputPath: string,
245
- options: {
246
- createBackgroundPlate?: boolean;
247
- quality?: "fast" | "balanced" | "best";
248
- onProgress?: (progress: BackgroundRemovalProgress) => void;
249
- },
250
- ) => {
251
- const response = await fetch(
252
- `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
253
- {
254
- method: "POST",
255
- headers: { "Content-Type": "application/json" },
256
- body: JSON.stringify({
257
- inputPath,
258
- createBackgroundPlate: options.createBackgroundPlate === true,
259
- quality: options.quality ?? "balanced",
260
- }),
261
- },
262
- );
263
- const data = (await response.json().catch(() => ({}))) as {
264
- jobId?: string;
265
- error?: string;
266
- };
267
- if (!response.ok || !data.jobId) {
268
- throw new Error(data.error || `Background removal failed (${response.status})`);
269
- }
270
- showToast("Removing background...", "info");
271
- backgroundRemovalAbortRef.current?.abort();
272
- const controller = new AbortController();
273
- backgroundRemovalAbortRef.current = controller;
274
- try {
275
- const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
276
- await refreshFileTree();
277
- showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
278
- return result;
279
- } finally {
280
- if (backgroundRemovalAbortRef.current === controller) {
281
- backgroundRemovalAbortRef.current = null;
282
- }
283
- }
284
- },
285
- [projectId, refreshFileTree, showToast],
286
- );
233
+ const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
287
234
 
288
235
  /**
289
236
  * A dial being dragged writes to the preview and stops there.
@@ -300,6 +247,17 @@ export function StudioRightPanel({
300
247
  [handleDomAttributeLiveCommit],
301
248
  );
302
249
  const handleHideAllSelected = () => {
250
+ // Audio has no visual to hide, and `data-hidden` on an audio element is what
251
+ // MUTES it — preview silences it and the render drops it from the mix. The
252
+ // timeline withholds the eye on an audio track for that reason
253
+ // (`visible={!isAudioTrack}`), and the single-selection panel gates the same
254
+ // write on `audioSelection`; this multi-selection path was the way back to
255
+ // it. Checked here as well as in the panel because the button is not the
256
+ // only caller.
257
+ if (!canHideSelections(domEditGroupSelections)) {
258
+ showToast("Audio can't be hidden — use the group's own controls", "info");
259
+ return;
260
+ }
303
261
  const { elements } = usePlayerStore.getState();
304
262
  const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
305
263
  if (keys.length > 0) void onToggleElementHidden?.(keys, true);
@@ -22,7 +22,7 @@ afterEach(() => {
22
22
  vi.resetModules();
23
23
  });
24
24
 
25
- function baseElement() {
25
+ function baseElement(): NonNullable<PropertyPanelProps["element"]> {
26
26
  return {
27
27
  element: document.createElement("div"),
28
28
  id: "mono-label",
@@ -81,7 +81,7 @@ function nonTextElement() {
81
81
  // flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
82
82
  // must not double-render the "Text" heading (FlatGroup's own heading; this
83
83
  // component never renders one of its own).
84
- function multiFieldTextElement() {
84
+ function multiFieldTextElement(): NonNullable<PropertyPanelProps["element"]> {
85
85
  const base = baseElement();
86
86
  return {
87
87
  ...base,
@@ -177,6 +177,43 @@ function sixGroupElement() {
177
177
  };
178
178
  }
179
179
 
180
+ /** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
181
+ function audioClipElement() {
182
+ const element = document.createElement("audio");
183
+ return {
184
+ ...baseElement(),
185
+ element,
186
+ id: "vo-1",
187
+ selector: "#vo-1",
188
+ label: "Vo 1",
189
+ tagName: "audio",
190
+ textFields: [],
191
+ dataAttributes: { start: "1", duration: "3" },
192
+ };
193
+ }
194
+
195
+ /**
196
+ * A mixer bus: no clip range at all, and no box either.
197
+ *
198
+ * Carries a `data-start` on purpose. A real bus has none — its automation clock
199
+ * is composition time — but the timing gate has to refuse the TAG rather than
200
+ * merely fall out of a missing attribute, or something writing one would put
201
+ * Start/Duration back on a thing that has no range.
202
+ */
203
+ function audioBusElement() {
204
+ const element = document.createElement("hf-audio-group");
205
+ return {
206
+ ...baseElement(),
207
+ element,
208
+ id: "voiceover",
209
+ selector: "#voiceover",
210
+ label: "Voiceover",
211
+ tagName: "hf-audio-group",
212
+ textFields: [],
213
+ dataAttributes: { start: "0", duration: "8" },
214
+ };
215
+ }
216
+
180
217
  const INFERRED_TIMING_ANIMATION = {
181
218
  id: "a1",
182
219
  targetSelector: "#inferred-anim",
@@ -196,7 +233,7 @@ const INFERRED_TIMING_ANIMATION = {
196
233
 
197
234
  async function renderPanel(
198
235
  flatEnabled: boolean,
199
- elementOverride: ReturnType<typeof baseElement> = baseElement(),
236
+ elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
200
237
  propsOverride: Partial<PropertyPanelProps> = {},
201
238
  currentTime?: number,
202
239
  ) {
@@ -952,3 +989,64 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
952
989
  RENDER_TIMEOUT_MS,
953
990
  );
954
991
  });
992
+
993
+ describe("PropertyPanel — Motion is for things that move", () => {
994
+ it.each([
995
+ ["a custom music tag", () => document.createElement("music")],
996
+ [
997
+ "an element with an audio source",
998
+ () => {
999
+ const element = document.createElement("div");
1000
+ element.setAttribute("src", "voiceover.mp3");
1001
+ return element;
1002
+ },
1003
+ ],
1004
+ ])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
1005
+ const fixture = {
1006
+ ...audioClipElement(),
1007
+ element: makeElement(),
1008
+ tagName: "div",
1009
+ };
1010
+ const { host, root } = await renderPanel(true, fixture);
1011
+ const titles = Array.from(
1012
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1013
+ ).map((node) => node.textContent ?? "");
1014
+ expect(titles.some((title) => title.includes("Motion"))).toBe(false);
1015
+ expect(titles.some((title) => title.includes("Timing"))).toBe(true);
1016
+ act(() => root.unmount());
1017
+ });
1018
+
1019
+ it(
1020
+ "calls the section Timing on an audio clip, and offers no tween editor",
1021
+ async () => {
1022
+ const { host, root } = await renderPanel(true, audioClipElement());
1023
+ const titles = Array.from(
1024
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1025
+ ).map((el) => el.textContent ?? "");
1026
+ // The clip's placement survives — it is still a clip on a track.
1027
+ expect(titles.some((t) => t.includes("Timing"))).toBe(true);
1028
+ // "Motion" named the tween editor, which an <audio> element has no
1029
+ // transform, opacity or box for. Showing it was the panel gating on
1030
+ // handler presence rather than on the element.
1031
+ expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1032
+ act(() => root.unmount());
1033
+ },
1034
+ RENDER_TIMEOUT_MS,
1035
+ );
1036
+
1037
+ it(
1038
+ "offers a bus neither — it has no clip range to edit",
1039
+ async () => {
1040
+ const { host, root } = await renderPanel(true, audioBusElement());
1041
+ const titles = Array.from(
1042
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1043
+ ).map((el) => el.textContent ?? "");
1044
+ expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1045
+ expect(titles.some((t) => t.includes("Timing"))).toBe(false);
1046
+ // It is still a mixer bus: the reason to select one at all.
1047
+ expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
1048
+ act(() => root.unmount());
1049
+ },
1050
+ RENDER_TIMEOUT_MS,
1051
+ );
1052
+ });
@@ -36,6 +36,7 @@ import { type PropertyPanelProps } from "./propertyPanelHelpers";
36
36
  import { GestureRecordPanelButton } from "./GestureRecordControl";
37
37
  import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
38
38
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
39
+ import { isAudioDomElement } from "../../utils/timelineInspector";
39
40
 
40
41
  // Re-export helpers that external consumers import from this module
41
42
  export {
@@ -119,6 +120,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
119
120
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
120
121
  const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
121
122
  const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
123
+ /**
124
+ * An audio element gets no hide control here.
125
+ *
126
+ * On an audio track "hidden" and "muted" are not similar operations, they are
127
+ * the SAME operation with two names (groups doc §2.1) — which is why the
128
+ * timeline's eye became the mute rather than growing a sibling. A second copy
129
+ * in the panel, still called "Hide element", is precisely the thing that step
130
+ * removed: "Two controls that silence a track, sitting next to each other,
131
+ * differing only in a distinction the author cannot see." An
132
+ * `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
133
+ * own row.
134
+ */
135
+ const audioSelection = isAudioDomElement(element?.element);
122
136
  // Live during playback, the store's when paused — see the hook. Shared with the
123
137
  // audio FX panel, which follows the playhead for the same reason: a value the
124
138
  // timeline drives has to be shown moving, not frozen at what the attribute says.
@@ -309,7 +323,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
309
323
  selectedElementId={selectedElementId}
310
324
  selectedElementHidden={selectedElementHidden}
311
325
  visibilityLabel={visibilityToggleLabel}
312
- onToggleHidden={onToggleElementHidden}
326
+ onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
313
327
  />
314
328
  </div>
315
329
  </div>
@@ -71,4 +71,78 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
71
71
  expect(onClearSelection).toHaveBeenCalledTimes(1);
72
72
  act(() => root.unmount());
73
73
  });
74
+
75
+ // A layout group is a positioned wrapper around a bounding box; an <audio>
76
+ // clip has none (offsetWidth/Height are 0), so grouping audio produced a 0x0
77
+ // div with inline left/top on elements that are never laid out. Withheld
78
+ // rather than offered-then-refused.
79
+ const audioElements = (tags: string[]) =>
80
+ tags.map((tag, i) => ({
81
+ id: `el-${i}`,
82
+ selector: `#el-${i}`,
83
+ label: `El ${i}`,
84
+ tagName: tag,
85
+ element: document.createElement(tag),
86
+ })) as unknown as DomEditSelection[];
87
+
88
+ it("withholds both actions when the selection includes audio", () => {
89
+ const { host, root } = renderInto(
90
+ <PropertyPanelEmptyState
91
+ flat
92
+ multiSelectCount={2}
93
+ multiSelectedElements={audioElements(["audio", "audio"])}
94
+ onGroupSelection={vi.fn()}
95
+ onHideAllSelected={vi.fn()}
96
+ />,
97
+ );
98
+ expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
99
+ // Hiding is visibility, and `data-hidden` on audio is what MUTES it — the
100
+ // timeline withholds the eye on an audio track for that reason, and this
101
+ // panel was the way back to the same write.
102
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
103
+ // The list still names what is selected; only the actions go.
104
+ expect(host.textContent).toContain("2 elements selected");
105
+ act(() => root.unmount());
106
+ });
107
+
108
+ it("withholds it for a mixed selection too, since the wrapper would still take audio in", () => {
109
+ const { host, root } = renderInto(
110
+ <PropertyPanelEmptyState
111
+ flat
112
+ multiSelectCount={2}
113
+ multiSelectedElements={audioElements(["div", "audio"])}
114
+ onGroupSelection={vi.fn()}
115
+ />,
116
+ );
117
+ expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
118
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
119
+ act(() => root.unmount());
120
+ });
121
+
122
+ it("counts an <hf-audio-group> bus as audio too", () => {
123
+ const { host, root } = renderInto(
124
+ <PropertyPanelEmptyState
125
+ flat
126
+ multiSelectCount={2}
127
+ multiSelectedElements={audioElements(["hf-audio-group", "div"])}
128
+ onGroupSelection={vi.fn()}
129
+ />,
130
+ );
131
+ expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
132
+ act(() => root.unmount());
133
+ });
134
+
135
+ it("still offers both for a selection of layout elements", () => {
136
+ const { host, root } = renderInto(
137
+ <PropertyPanelEmptyState
138
+ flat
139
+ multiSelectCount={2}
140
+ multiSelectedElements={audioElements(["div", "span"])}
141
+ onGroupSelection={vi.fn()}
142
+ />,
143
+ );
144
+ expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull();
145
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
146
+ act(() => root.unmount());
147
+ });
74
148
  });
@@ -1,5 +1,6 @@
1
1
  import { Eye, Layers } from "../../icons/SystemIcons";
2
2
  import type { DomEditSelection } from "./domEditingTypes";
3
+ import { canHideSelections } from "../../utils/timelineInspector";
3
4
 
4
5
  function FlatEmptyState() {
5
6
  return (
@@ -67,6 +68,9 @@ function FlatMultiSelectState({
67
68
  onHideAllSelected?: () => void;
68
69
  onClearSelection?: () => void;
69
70
  }) {
71
+ // One predicate for both actions and for the handler's own refusal, so the
72
+ // button and the refusal cannot disagree about what audio is.
73
+ const hasAudio = !canHideSelections(multiSelectedElements);
70
74
  return (
71
75
  <div className="flex flex-col gap-3 px-4 py-3">
72
76
  <div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
@@ -123,26 +127,37 @@ function FlatMultiSelectState({
123
127
  );
124
128
  })}
125
129
  </div>
126
- <div className="flex gap-2">
127
- <button
128
- type="button"
129
- data-flat-multiselect-group="true"
130
- onClick={onGroupSelection}
131
- className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
132
- >
133
- <Layers size={13} />
134
- Group selection
135
- </button>
136
- <button
137
- type="button"
138
- data-flat-multiselect-hide-all="true"
139
- onClick={onHideAllSelected}
140
- className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
141
- >
142
- <Eye size={13} />
143
- Hide all
144
- </button>
145
- </div>
130
+ {/* Neither action applies to audio, so the row goes rather than showing
131
+ an empty frame. Grouping is the LAYOUT grouper — a positioned wrapper
132
+ around a bounding box, and an <audio> clip has none (grouping two
133
+ produced a 0x0 div with inline left/top on elements that are never
134
+ laid out). Hiding is visibility, which for audio doubles as mute; the
135
+ timeline already withholds the eye on an audio track
136
+ (`visible={!isAudioTrack}`) and this panel was the way back to the
137
+ same write. Both handlers refuse it too — they own keyboard paths no
138
+ hidden button can gate. */}
139
+ {!hasAudio && (
140
+ <div className="flex gap-2">
141
+ <button
142
+ type="button"
143
+ data-flat-multiselect-group="true"
144
+ onClick={onGroupSelection}
145
+ className="flex h-[34px] flex-1 items-center justify-center gap-2 rounded-lg bg-panel-hover text-[11px] font-semibold text-panel-text-0"
146
+ >
147
+ <Layers size={13} />
148
+ Group selection
149
+ </button>
150
+ <button
151
+ type="button"
152
+ data-flat-multiselect-hide-all="true"
153
+ onClick={onHideAllSelected}
154
+ className="flex h-[34px] items-center gap-1.5 rounded-lg border border-panel-border-input bg-panel-input px-3 text-[11px] font-medium text-panel-text-2"
155
+ >
156
+ <Eye size={13} />
157
+ Hide all
158
+ </button>
159
+ </div>
160
+ )}
146
161
  <span className="text-center text-[10px] text-panel-text-5">
147
162
  Select a single element to edit its properties
148
163
  </span>