Skip to content

Commit 0c274f7

Browse files
authored
fix(studio): reconnect property-panel audio controls (#3453)
* fix(studio): reconnect property-panel audio controls * fix(studio): unify property panel audio detection * fix(studio): satisfy panel and deletion gates
1 parent f575bda commit 0c274f7

19 files changed

Lines changed: 781 additions & 170 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: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ afterEach(() => {
2222
vi.resetModules();
2323
});
2424

25-
function baseElement() {
25+
function baseElement(): NonNullable<PropertyPanelProps["element"]> {
2626
return {
2727
element: document.createElement("div"),
2828
id: "mono-label",
@@ -81,7 +81,7 @@ function nonTextElement() {
8181
// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
8282
// must not double-render the "Text" heading (FlatGroup's own heading; this
8383
// component never renders one of its own).
84-
function multiFieldTextElement() {
84+
function multiFieldTextElement(): NonNullable<PropertyPanelProps["element"]> {
8585
const base = baseElement();
8686
return {
8787
...base,
@@ -177,6 +177,43 @@ function sixGroupElement() {
177177
};
178178
}
179179

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+
180217
const INFERRED_TIMING_ANIMATION = {
181218
id: "a1",
182219
targetSelector: "#inferred-anim",
@@ -196,7 +233,7 @@ const INFERRED_TIMING_ANIMATION = {
196233

197234
async function renderPanel(
198235
flatEnabled: boolean,
199-
elementOverride: ReturnType<typeof baseElement> = baseElement(),
236+
elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
200237
propsOverride: Partial<PropertyPanelProps> = {},
201238
currentTime?: number,
202239
) {
@@ -952,3 +989,64 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
952989
RENDER_TIMEOUT_MS,
953990
);
954991
});
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+
});

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

Lines changed: 15 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 { isAudioDomElement } from "../../utils/timelineInspector";
3940

4041
// Re-export helpers that external consumers import from this module
4142
export {
@@ -119,6 +120,19 @@ 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 audioSelection = isAudioDomElement(element?.element);
122136
// Live during playback, the store's when paused — see the hook. Shared with the
123137
// audio FX panel, which follows the playhead for the same reason: a value the
124138
// 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
309323
selectedElementId={selectedElementId}
310324
selectedElementHidden={selectedElementHidden}
311325
visibilityLabel={visibilityToggleLabel}
312-
onToggleHidden={onToggleElementHidden}
326+
onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
313327
/>
314328
</div>
315329
</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)