Files
hyperframes/packages/studio/src/player/components/TimelineGroupRow.tsx
T
Vance Ingalls c6e6c04f5c 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.
2026-08-20 02:17:49 -07:00

247 lines
10 KiB
TypeScript

import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
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";
/** Accent rail on a group-owned lane — the same green the member rail uses, so
* "this belongs to the group" reads the same in both places (groups doc §5). */
const GROUP_LANE_ACCENT = "#3CE6AC";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
interface TimelineGroupRowProps {
index: number;
rowKey: number;
group: TimelineTrackGroupInfo;
logicalRow: TimelineLogicalRow;
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
collapsedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
lanes: UseAutomationLanesResult;
pps: number;
currentTime: number;
/** A group's lanes are in composition time (§1.3), so this is their span. */
compositionDuration: number;
beatTimes?: readonly number[];
contentGutter: number;
trackContentWidth: number;
}
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
export function TimelineGroupRow({
index,
rowKey,
group,
logicalRow,
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
collapsedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
lanes,
pps,
currentTime,
compositionDuration,
beatTimes,
contentGutter,
trackContentWidth,
}: TimelineGroupRowProps) {
// From the group, NOT from `tracks`: a collapsed group emits no member rows
// into the display list, and every one of these reads silently degraded to
// empty in that (default) state — half-lit solo went dark, the lane count
// read 0, and the bus strip fell back to "track 1", "track 2".
const memberElements = group.memberElements;
// The group wearing a clip's shape so the lane machinery can render it — see
// `groupAutomationElement` for why that beats a second, parallel lane path.
const groupElement = groupAutomationElement(group, compositionDuration);
// The binder writes through the dom-edit selection, so a group lane is
// editable exactly when the group is the selected element — which clicking
// its name in the header does.
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
const isGroupSelected = domSelection?.id === group.id;
const memberLabels = group.memberTracks.map((track, i) => {
const owner = memberElements.find((el) => el.track === track && el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
});
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
// Optional, like every sibling row: Timeline renders outside the edit
// provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
// hook took the whole timeline down with it the moment a group existed —
// not just this row.
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// Bare DOM ids: this list is compared against the `soloed` set, which the
// runtime matches on `el.id` (see `runtimeAudioId`). Store keys here made the
// half-lit state unreachable — soloing a member lit nothing on its group.
const memberIds = memberElements.map(runtimeAudioId).filter((id): id is string => id !== null);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
// Hovering a preset on a muted bus is a question about the preset, not about
// the mute — so the audition lifts the mute while it plays and puts it back
// on the way out, the same borrow-and-return it already does with the
// playhead. Live only: `data-hidden` stays in the document, so the row keeps
// reading (and rendering) as muted throughout.
const setGroupMutedLive = (muted: boolean) =>
onSetAudioGroupAttributeLive?.(group.id, "data-hidden", muted ? "" : null);
const openGroupFxRack = () => {
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id,
);
if (!target) return;
void domEditActions
?.buildDomSelectionFromTarget(target)
.then((selection) => selection && domEditActions.applyDomSelection(selection));
};
return (
<TimelineTrackRow
index={index}
rowKey={rowKey}
logicalRow={logicalRow}
propertyRows={[]}
lanesId=""
headerLanesId=""
top={top}
height={height}
virtualized={virtualized}
background={theme.rowBackground}
borderColor={theme.rowBorder}
rovingTargetId={rovingTargetId}
>
<TimelineGroupHeader
label={group.label}
memberCount={group.memberTracks.length}
isExpanded={!collapsedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
// The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
// §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
// counting the members' here made the group advertise curves it does
// not own and cannot show.
laneCount={groupAutomationLanes([groupElement]).length}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
hidden={group.hidden}
onToggleHidden={() =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-hidden",
group.hidden ? null : "",
group.hidden ? "Unmute group" : `Mute group ${group.label}`,
)
}
isSoloed={soloed.has(group.id)}
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
onToggleSolo={(options) => toggleSolo(group.id, options)}
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onSetMutedLive={setGroupMutedLive}
onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
// timeline (see Timeline.tsx) rather than by overhanging alone — an
// overhanging header paints opaquely across the rest of its row and
// stays pinned there through horizontal scroll.
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{isLaneOpen && (
<TimelineGroupBusStrip
groupId={group.id}
volume={group.volume}
memberLabels={memberLabels}
onVolumeChange={(value) =>
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
}
onVolumeCommit={(value) =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-volume",
String(value),
"Set group volume",
)
}
theme={theme}
/>
)}
{/* 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
// against the row instead and drew the envelope across the label gutter
// from x=0.
<div
role="gridcell"
aria-colindex={2}
style={{ width: trackContentWidth, marginLeft: contentGutter }}
className="relative"
>
<TimelineAutomationLaneSlot
elements={[groupElement]}
isSelected={() => isGroupSelected}
lanes={lanes}
pps={pps}
// Below the strip, which sits directly under the header row.
laneCount={0}
topOffset={TRACK_H + STRIP_H}
accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
/>
</div>
)}
</TimelineTrackRow>
);
}