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} + +