diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx
index 6316c410b..e559af8c8 100644
--- a/packages/studio/src/components/StudioRightPanel.tsx
+++ b/packages/studio/src/components/StudioRightPanel.tsx
@@ -26,6 +26,7 @@ import {
type ColorGradingScope,
} from "./studioColorGradingScope";
import { timelineKeysForSelections } from "../utils/studioHelpers";
+import { canHideSelections } from "../utils/timelineInspector";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
import { useRemoveBackground } from "../hooks/useRemoveBackground";
@@ -246,6 +247,17 @@ export function StudioRightPanel({
[handleDomAttributeLiveCommit],
);
const handleHideAllSelected = () => {
+ // Audio has no visual to hide, and `data-hidden` on an audio element is what
+ // MUTES it — preview silences it and the render drops it from the mix. The
+ // timeline withholds the eye on an audio track for that reason
+ // (`visible={!isAudioTrack}`), and the single-selection panel gates the same
+ // write on `audioSelection`; this multi-selection path was the way back to
+ // it. Checked here as well as in the panel because the button is not the
+ // only caller.
+ if (!canHideSelections(domEditGroupSelections)) {
+ showToast("Audio can't be hidden — use the group's own controls", "info");
+ return;
+ }
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
index f7a34a8be..ed2059bfc 100644
--- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx
@@ -85,18 +85,23 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
element: document.createElement(tag),
})) as unknown as DomEditSelection[];
- it("withholds Group selection when the selection includes audio", () => {
+ it("withholds both actions when the selection includes audio", () => {
const { host, root } = renderInto(
,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
- // Hide all still applies — hiding an audio clip is what mutes it.
- expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
+ // Hiding is visibility, and `data-hidden` on audio is what MUTES it — the
+ // timeline withholds the eye on an audio track for that reason, and this
+ // panel was the way back to the same write.
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
+ // The list still names what is selected; only the actions go.
+ expect(host.textContent).toContain("2 elements selected");
act(() => root.unmount());
});
@@ -110,10 +115,24 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
act(() => root.unmount());
});
- it("still offers it for a selection of layout elements", () => {
+ it("counts an bus as audio too", () => {
+ const { host, root } = renderInto(
+ ,
+ );
+ expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
+ act(() => root.unmount());
+ });
+
+ it("still offers both for a selection of layout elements", () => {
const { host, root } = renderInto(
{
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull();
+ expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
act(() => root.unmount());
});
});
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
index d1b0ae0b1..c0037d128 100644
--- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
+++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx
@@ -1,6 +1,6 @@
import { Eye, Layers } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditingTypes";
-import { isAudioDomElement } from "../../utils/timelineInspector";
+import { canHideSelections } from "../../utils/timelineInspector";
function FlatEmptyState() {
return (
@@ -68,7 +68,9 @@ function FlatMultiSelectState({
onHideAllSelected?: () => void;
onClearSelection?: () => void;
}) {
- const hasAudio = multiSelectedElements.some((el) => isAudioDomElement(el.element));
+ // One predicate for both actions and for the handler's own refusal, so the
+ // button and the refusal cannot disagree about what audio is.
+ const hasAudio = !canHideSelections(multiSelectedElements);
return (
@@ -125,14 +127,17 @@ function FlatMultiSelectState({
);
})}
-
- {/* A layout group is a positioned wrapper around a bounding box, and an
-
+ )}
Select a single element to edit its properties
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
index 845f8a27d..3516d68c2 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx
@@ -85,6 +85,7 @@ interface RenderHeaderOptions {
onRemoveAutomationLane?: (target: string) => void;
isAudioTrack?: boolean;
isGroupMember?: boolean;
+ isTrackHidden?: boolean;
}
function renderHeader(options: RenderHeaderOptions = {}): {
@@ -105,6 +106,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
currentTime: 0,
isAudioTrack: false,
isGroupMember: false,
+ isTrackHidden: false,
onToggleTrackHidden: vi.fn(),
...raw,
};
@@ -124,7 +126,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
isExpanded={next.expanded !== false}
animations={next.animations}
currentTime={next.currentTime}
- isTrackHidden={false}
+ isTrackHidden={next.isTrackHidden}
isAudioTrack={next.isAudioTrack}
isGroupMember={next.isGroupMember}
theme={defaultTimelineTheme}
@@ -282,6 +284,27 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount());
});
+ // The escape hatch. `data-hidden` on audio silences it in preview and drops it
+ // from the render; the panel's "Muted" is the unrelated HTML `muted`
+ // attribute, and nothing else writes it. Withholding the eye unconditionally
+ // meant a track hidden by "Hide all" (or by hand, or before that rule existed)
+ // was silent with no control anywhere to bring it back.
+ it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
+ const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
+ const view = renderHeader({
+ keyframeClip: audio,
+ trackElements: [audio],
+ isAudioTrack: true,
+ isTrackHidden: true,
+ animations: [],
+ });
+ const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
+ b.getAttribute("aria-label"),
+ );
+ expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
+ act(() => view.root.unmount());
+ });
+
it("keeps it on a non-audio track", () => {
const view = renderHeader({ isAudioTrack: false });
const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx
index 99bf6571f..ea5714e47 100644
--- a/packages/studio/src/player/components/TimelineTrackHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx
@@ -603,7 +603,8 @@ export function TimelineTrackHeader({
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
- visible={!isAudioTrack}
+ // Audio: only while hidden — see the plain header.
+ visible={!isAudioTrack || isTrackHidden}
onToggle={onToggleTrackHidden}
/>
diff --git a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx
index a3bb3fcbd..44fae3b0d 100644
--- a/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx
+++ b/packages/studio/src/player/components/TimelineTrackPlainHeader.tsx
@@ -99,12 +99,21 @@ export function PlainTrackHeader({
audio it silences rather than hides — but a row that already says what
it is with a speaker does not also need the hide affordance sitting in
the eye's slot. `visible={false}` rather than omitting the element, so
- the spacer keeps every row's control columns aligned. */}
+ the spacer keeps every row's control columns aligned.
+
+ EXCEPT when the audio track is ALREADY hidden. Withholding the control
+ unconditionally withheld the only way back: `data-hidden` silences the
+ clip in preview and drops it from the render, the panel's "Muted" is
+ the unrelated HTML `muted` attribute, and nothing else writes it — so a
+ track hidden before this rule (or by "Hide all", or by hand) was
+ silent with no control anywhere to restore it. Offering the eye only
+ in that state keeps the affordance off a normal audio row while
+ leaving the door open from the inside. */}
{trailing}
diff --git a/packages/studio/src/utils/timelineInspector.test.ts b/packages/studio/src/utils/timelineInspector.test.ts
index 318f3cbb0..fb5cef015 100644
--- a/packages/studio/src/utils/timelineInspector.test.ts
+++ b/packages/studio/src/utils/timelineInspector.test.ts
@@ -1,5 +1,12 @@
+// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
-import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
+import {
+ canHideSelections,
+ isAudioDomElement,
+ isAudioTimelineElement,
+ isMusicTrack,
+ resolveBeatSourceTrack,
+} from "./timelineInspector";
import type { TimelineElement } from "../player";
// Minimal element factory for tests
@@ -119,3 +126,30 @@ describe("resolveBeatSourceTrack", () => {
expect(result!.isFallback).toBe(true);
});
});
+
+describe("isAudioDomElement / canHideSelections", () => {
+ const el = (tag: string, src?: string): Element => {
+ const node = document.createElement(tag);
+ if (src) node.setAttribute("src", src);
+ return node;
+ };
+
+ it("agrees with isAudioTimelineElement about tags and source extensions", () => {
+ expect(isAudioDomElement(el("audio"))).toBe(true);
+ expect(isAudioDomElement(el("div", "narration.mp3"))).toBe(true);
+ expect(isAudioDomElement(el("div"))).toBe(false);
+ expect(isAudioDomElement(null)).toBe(false);
+ });
+
+ it("counts a group bus as audio, the way the single-selection panel does", () => {
+ expect(isAudioDomElement(el("hf-audio-group"))).toBe(true);
+ });
+
+ // `data-hidden` on audio is what mutes it — preview silences it and the render
+ // drops it from the mix. A control labelled "Hide all" must not reach that.
+ it("refuses to hide a selection holding any audio, and allows a layout one", () => {
+ expect(canHideSelections([{ element: el("div") }, { element: el("span") }])).toBe(true);
+ expect(canHideSelections([{ element: el("div") }, { element: el("audio") }])).toBe(false);
+ expect(canHideSelections([{ element: el("hf-audio-group") }])).toBe(false);
+ });
+});
diff --git a/packages/studio/src/utils/timelineInspector.ts b/packages/studio/src/utils/timelineInspector.ts
index 1a80bca4c..af4b053b9 100644
--- a/packages/studio/src/utils/timelineInspector.ts
+++ b/packages/studio/src/utils/timelineInspector.ts
@@ -1,3 +1,4 @@
+import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import type { TimelineElement } from "../player";
const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narration"]);
@@ -15,6 +16,9 @@ const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
*/
export function isAudioDomElement(node: Element | null | undefined): boolean {
if (!node) return false;
+ // A group bus counts: it is audio-only, and the panel's single-select path
+ // already treats `` as audio for exactly these decisions.
+ if (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
return isAudioTimelineElement({
tag: node.tagName,
src: node.getAttribute("src") ?? undefined,
@@ -77,3 +81,20 @@ export function resolveBeatSourceTrack(
}
return best ? { element: best, isFallback: true } : null;
}
+
+/**
+ * May this multi-selection be hidden as one action?
+ *
+ * Audio has no visual to hide, and `data-hidden` on an audio element is what
+ * MUTES it — preview silences it and the render drops it from the mix. The
+ * timeline withholds the eye on an audio track for that reason
+ * (`visible={!isAudioTrack}`), and the single-selection panel gates the same
+ * write on `audioSelection`. The multi-selection "Hide all" was the one path
+ * left back to it, on a control whose label promises visibility.
+ *
+ * A shared predicate rather than a check in the handler so the panel's button
+ * and the handler's refusal cannot disagree — the button is not the only caller.
+ */
+export function canHideSelections(selections: readonly { element?: Element | null }[]): boolean {
+ return !selections.some((selection) => isAudioDomElement(selection.element));
+}