feat(studio): group rows in the timeline, and a split disclosure

A group renders as its own row with member rows beneath it, and disclosure
splits into two independent controls: caret shows/hides a group's member
rows (structural), `∿` shows/hides any row's automation-lane rows. Plain
tracks lose their caret (nothing to disclose structurally) and keep only
`∿`. `expandedClipIds` keeps its existing keyframe-lane-state job;
`expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets.

Groups get a real position in the row/geometry pipeline rather than a
visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member
tracks contiguously under a synthetic fractional anchor key
(firstMember - 0.5, the same fractional-key convention sub-composition
expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat
a group row as a first-class row without widening their key type away from
number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group /
member-under-group / lane), lanes always `owner.level + 1`.

All of it — grouped row emission, the header, the new expansion state — is
gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves
empty and every new code path no-ops. `TimelineElement.audioGroup` (+
`audioGroupLabel`, resolved once per document via `resolveAudioGroups` from
B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already
flow DOM → manifest → TimelineElement — inert without the canary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 02:08:27 -07:00
co-authored by Claude Sonnet 5
parent 975b079473
commit f16a2b3aa2
23 changed files with 707 additions and 113 deletions
@@ -0,0 +1,38 @@
import { useMemo } from "react";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The four pieces of group-disclosure state a group row's header reads and writes. */
export function useTimelineGroupDisclosure() {
return {
expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
};
}
/** Two lookups TimelineLanes needs once per render: a row's logical rows by
* physical key, and which physical key is a group's own anchor row. */
export function useTimelineLaneRowIndexes(
logicalRows: readonly TimelineLogicalRow[],
groups: readonly TimelineTrackGroupInfo[],
) {
const logicalRowsByTrack = useMemo(() => {
const byTrack = new Map<number, TimelineLogicalRow[]>();
for (const logicalRow of logicalRows) {
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
trackRows.push(logicalRow);
byTrack.set(logicalRow.physicalTrackKey, trackRows);
}
return byTrack;
}, [logicalRows]);
const groupByAnchor = useMemo(
() => new Map(groups.map((group) => [group.anchorKey, group])),
[groups],
);
return { logicalRowsByTrack, groupByAnchor };
}