diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index ec03f6a40..c632128f8 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -7,6 +7,7 @@ import { isTextEditableSelection } from "./domEditing"; import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { audioFxSummary } from "./audioFxSummary"; +import { resolveAudioGroups } from "@hyperframes/core/audio-groups"; import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; import { FlatGroupHeader } from "./propertyPanelFlatPrimitives"; @@ -266,6 +267,17 @@ export function PropertyPanelFlat({ const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive); + // The group this clip belongs to, if any — the Audio FX summary reads + // "in Voiceover" for a member (see `audioFxSummary`). Resolved from the live + // document because membership lives on the members, so the owning group's + // LABEL is not on the selected element. + const audioGroupLabel = ((): string | undefined => { + const doc = element.element?.ownerDocument; + const id = element.id; + if (!doc || !id) return undefined; + return resolveAudioGroups(doc).find((g) => g.memberIds.includes(id))?.label; + })(); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -441,7 +453,7 @@ export function PropertyPanelFlat({ groups.push({ id: "audio-fx", title: "Audio FX", - summary: audioFxSummary(element), + summary: audioFxSummary(element, audioGroupLabel), content: ( ): DomEditSelection => const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes }); describe("audioFxSummary", () => { + // The designs give a member's rack the summary "in Voiceover" — it answers + // "where does this go?" before anything is opened, the same job the rack's + // OUT does from the other end, and it outranks the effect count because a + // member with no effects of its own is still in the group. + it("names the group a clip belongs to, ahead of any effect count", () => { + expect(audioFxSummary(el({}), "Voiceover")).toBe("in Voiceover"); + }); + it("counts a carve as one module, not as the filters behind it", () => { // Six bands and a level stage reading "7 effects" is the misreading the // grouping exists to prevent. diff --git a/packages/studio/src/components/editor/audioFxSummary.ts b/packages/studio/src/components/editor/audioFxSummary.ts index a56ee5323..94c0c3da2 100644 --- a/packages/studio/src/components/editor/audioFxSummary.ts +++ b/packages/studio/src/components/editor/audioFxSummary.ts @@ -10,7 +10,13 @@ import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx"; import type { DomEditSelection } from "./domEditingTypes"; -export function audioFxSummary(element: DomEditSelection): string { +export function audioFxSummary(element: DomEditSelection, groupLabel?: string): string { + // A clip inside a group reads "in Voiceover" — the designs use this line to + // answer "where does this go?" before the author opens anything, which is + // the same job the rack's OUT does from the other end. It outranks the effect + // count: a member with no effects of its own is still IN the group, and that + // is the more useful thing to say about it. + if (groupLabel) return `in ${groupLabel}`; const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY]; const carveAttr = element.dataAttributes?.["fx-carve"]; let handBuilt = 0; diff --git a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx index d31308ed2..3c8def73d 100644 --- a/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx +++ b/packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx @@ -133,8 +133,18 @@ export function FxPresetMenu({ {PRESET_PROBLEM[preset.id] ?? preset.description} - - {preset.label} + {/* "Clean Voice · 5 effects" — the count is on the row in the + designs, not hidden in a tooltip. It is doing real work there: + it tells the author a preset IS a chain they can open and edit, + rather than an opaque setting they cannot follow. The count is + its own span so `.hf-fx-preset-name` stays the NAME — several + tests read it as the preset's identity. */} + + {preset.label} + + {" · "} + {preset.nodes.length} effect{preset.nodes.length === 1 ? "" : "s"} + {/* Hovering a preset plays it, and playing is otherwise invisible: the panel looks identical whether the audition is sounding or diff --git a/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx b/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx index 309baaf4a..617c45397 100644 --- a/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx +++ b/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx @@ -63,14 +63,21 @@ function setSliderValue(input: HTMLInputElement, value: string) { } describe("TimelineGroupBusStrip", () => { - it('renders "Holds …" from the member labels, comma-joined', () => { + // "vo-1 and vo-2", the designs' own phrasing. A comma list reads as data; + // this line is a sentence about what the group holds. + it('renders "Holds …" from the member labels, joined as a sentence', () => { renderStrip({ memberLabels: ["vo-1", "vo-2"] }); - expect(container.textContent).toContain("Holds vo-1, vo-2"); + expect(container.textContent).toContain("Holdsvo-1 and vo-2"); + }); + + it("keeps the serial comma out of a two-name list but uses it beyond that", () => { + renderStrip({ memberLabels: ["vo-1", "vo-2", "vo-3"] }); + expect(container.textContent).toContain("vo-1, vo-2 and vo-3"); }); it("falls back to a neutral line when a group has no members yet", () => { renderStrip({ memberLabels: [] }); - expect(container.textContent).toContain("Holds nothing yet"); + expect(container.textContent).toContain("Holdsnothing yet"); }); it("live-writes on every drag tick, but only commits once on release", () => { diff --git a/packages/studio/src/player/components/TimelineGroupBusStrip.tsx b/packages/studio/src/player/components/TimelineGroupBusStrip.tsx index fcfda949e..422693d9f 100644 --- a/packages/studio/src/player/components/TimelineGroupBusStrip.tsx +++ b/packages/studio/src/player/components/TimelineGroupBusStrip.tsx @@ -64,8 +64,13 @@ export function TimelineGroupBusStrip({ const shownVolume = dragValue ?? volume; const level = Math.min(1, reading?.level ?? 0); - const holdsText = - memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet"; + // "vo-1 and vo-2", the designs' own phrasing — a comma list reads as data, + // and this line is a sentence about what the group is holding. + const holds = + memberLabels.length > 1 + ? `${memberLabels.slice(0, -1).join(", ")} and ${memberLabels[memberLabels.length - 1]}` + : (memberLabels[0] ?? "nothing yet"); + const holdsText = `Holds ${holds}`; return (
{clipped && ⚠ Too loud} + {/* Label and value, as the designs split them: "Holds" is chrome, the + member list is the answer. */} + Holds - {holdsText} + {holds} );