From 6f0cbb0392a4eea0a30973ac9777639725057e11 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 18 Aug 2026 15:50:51 -0700 Subject: [PATCH] feat(studio): open a group's rack from its row header, and let the rack say what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group is an element carrying `data-fx-chain`, so selecting one IS opening its rack — but nothing on the row said so, and the FX popover's footer was the only route in. Clicking the group's name now does it. Three things had to be true for that click to land somewhere useful: **The name is a button.** Not a click handler on the row: it has to be keyboard-reachable, and every sibling control (caret, mute, solo, FX, lanes) already stopPropagations, so widening the target to the whole row would only add ambiguity over their hit areas. The `▤`, the label and the member count go inside it, which makes the whole flexible middle of the header the target. **The panel opens on the rack.** `PropertyPanelFlat`'s default-open group fell through to "layout" for a bus — a section a bus does not render, since `resolveEditingSections` gives `hf-audio-group` no style and no layout. So the selection landed on a panel with everything collapsed. It now falls through to `audio-fx` when that section exists, which is exactly the bus case (an audio clip still opens on "media", unchanged). **The rack stops calling a group a track.** Its `In`/`Out` lines were hardcoded to a clip's answer. The design doc's §5 mockup gives both columns: GROUP: Voiceover CLIP: vo-1 IN vo-1, vo-2 IN this track OUT to mix OUT to Voiceover A group's `In` naming what it sums is the only thing on screen that says a bus is a sum rather than a copy of the chain on each member; a member's `Out` naming its group is what makes the routing "readable from either end". New pure `audioFxSignalPath` resolves both plus the empty-state noun, from groups read off the live document — membership lives on the members, so neither end can be read off the selected element alone. Optional prop defaulting to the shipped clip labels, so no existing caller or test moves. Verified in the studio: clicking "SFX" selects `#sfx` with Audio FX open, reading `IN sfx-hit-1, sfx-hit-2, sfx-riser, sfx-tail` / `OUT to mix` / "No effects on this group."; selecting a member reads `IN this track` / `OUT to SFX`. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4328 green. --- .../components/editor/PropertyPanelFlat.tsx | 8 ++- .../editor/audioFxSignalPath.test.ts | 47 +++++++++++++++ .../components/editor/audioFxSignalPath.ts | 59 +++++++++++++++++++ .../editor/propertyPanelAudioFxGroup.tsx | 17 +++++- .../editor/propertyPanelFxRackChain.tsx | 17 ++++-- .../editor/propertyPanelFxSection.tsx | 3 + .../editor/propertyPanelFxSectionTypes.ts | 4 ++ .../player/components/TimelineGroupHeader.tsx | 42 +++++++++---- 8 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 packages/studio/src/components/editor/audioFxSignalPath.test.ts create mode 100644 packages/studio/src/components/editor/audioFxSignalPath.ts diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index af0d89d7a..ec03f6a40 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -136,7 +136,13 @@ export function PropertyPanelFlat({ ? "style" : sections.media ? "media" - : "layout", + : // An `` has no style, no layout and no media — its + // chain is the only reason to select one. Without this the fallback + // landed on "layout", a section a bus does not render, so opening the + // rack on a group produced a panel with everything collapsed. + sections.audioFx + ? "audio-fx" + : "layout", ); // Tracks which group(s) are actively transitioning this toggle cycle, so diff --git a/packages/studio/src/components/editor/audioFxSignalPath.test.ts b/packages/studio/src/components/editor/audioFxSignalPath.test.ts new file mode 100644 index 000000000..b1e56ac0a --- /dev/null +++ b/packages/studio/src/components/editor/audioFxSignalPath.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { HfAudioGroup } from "@hyperframes/core/audio-groups"; +import { audioFxSignalPath } from "./audioFxSignalPath"; + +const group = (over: Partial = {}): HfAudioGroup => ({ + id: "voiceover", + label: "Voiceover", + memberIds: ["vo-1", "vo-2"], + volume: 1, + hidden: false, + ...over, +}); + +describe("audioFxSignalPath", () => { + // The design doc's §5 mockup, both columns. + it("names what a group sums, and sends it to the mix", () => { + expect(audioFxSignalPath("hf-audio-group", "voiceover", [group()])).toEqual({ + inLabel: "vo-1, vo-2", + outLabel: "to mix", + subject: "group", + }); + }); + + it("names the group a member feeds, so routing reads from either end", () => { + expect(audioFxSignalPath("audio", "vo-1", [group()])).toEqual({ + inLabel: "this track", + outLabel: "to Voiceover", + subject: "track", + }); + }); + + it("leaves an ungrouped clip on the shipped clip labels", () => { + expect(audioFxSignalPath("audio", "music-bed", [group()])).toEqual({ + inLabel: "this track", + outLabel: "to mix", + subject: "track", + }); + }); + + // The state an author is in the instant after making a group. It must not + // read as a failure to resolve. + it("says a memberless group holds nothing yet", () => { + expect( + audioFxSignalPath("hf-audio-group", "empty", [group({ id: "empty", memberIds: [] })]), + ).toMatchObject({ inLabel: "nothing yet", subject: "group" }); + }); +}); diff --git a/packages/studio/src/components/editor/audioFxSignalPath.ts b/packages/studio/src/components/editor/audioFxSignalPath.ts new file mode 100644 index 000000000..1f9e6ef20 --- /dev/null +++ b/packages/studio/src/components/editor/audioFxSignalPath.ts @@ -0,0 +1,59 @@ +/** + * What the rack's `IN` and `OUT` lines say, per selected element. + * + * The rack brackets its chain with the signal path because the ORDER is the + * point (see `propertyPanelFxRackChain`). Those two lines were hardcoded to a + * clip's answer — "in this track", "out to mix" — which is wrong at both ends + * once groups exist, and the design doc's §5 mockup spells out both: + * + * GROUP: Voiceover CLIP: vo-1 + * IN vo-1, vo-2 IN this track + * OUT to mix OUT to Voiceover + * + * A group's IN names what it sums, which is the only thing on screen that says + * a bus is a sum rather than a copy of the chain on each member. A member's OUT + * names the group it feeds, "so the routing is readable from either end". + */ + +import type { HfAudioGroup } from "@hyperframes/core/audio-groups"; + +export interface AudioFxSignalPath { + /** After the word "In". */ + inLabel: string; + /** After the word "Out". */ + outLabel: string; + /** The thing the empty-state sentence is about: "No effects on this …". */ + subject: string; +} + +/** What a plain, ungrouped clip has always said, and the default everywhere. */ +export const CLIP_SIGNAL_PATH: AudioFxSignalPath = { + inLabel: "this track", + outLabel: "to mix", + subject: "track", +}; + +/** + * `groups` is the resolved set from the composition; `elementId` and `tag` come + * from the selection. Pure so the labels can be asserted without a DOM. + */ +export function audioFxSignalPath( + tag: string | undefined, + elementId: string | undefined, + groups: readonly HfAudioGroup[], +): AudioFxSignalPath { + if (tag === "hf-audio-group") { + const group = groups.find((g) => g.id === elementId); + // A group with no members yet still reads as a group — "nothing yet" is the + // honest answer, and it is also the state the author is in right after + // making one, so it must not look like a bug. + const members = group?.memberIds ?? []; + return { + inLabel: members.length > 0 ? members.join(", ") : "nothing yet", + outLabel: "to mix", + subject: "group", + }; + } + const owner = elementId ? groups.find((g) => g.memberIds.includes(elementId)) : undefined; + return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `to ${owner.label}` } : CLIP_SIGNAL_PATH; +} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index eb806368d..b9cf996a6 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -7,7 +7,7 @@ * budget, and self-contained enough to test on its own. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { HF_AUDIO_FX_ATTR, HF_AUDIO_FX_DATA_KEY, @@ -44,6 +44,8 @@ import { FxSection } from "./propertyPanelFxSection.js"; import { clipStart } from "./propertyPanelAudioFxGroupUtils.js"; import { useFxChainObserved } from "./useFxChainObserved.js"; import { useFxCarve } from "./useFxCarve.js"; +import { audioFxSignalPath } from "./audioFxSignalPath.js"; +import { resolveAudioGroups } from "@hyperframes/core/audio-groups"; import { useFxLevelling } from "./useFxLevelling.js"; /** @@ -226,6 +228,18 @@ export function AudioFxGroup({ const [analysing, setAnalysing] = useState(false); + // The rack's In/Out lines. Resolved from the live document because a group's + // membership lives on the members, so neither end of the routing can be read + // off the selected element alone. + const signalPath = useMemo(() => { + const doc = element.element?.ownerDocument; + return audioFxSignalPath( + element.tagName?.toLowerCase(), + element.id ?? undefined, + doc ? resolveAudioGroups(doc) : [], + ); + }, [element]); + const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve( element, chain, @@ -273,6 +287,7 @@ export function AudioFxGroup({ next.nodes.length ? serializeAudioFxChain(next) : null, ) } + signalPath={signalPath} onAuditionTransport={auditionTransport} onChainPreview={(next) => // Live writes skip the preview refresh entirely, so dragging a knob no diff --git a/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx b/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx index 69a2e00b3..428afe96c 100644 --- a/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxRackChain.tsx @@ -1,7 +1,8 @@ /** * The rack's own signal path: the carve, the Tone EQ modules, and every - * hand-built effect or preset run in between — bracketed by the "In this - * track" / "Out to mix" labels that say the order is the point. + * hand-built effect or preset run in between — bracketed by the `In` / `Out` + * labels that say the order is the point. What those two name depends on what + * is selected; see `audioFxSignalPath`. * * Split out of `propertyPanelFxSection.tsx`, which owned this whole chain * before the file grew past a size where the chain and the add/pick menus @@ -15,8 +16,11 @@ import { trackEqChanged, trackPresetAmount } from "./audioFxTelemetry.js"; import { FxCarveModule, type AudioTrackOption } from "./propertyPanelFxCarveModule.js"; import { FxEqModule } from "./propertyPanelFxEqModule.js"; import { FxPresetRun } from "./propertyPanelFxPresetRun.js"; +import type { AudioFxSignalPath } from "./audioFxSignalPath.js"; export interface FxRackChainProps { + /** What the `In`/`Out` lines name — a clip's answer differs from a bus's. */ + signalPath: AudioFxSignalPath; chain: HfAudioFxChain; showCarve: boolean; carveNodes: HfAudioFxNode[]; @@ -103,6 +107,7 @@ export function FxRackChain({ presetAutomated, presetAutomateHandler, presetRemoveAutomationHandler, + signalPath, }: FxRackChainProps) { return (
@@ -111,7 +116,7 @@ export function FxRackChain({ "move up" look cosmetic — it is the most consequential control here. */}

In - this track + {signalPath.inLabel}

{/* Carve leads the rack, which is also where its effects sit in the signal path — corrective work before anything the author added. Present @@ -151,7 +156,9 @@ export function FxRackChain({ ))} {handBuiltCount === 0 && eqIds.length === 0 ? (

- {showCarve ? "No other effects on this track." : "No effects on this track."} + {showCarve + ? `No other effects on this ${signalPath.subject}.` + : `No effects on this ${signalPath.subject}.`}

) : ( runs.map((run) => { @@ -195,7 +202,7 @@ export function FxRackChain({ )}

Out - to mix + {signalPath.outLabel}

); diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx index 056c8f281..7451cbd53 100644 --- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx @@ -25,6 +25,7 @@ import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio- import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs"; import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js"; import { FxRackChain } from "./propertyPanelFxRackChain.js"; +import { CLIP_SIGNAL_PATH } from "./audioFxSignalPath.js"; import { FxAddMenu } from "./propertyPanelFxAddMenu.js"; import { useFxAudition } from "./useFxAudition.js"; import { @@ -103,6 +104,7 @@ export function FxSection({ onRemovePresetAutomation, automatedPresets, onAuditionTransport, + signalPath, }: FxSectionProps) { const presetAutomated = automatedPresets ?? new Set(); // Falls back to the persisting write when no preview handler is supplied, which @@ -417,6 +419,7 @@ export function FxSection({ onKeyDown={closeMenus} > .` strings. */ automatedTargets?: ReadonlySet; diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index 9c17d6931..4cda5a030 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -91,19 +91,37 @@ export function TimelineGroupHeader({ ▸ - - - {label} - - + + {label} + +