fix(studio): close four gaps found against the rendered designs

Until now I had only the ASCII stand-ins in `plans/audio-mixer-groups.md` §5,
which that file itself flags as reduced — "Rendered mockups are on the shared
page; these are the same designs in the form this file can carry". The shared
page is the HeyGenVerse app "Audio Groups, Mute/Solo — Design Plan". Read
against it, four things were wrong or missing:

**A muted group's name is not struck through.** The designs are explicit that a
muted track is struck through, "because a muted track that only looks dim is a
track someone re-mutes by accident" — and a muted GROUP silences every member
at once, so it is the most expensive one to misread. Plain track rows already
did this; the group header did not.

**A group's automation lanes had no label column.** The curve rendered on the
canvas with nothing naming it. The designs draw `▤ Volume  0.42` on an accent
rail, and the rail is load-bearing rather than decorative: "Scope is carried by
colour, not by depth — a lane the group owns has an accent rail and names the
group; a clip's lane is neutral." Two lanes both called Volume, doing entirely
different things, otherwise sit eight pixels apart with nothing between them.

**The number has to be the value at the playhead.** Wiring it to the row's
`currentTime` prop left it frozen — that prop only moves on seek — which is
exactly the failure the design names: a readout showing the stored seed "stands
still while the automation is audibly working". It reads the live playhead now.
Verified across the curve: 0.99 at t=0, 0.85 at the trough, 1.00 at t=20.

**The rack's IN/OUT copy was the ASCII's, not the design's.** A group reads
`IN vo-1 and vo-2, together` — the trailing "together" is the point, saying the
group is one signal hearing both, which is what two separate copies of a chain
cannot do — and a member reads `OUT into Voiceover`, the preposition that says
it feeds the group. I had built `vo-1, vo-2` and `to Voiceover` from the ASCII.

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4343 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:57 -07:00
parent 47030f59b2
commit dd45bcf71b
5 changed files with 123 additions and 5 deletions
@@ -13,9 +13,12 @@ const group = (over: Partial<HfAudioGroup> = {}): HfAudioGroup => ({
describe("audioFxSignalPath", () => {
// The design doc's §5 mockup, both columns.
// Copy taken from the rendered designs, which are more specific than the
// ASCII stand-ins in the markdown: "vo-1 and vo-2, together" / "into
// Voiceover", not "vo-1, vo-2" / "to Voiceover".
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",
inLabel: "vo-1 and vo-2, together",
outLabel: "to mix",
subject: "group",
});
@@ -24,7 +27,7 @@ describe("audioFxSignalPath", () => {
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",
outLabel: "into Voiceover",
subject: "track",
});
});
@@ -37,6 +37,12 @@ export const CLIP_SIGNAL_PATH: AudioFxSignalPath = {
* `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.
*/
/** "a", "a and b", "a, b and c" — how the designs read a member list aloud. */
function joinNatural(items: readonly string[]): string {
if (items.length <= 1) return items[0] ?? "";
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}
export function audioFxSignalPath(
tag: string | undefined,
elementId: string | undefined,
@@ -49,11 +55,17 @@ export function audioFxSignalPath(
// making one, so it must not look like a bug.
const members = group?.memberIds ?? [];
return {
inLabel: members.length > 0 ? members.join(", ") : "nothing yet",
// "vo-1 and vo-2, together" — the rendered design's exact phrasing, not a
// comma list. The trailing "together" is the point: it says the group is
// ONE signal hearing both, which is the thing two separate copies of a
// chain cannot do, and it says it without "sum" or "bus".
inLabel: members.length > 0 ? `${joinNatural(members)}, together` : "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;
// "into Voiceover", not "to" — a member feeds the group, and the design uses
// the preposition that says so.
return owner ? { ...CLIP_SIGNAL_PATH, outLabel: `into ${owner.label}` } : CLIP_SIGNAL_PATH;
}
@@ -117,7 +117,13 @@ export function TimelineGroupHeader({
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
</span>
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
{/* Struck through, not merely dimmed — the designs are explicit that "a
muted track that only looks dim is a track someone re-mutes by
accident", and a muted GROUP silences every member at once, so it is
the most expensive one to misread. */}
<span className={`min-w-0 flex-1 truncate font-medium${hidden ? " line-through" : ""}`}>
{label}
</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
@@ -0,0 +1,83 @@
/**
* The label column beside a group's own automation lanes.
*
* The designs draw a group lane as `▤ Volume 0.42` on an accent rail — and
* the rail is load-bearing, not decoration: "Scope is carried by colour, not by
* depth — a lane the group owns has an accent rail and names the group; a
* clip's lane is neutral." Two lanes both called Volume, doing entirely
* different things, otherwise sit eight pixels apart with nothing to tell them
* apart.
*
* The number is the value AT THE PLAYHEAD, not the stored seed: a readout that
* showed the seed would stand still while the curve is audibly working.
*/
import { sampleAutomationLane } from "@hyperframes/core/audio-automation";
import { automationLaneLabelParts, elementAutomation, elementFxChain } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { TimelineElement } from "../store/playerStore";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
export function TimelineGroupLaneLabels({
groupElement,
groupLabel,
top,
columnWidth,
gutterBackground,
accentColor,
}: {
/** The group wearing a clip's shape — see `groupAutomationElement`. */
groupElement: TimelineElement;
groupLabel: string;
/** y of the first lane, matching the canvas slot's own offset. */
top: number;
columnWidth: number;
gutterBackground: string;
accentColor: string;
}) {
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
// on seek, so the readout sat frozen while the curve was audibly working,
// which is precisely the failure this number exists to prevent.
const currentTime = useLivePlayheadTime();
const chain = elementFxChain(groupElement);
const lanes = elementAutomation(groupElement).lanes;
return (
<>
{lanes.map((lane, index) => {
const parts = automationLaneLabelParts(lane.target, chain);
if (!parts) return null;
// A group's clock is composition time (§1.3), so the playhead needs no
// clip-local rebase here — unlike a clip's lane.
const value = sampleAutomationLane(lane, currentTime);
return (
<div
key={lane.target}
data-group-lane-label={lane.target}
className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top: top + index * AUTOMATION_LANE_H,
width: columnWidth,
height: AUTOMATION_LANE_H,
background: gutterBackground,
borderLeft: `2px solid ${accentColor}`,
}}
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
>
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
</span>
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
<span className="truncate font-mono text-[9px] text-white/70">{parts.name}</span>
{parts.param ? (
<span className="truncate font-mono text-[9px] text-white/40">{parts.param}</span>
) : null}
</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
{value.toFixed(2)}
</span>
</div>
);
})}
</>
);
}
@@ -15,6 +15,7 @@ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { groupAutomationElement } from "./groupAutomationElement";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
import { STRIP_H, TRACK_H } from "./timelineLayout";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
@@ -202,6 +203,19 @@ export function TimelineGroupRow({
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected — which clicking its name does. */}
{/* The label column for those lanes, on the accent rail. Outside the
offset content cell below, because the labels belong to the sticky
gutter the row header occupies, not to the scrolling canvas. */}
{isLaneOpen && (
<TimelineGroupLaneLabels
groupElement={groupElement}
groupLabel={group.label}
top={TRACK_H + STRIP_H}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
/>
)}
{isLaneOpen && (
// The same offset content cell a track row wraps its lanes in — the
// slot positions absolutely, so mounted straight on the row it resolved