Skip to content

Commit 1e187cd

Browse files
committed
fix(studio): reconnect property-panel audio controls
1 parent eef80e2 commit 1e187cd

17 files changed

Lines changed: 750 additions & 107 deletions

packages/studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"types": "./dist/index.d.ts"
4646
},
4747
"scripts": {
48-
"dev": "vite",
48+
"dev": "bun --bun ./node_modules/.bin/vite --host 127.0.0.1",
4949
"build": "vite build && tsup",
5050
"typecheck": "tsc --noEmit",
5151
"test": "vitest run",

packages/studio/src/App.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast";
3838
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
3939
import { useStudioUrlState } from "./hooks/useStudioUrlState";
4040
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
41-
import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
4241
import {
4342
buildStudioContextValue,
4443
useGlobalFileDrop,
@@ -82,7 +81,6 @@ export function StudioApp() {
8281
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
8382
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
8483
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
85-
useAudioSoloBridge(previewIframeRef);
8684
const activeCompPathRef = useRef(activeCompPath);
8785
activeCompPathRef.current = activeCompPath;
8886
const leftSidebarRef = useRef<LeftSidebarHandle>(null);

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef } from "react";
1+
import { useCallback } from "react";
22
import type { StudioRightPanelProps } from "./StudioRightPanel.types";
33

44
export type { StudioRightPanelProps };
@@ -20,15 +20,15 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
2020
import { useFileManagerContext } from "../contexts/FileManagerContext";
2121
import { useDomEditContext } from "../contexts/DomEditContext";
2222
import { usePlayerStore } from "../player";
23-
import { waitForMediaJob } from "./studioMediaJobs";
2423
import {
2524
applyColorGradingScopeUpdate,
2625
EMPTY_COLOR_GRADING_SCOPE_RESULT,
2726
type ColorGradingScope,
2827
} from "./studioColorGradingScope";
29-
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
3028
import { timelineKeysForSelections } from "../utils/studioHelpers";
29+
import { canHideSelections } from "../utils/timelineInspector";
3130
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
31+
import { useRemoveBackground } from "../hooks/useRemoveBackground";
3232

3333
// fallow-ignore-next-line complexity
3434
export function StudioRightPanel({
@@ -164,14 +164,6 @@ export function StudioRightPanel({
164164
handleInspectorSplitResizeMove,
165165
handleInspectorSplitResizeEnd,
166166
} = useInspectorSplitResize();
167-
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
168-
169-
useEffect(
170-
() => () => {
171-
backgroundRemovalAbortRef.current?.abort();
172-
},
173-
[],
174-
);
175167

176168
const renderJobs = renderQueue.jobs as RenderJob[];
177169
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -238,52 +230,7 @@ export function StudioRightPanel({
238230
],
239231
);
240232

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);
287234

288235
/**
289236
* A dial being dragged writes to the preview and stops there.
@@ -300,6 +247,17 @@ export function StudioRightPanel({
300247
[handleDomAttributeLiveCommit],
301248
);
302249
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+
}
303261
const { elements } = usePlayerStore.getState();
304262
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
305263
if (keys.length > 0) void onToggleElementHidden?.(keys, true);

packages/studio/src/components/editor/PropertyPanel.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,39 @@ function sixGroupElement() {
177177
};
178178
}
179179

180+
/** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
181+
function audioClipElement() {
182+
return {
183+
...baseElement(),
184+
id: "vo-1",
185+
selector: "#vo-1",
186+
label: "Vo 1",
187+
tagName: "audio",
188+
textFields: [],
189+
dataAttributes: { start: "1", duration: "3" },
190+
};
191+
}
192+
193+
/**
194+
* A mixer bus: no clip range at all, and no box either.
195+
*
196+
* Carries a `data-start` on purpose. A real bus has none — its automation clock
197+
* is composition time — but the timing gate has to refuse the TAG rather than
198+
* merely fall out of a missing attribute, or something writing one would put
199+
* Start/Duration back on a thing that has no range.
200+
*/
201+
function audioBusElement() {
202+
return {
203+
...baseElement(),
204+
id: "voiceover",
205+
selector: "#voiceover",
206+
label: "Voiceover",
207+
tagName: "hf-audio-group",
208+
textFields: [],
209+
dataAttributes: { start: "0", duration: "8" },
210+
};
211+
}
212+
180213
const INFERRED_TIMING_ANIMATION = {
181214
id: "a1",
182215
targetSelector: "#inferred-anim",
@@ -952,3 +985,39 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
952985
RENDER_TIMEOUT_MS,
953986
);
954987
});
988+
989+
describe("PropertyPanel — Motion is for things that move", () => {
990+
it(
991+
"calls the section Timing on an audio clip, and offers no tween editor",
992+
async () => {
993+
const { host, root } = await renderPanel(true, audioClipElement());
994+
const titles = Array.from(
995+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
996+
).map((el) => el.textContent ?? "");
997+
// The clip's placement survives — it is still a clip on a track.
998+
expect(titles.some((t) => t.includes("Timing"))).toBe(true);
999+
// "Motion" named the tween editor, which an <audio> element has no
1000+
// transform, opacity or box for. Showing it was the panel gating on
1001+
// handler presence rather than on the element.
1002+
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1003+
act(() => root.unmount());
1004+
},
1005+
RENDER_TIMEOUT_MS,
1006+
);
1007+
1008+
it(
1009+
"offers a bus neither — it has no clip range to edit",
1010+
async () => {
1011+
const { host, root } = await renderPanel(true, audioBusElement());
1012+
const titles = Array.from(
1013+
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1014+
).map((el) => el.textContent ?? "");
1015+
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1016+
expect(titles.some((t) => t.includes("Timing"))).toBe(false);
1017+
// It is still a mixer bus: the reason to select one at all.
1018+
expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
1019+
act(() => root.unmount());
1020+
},
1021+
RENDER_TIMEOUT_MS,
1022+
);
1023+
});

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { type PropertyPanelProps } from "./propertyPanelHelpers";
3636
import { GestureRecordPanelButton } from "./GestureRecordControl";
3737
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
3838
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
39+
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
3940

4041
// Re-export helpers that external consumers import from this module
4142
export {
@@ -119,6 +120,20 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
119120
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
120121
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
121122
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 selectedTag = element?.tagName?.toLowerCase();
136+
const audioSelection = selectedTag === "audio" || selectedTag === HF_AUDIO_GROUP_TAG;
122137
// Live during playback, the store's when paused — see the hook. Shared with the
123138
// audio FX panel, which follows the playhead for the same reason: a value the
124139
// timeline drives has to be shown moving, not frozen at what the attribute says.
@@ -309,7 +324,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
309324
selectedElementId={selectedElementId}
310325
selectedElementHidden={selectedElementHidden}
311326
visibilityLabel={visibilityToggleLabel}
312-
onToggleHidden={onToggleElementHidden}
327+
onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
313328
/>
314329
</div>
315330
</div>

packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,78 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
7171
expect(onClearSelection).toHaveBeenCalledTimes(1);
7272
act(() => root.unmount());
7373
});
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+
});
74148
});

0 commit comments

Comments
 (0)