mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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.
247 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|