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 16:39:28 -07:00
co-authored by Claude Sonnet 5
parent e42185582b
commit dd3212d11b
23 changed files with 707 additions and 113 deletions
@@ -4,6 +4,8 @@ import { resolveBeatSourceTrack } from "../utils/timelineInspector";
import { analyzeMusicFromUrl } from "@hyperframes/core/beats";
import { useFileManagerContextOptional } from "../contexts/FileManagerContext";
import { mergeUserBeats } from "../utils/beatEditing";
import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes";
import { remapBeatAnalysisToComposition } from "../utils/beatEditActions";
import {
audioRelPathForSrc,
beatFilePathForSrc,
@@ -90,6 +92,17 @@ async function loadBeatAnalysis(
}
}
/** The current beat analysis, remapped onto the composition's edited beat grid. */
export function useAdjustedBeatAnalysis() {
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
const beatEdits = usePlayerStore((s) => s.beatEdits);
return useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
[beatAnalysis, musicElement, beatEdits],
);
}
export function useMusicBeatAnalysis(): void {
const elements = usePlayerStore((s) => s.elements);
const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis);
@@ -1,9 +1,11 @@
import { CaretRight } from "@phosphor-icons/react";
import { TRACK_H } from "./timelineLayout";
import { TrackClipCount } from "./TrackClipCount";
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer.
// Layer row (Figma order: disclosure , diamond, name) — the disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
// caret) because a group's own row keeps the caret for its structural
// disclosure (member rows) — this button only ever means "show this row's
// lanes", so it needs its own distinct glyph.
export function LayerDisclosureRow({
name,
clipCount,
@@ -49,23 +51,20 @@ export function LayerDisclosureRow({
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
title={`${isExpanded ? "Hide" : "Show"} lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
// only the hit box grows.
className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleClipExpanded();
}}
>
<CaretRight
size={11}
weight="bold"
aria-hidden="true"
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
/>
<span aria-hidden="true"></span>
</button>
{/* Decorative: the disclosure button above already names the row's keyframe
state, and aria-label on a plain span is not exposed reliably anyway. */}
@@ -556,11 +556,11 @@ describe("Timeline provider boundary", () => {
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
// lives in the left column. clip-2 has no keyframes so it never shows one.
const collapseButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Collapse clip-1 keyframes"]',
'button[aria-label="Hide clip-1 lanes"]',
);
expect(collapseButton).not.toBeNull();
expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull();
expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull();
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
const row = clip?.parentElement?.parentElement;
@@ -571,7 +571,7 @@ describe("Timeline provider boundary", () => {
expectTrackExpansion(row, [], TRACK_H);
const expandButton = host.querySelector<HTMLButtonElement>(
'button[aria-label="Expand clip-1 keyframes"]',
'button[aria-label="Show clip-1 lanes"]',
);
expect(expandButton).not.toBeNull();
act(() => expandButton?.click());
@@ -603,8 +603,8 @@ describe("Timeline provider boundary", () => {
const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
?.parentElement;
// A row of several clips is named for the track, so the caret is too.
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]');
expect(caret()?.getAttribute("aria-label")).toBe("Expand Track 1 keyframes");
const caret = () => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]');
expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes");
act(() => caret()?.click());
// One shared volume row, and BOTH clips hold it open.
@@ -647,7 +647,7 @@ describe("Timeline provider boundary", () => {
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" keyframes"]')?.click());
act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
const before = [...host.querySelectorAll(".hf-automation-lane")];
expect(before).toHaveLength(2);
@@ -1,6 +1,5 @@
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { defaultTimelineTheme } from "./timelineTheme";
@@ -39,7 +38,6 @@ import {
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
@@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
const beatEdits = usePlayerStore((s) => s.beatEdits);
const adjustedBeatAnalysis = useMemo(
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
[beatAnalysis, musicElement, beatEdits],
);
const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const timelineReady = usePlayerStore((s) => s.timelineReady);
@@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
rowGeometry,
rowGeometryRef,
groups,
trackGroupOf,
} = useTimelineTrackLayout(
expandedElements,
gsapAnimations,
@@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
selectedElementId,
selectedElementIds,
groups,
trackGroupOf,
gsapAnimations,
elements: expandedElements,
pixelsPerSecond: pps,
@@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
groups={groups}
laneCounts={laneCounts}
selectedElementId={selectedElementId}
selectedElementIds={selectedElementIds}
@@ -0,0 +1,101 @@
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
interface TimelineGroupHeaderProps {
label: string;
memberCount: number;
/** Caret: shows/hides the member rows beneath this group (structural). */
isExpanded: boolean;
onToggleExpanded: () => void;
/** `∿`: shows/hides the group's own automation-lane rows. */
laneCount: number;
isLaneOpen: boolean;
onToggleLanes: () => void;
columnWidth: number;
theme: TimelineTheme;
}
/**
* A group's own row header: caret (member disclosure) + `` + label + `∿ n`
* (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as
* siblings once those steps exist nothing to reserve for them yet.
*/
export function TimelineGroupHeader({
label,
memberCount,
isExpanded,
onToggleExpanded,
laneCount,
isLaneOpen,
onToggleLanes,
columnWidth,
theme,
}: TimelineGroupHeaderProps) {
return (
<div
role="rowheader"
aria-colindex={1}
className="sticky left-0 z-[12] flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: columnWidth,
height: TRACK_H,
color: "#ffffff",
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
title={`${isExpanded ? "Hide" : "Show"} tracks`}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
</span>
</button>
<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" title={label}>
{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"
title={`${memberCount} tracks`}
>
{memberCount}
</span>
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true"></span>
{laneCount > 0 && (
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
)}
</button>
</div>
);
}
@@ -0,0 +1,77 @@
import type { TimelineElement } from "../store/playerStore";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";
interface TimelineGroupRowProps {
index: number;
rowKey: number;
group: TimelineTrackGroupInfo;
logicalRow: TimelineLogicalRow;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
expandedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
}
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
export function TimelineGroupRow({
index,
rowKey,
group,
logicalRow,
tracks,
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
expandedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
}: TimelineGroupRowProps) {
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
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={expandedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
</TimelineTrackRow>
);
}
@@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
expandedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
@@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
trackOrder={displayTrackOrder}
tracks={tracks}
trackStyles={new Map()}
groups={[]}
laneCounts={laneCounts}
selectedElementId={null}
selectedElementIds={next.selectedElementIds ?? new Set()}
@@ -1,4 +1,4 @@
import { Fragment, useId, useMemo } from "react";
import { Fragment, useId } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -7,6 +7,8 @@ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
import { useAutomationLanes } from "./useAutomationLanes";
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { TimelineGroupRow } from "./TimelineGroupRow";
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
import {
isTrackRowExpanded,
resolveTrackKeyframeClip,
@@ -17,12 +19,8 @@ import { clipTimingStart } from "../../hooks/gsapShared";
import { getTimelineEditCapabilities } from "./timelineEditing";
import { CLIP_Y, TRACK_H } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import {
isMultiDragActive,
isMultiDragPassenger,
multiDragDeltaSeconds,
multiDragPassengerOffsetPx,
} from "./timelineMultiDragPreview";
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
import type { TimelineLanesProps } from "./timelineLaneProps";
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
@@ -32,7 +30,6 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
@@ -55,6 +52,7 @@ export function TimelineLanes({
trackOrder,
tracks,
trackStyles,
groups,
laneCounts,
selectedElementId,
selectedElementIds,
@@ -102,20 +100,14 @@ export function TimelineLanes({
// from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
useAutomationSelectionKeyboard({ lanes: automationLanes });
const expandClips = usePlayerStore((s) => s.expandClips);
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
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 { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
// The caret belongs to the ROW, so it opens and closes every clip on it at
// once. Toggling only the active clip left the row's state depending on which
// sibling happened to be selected: expand one, click another, and the row
@@ -131,22 +123,11 @@ export function TimelineLanes({
trackStudioKeyframeLaneExpand({ expanded: willExpand });
toggleClipExpanded(key);
};
const multiDragDelta =
multiDragPreview && isMultiDragActive(multiDragPreview)
? multiDragDeltaSeconds(multiDragPreview)
: 0;
const actorWindows =
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
? [
{
range: {
start: renderTimeRange.start - multiDragDelta,
end: renderTimeRange.end - multiDragDelta,
},
identities: multiDragPreview.selectedKeys,
},
]
: [];
const actorWindows = useTimelineMultiDragActorWindows(
multiDragPreview,
rowsVirtualized,
renderTimeRange,
);
const keyboard = useTimelineKeyboardActor({
logicalRows,
focusedTargetId,
@@ -171,6 +152,31 @@ export function TimelineLanes({
virtualRows.map(({ index: row, rowKey }) => {
const trackNum = displayTrackOrder[row];
if (trackNum === undefined) return null;
const group = groupByAnchor.get(trackNum);
if (group) {
const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
if (!groupLogicalRow) return null;
return (
<TimelineGroupRow
key={rowKey}
index={row}
rowKey={rowKey}
group={group}
logicalRow={groupLogicalRow}
tracks={tracks}
top={rowGeometry.getRowTop(row)}
height={rowGeometry.getRowHeight(row)}
virtualized={rowsVirtualized}
contentOrigin={contentOrigin}
theme={theme}
rovingTargetId={keyboard.rovingTargetId}
expandedGroupIds={expandedGroupIds}
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={toggleGroupExpanded}
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
/>
);
}
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
const logicalRow = trackLogicalRows[0];
@@ -58,6 +58,10 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
selectedElementId: "active",
selectedElementIds: new Set(),
expandedClipIds: new Set(["active"]),
expandedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
gsapAnimations: new Map([
[
"active",
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
selectedElementId: null,
selectedElementIds: new Set(),
expandedClipIds: new Set(),
expandedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
gsapAnimations: new Map(),
});
@@ -1,6 +1,7 @@
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
@@ -8,11 +9,13 @@ import {
import {
timelineClipFocusId,
timelineEaseFocusId,
timelineGroupRowId,
timelineKeyframeFocusId,
timelinePropertyRowId,
timelineTrackRowId,
} from "./timelineNavigationIdentity";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export type TimelineNavigationKey =
| "ArrowLeft"
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
kind: "row";
physicalTrackKey: number;
logicalIndex: number;
level: 1 | 2;
level: 1 | 2 | 3;
parentId: string | null;
elementId: string | null;
/** Set only on a group's own row (level 1, no clips of its own). */
groupId?: string;
expandable: boolean;
expanded: boolean;
propertyGroup?: PropertyGroupName;
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
interface BuildTimelineLogicalRowsInput {
export interface BuildTimelineLogicalRowsInput {
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
displayTrackOrder: readonly number[];
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
/** Groups whose member rows the caret has shown (structural, not lanes). */
expandedGroupIds: ReadonlySet<string>;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
}
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
});
}
/** A track's active clip (if any), its element id, and its automation lanes. */
function resolveActiveTrackClip(
elements: readonly TimelineElement[],
laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
): {
activeClip: TimelineElement | null;
activeId: string | null;
lanes: ReturnType<typeof getTimelinePropertyLanes>;
} {
const activeClip = resolveTrackKeyframeClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
);
const activeId = activeClip ? elementId(activeClip) : null;
const lanes = activeClip
? getTimelinePropertyLanes(
gsapAnimations.get(elementId(activeClip)) ?? [],
activeClip.start,
activeClip.duration,
)
: [];
return { activeClip, activeId, lanes };
}
function keyframeTarget(
keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
): TimelineKeyframeTarget {
@@ -167,6 +207,42 @@ function propertyItems(
return items;
}
/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
function isRowOpen(
activeId: string | null,
expandedClipIds: ReadonlySet<string>,
expandedLaneOwnerIds: ReadonlySet<string>,
): boolean {
if (activeId === null) return false;
return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
}
/** A single automation-lane row, one level deeper than the track/group row that owns it. */
function buildLaneRow(
track: number,
logicalIndex: number,
activeId: string,
activeClip: TimelineElement,
lane: ReturnType<typeof getTimelinePropertyLanes>[number],
level: 2 | 3,
parentId: string,
): TimelineLogicalRow {
const laneRowId = timelinePropertyRowId(activeId, lane.group);
return {
id: laneRowId,
kind: "row",
physicalTrackKey: track,
logicalIndex,
level,
parentId,
elementId: activeId,
expandable: false,
expanded: false,
propertyGroup: lane.group,
items: propertyItems(laneRowId, activeClip, lane.keyframes),
};
}
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
export function buildTimelineLogicalRows({
tracks,
@@ -175,58 +251,99 @@ export function buildTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
const trackMap = new Map(tracks);
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
const rows: TimelineLogicalRow[] = [];
for (const track of displayTrackOrder) {
// A real track's own row (level 1 ungrouped, level 2 under a group) plus,
// when its clip's lanes are open, the lane rows one level deeper.
function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
const elements = trackMap.get(track) ?? [];
const trackId = timelineTrackRowId(track);
const activeClip = resolveTrackKeyframeClip(
const { activeClip, activeId, lanes } = resolveActiveTrackClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
gsapAnimations,
);
const activeId = activeClip ? elementId(activeClip) : null;
const lanes = activeClip
? getTimelinePropertyLanes(
gsapAnimations.get(elementId(activeClip)) ?? [],
activeClip.start,
activeClip.duration,
)
: [];
const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
rows.push({
id: trackId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
level: 1,
parentId: null,
level,
parentId,
elementId: activeId,
expandable: lanes.length > 0,
expanded,
items: clipItems(trackId, elements),
});
if (!expanded || !activeClip) continue;
if (!expanded || !activeClip || !activeId) return;
for (const lane of lanes) {
const rowId = timelinePropertyRowId(activeId, lane.group);
rows.push({
id: rowId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
level: 2,
parentId: trackId,
elementId: activeId,
expandable: false,
expanded: false,
propertyGroup: lane.group,
items: propertyItems(rowId, activeClip, lane.keyframes),
});
rows.push(
buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
);
}
}
// A group's own row (level 1) plus, when its `∿` is open, its own
// automation-lane rows (level 2) — structural content deferred to whatever
// step wires group automation editing; this reserves the rows and their
// count.
function emitGroup(group: TimelineTrackGroupInfo): void {
const groupRowId = timelineGroupRowId(group.id);
const groupExpanded = expandedGroupIds.has(group.id);
rows.push({
id: groupRowId,
kind: "row",
physicalTrackKey: group.anchorKey,
logicalIndex: rows.length,
level: 1,
parentId: null,
elementId: null,
groupId: group.id,
expandable: group.memberTracks.length > 0,
expanded: groupExpanded,
items: [],
});
if (expandedLaneOwnerIds.has(group.id)) {
const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
for (const laneGroup of groupAutomationLanes(memberElements)) {
rows.push({
id: `${groupRowId}::${laneGroup.key}`,
kind: "row",
physicalTrackKey: group.anchorKey,
logicalIndex: rows.length,
level: 2,
parentId: groupRowId,
elementId: null,
expandable: false,
expanded: false,
items: [],
});
}
}
if (!groupExpanded) return;
for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
}
for (const key of displayTrackOrder) {
const group = groupByAnchor.get(key);
if (group) {
emitGroup(group);
continue;
}
if (trackGroupOf.has(key)) continue; // emitted above, under its group
emitTrack(key, 1, null);
}
return rows;
}
@@ -13,6 +13,7 @@ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import type { TimelineClipRenderContext } from "./TimelineTypes";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/**
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
@@ -99,6 +100,8 @@ export interface TimelineLaneBaseProps {
*/
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
beatAnalysis?: MusicBeatAnalysis | null;
/** Resolved audio groups, positioned in row order — see useTimelineTrackDerivations. */
groups: readonly TimelineTrackGroupInfo[];
}
/**
@@ -13,6 +13,10 @@ export function timelineTrackRowId(track: number): string {
return stableId("track", track);
}
export function timelineGroupRowId(groupId: string): string {
return stableId("group", groupId);
}
export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
return stableId("property", elementId, group);
}
@@ -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 };
}
@@ -3,6 +3,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
import { usePlayerStore } from "../store/playerStore";
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
@@ -15,6 +16,8 @@ interface TimelineLogicalFocusInput {
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
elements: readonly TimelineElement[];
pixelsPerSecond: number;
@@ -32,6 +35,8 @@ interface TimelineLogicalFocusInput {
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
const projectId = usePlayerStore((state) => state.timelineProjectId);
const logicalRows = useTimelineLogicalRows({
tracks: input.tracks,
@@ -40,6 +45,10 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
selectedElementId: input.selectedElementId,
selectedElementIds: input.selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups: input.groups,
trackGroupOf: input.trackGroupOf,
gsapAnimations: input.gsapAnimations,
});
const focus = useTimelineFocusCoordinator({
@@ -22,6 +22,10 @@ const displayTrackOrder = tracks.map(([track]) => track);
const laneCounts = new Map<string, number>();
const selectedElementIds = new Set<string>();
const expandedClipIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const expandedLaneOwnerIds = new Set<string>();
const groups: never[] = [];
const trackGroupOf = new Map();
const gsapAnimations = new Map();
function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]> }) {
@@ -33,6 +37,10 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
selectedElementId: null,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
});
snapshots.push(logicalRows);
@@ -1,17 +1,10 @@
import { useMemo } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
import {
buildTimelineLogicalRows,
type BuildTimelineLogicalRowsInput,
} from "./timelineKeyboardNavigation";
interface TimelineLogicalRowsInput {
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
displayTrackOrder: readonly number[];
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
}
type TimelineLogicalRowsInput = BuildTimelineLogicalRowsInput;
/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
export function useTimelineLogicalRows({
@@ -21,6 +14,10 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
}: TimelineLogicalRowsInput) {
return useMemo(
@@ -32,11 +29,19 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
}),
[
displayTrackOrder,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
gsapAnimations,
laneCounts,
selectedElementId,
@@ -0,0 +1,30 @@
import { isMultiDragActive, multiDragDeltaSeconds } from "./timelineMultiDragPreview";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
/** The extra render window a live multi-drag needs: its passengers still draw
* at their ORIGIN position, offset by the drag delta, outside the normal
* virtualized range. Empty when nothing is dragging. */
export function useTimelineMultiDragActorWindows(
multiDragPreview: MultiDragPreviewInput | null,
rowsVirtualized: boolean,
renderTimeRange: TimelineTimeRange,
) {
const multiDragDelta =
multiDragPreview && isMultiDragActive(multiDragPreview)
? multiDragDeltaSeconds(multiDragPreview)
: 0;
const actorWindows =
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
? [
{
range: {
start: renderTimeRange.start - multiDragDelta,
end: renderTimeRange.end - multiDragDelta,
},
identities: multiDragPreview.selectedKeys,
},
]
: [];
return actorWindows;
}
@@ -1,20 +1,106 @@
import { useMemo } from "react";
import type { TimelineElement } from "../store/playerStore";
import { isCanaryEnabled } from "../../telemetry/canary";
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
/** One resolved audio group, positioned in the row order. */
export interface TimelineTrackGroupInfo {
id: string;
label: string;
/**
* Synthetic sort key for the group's own row the same fractional-key
* convention sub-composition expansion already uses (see
* timelineTrackDisplay.ts): the first (lowest) member track's number minus
* 0.5, so it slots in immediately above that member.
*/
anchorKey: number;
/** Member track numbers, ascending. */
memberTracks: number[];
}
interface GroupMembership {
trackToGroupId: Map<number, string>;
memberTracksByGroup: Map<string, number[]>;
labelByGroup: Map<string, string>;
}
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
const trackToGroupId = new Map<number, string>();
const memberTracksByGroup = new Map<string, number[]>();
const labelByGroup = new Map<string, string>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
trackToGroupId.set(trackNum, owner.audioGroup);
if (!labelByGroup.has(owner.audioGroup)) {
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
memberTracksByGroup.set(owner.audioGroup, members);
}
return { trackToGroupId, memberTracksByGroup, labelByGroup };
}
/**
* Pull each group's members out of raw ascending track order and re-emit them
* contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
* their position; a group's members move up to sit under its anchor even when
* other (ungrouped) tracks were interleaved between them.
*/
function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
tracks: [number, TimelineElement[]][];
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
const rawByTrack = new Map(rawTracks);
const groups: TimelineTrackGroupInfo[] = [];
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
const emitted = new Set<string>();
const tracks: [number, TimelineElement[]][] = [];
for (const [trackNum, elements] of rawTracks) {
const groupId = trackToGroupId.get(trackNum);
if (!groupId) {
tracks.push([trackNum, elements]);
continue;
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
const info: TimelineTrackGroupInfo = {
id: groupId,
label: labelByGroup.get(groupId) ?? groupId,
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
memberTracks,
};
groups.push(info);
tracks.push([info.anchorKey, []]);
for (const member of memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
}
}
return { tracks, groups, trackGroupOf };
}
/**
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane
* clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
* `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
* cohesive unit (600-line studio cap); each memo keys on the expanded display
* element set exactly as before.
* clip grouping (`tracks`, group-aware order), per-lane visual styles, the
* matching `trackOrder`, and audio-group membership. Extracted from
* Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
* expanded display element set exactly as before.
*/
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
trackOrder: number[];
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
} {
const tracks = useMemo(() => {
const rawTracks = useMemo(() => {
const map = new Map<number, TimelineElement[]>();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
@@ -24,6 +110,17 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const { tracks, groups, trackGroupOf } = useMemo(() => {
if (!isCanaryEnabled("audio-groups")) {
return {
tracks: rawTracks,
groups: [],
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
};
}
return groupTimelineTracks(rawTracks);
}, [rawTracks]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
for (const [trackNum, els] of tracks) {
@@ -34,5 +131,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
return { tracks, trackStyles, trackOrder };
return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
}
@@ -188,7 +188,8 @@ export function useTimelineTrackLayout(
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
) {
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
@@ -207,6 +208,8 @@ export function useTimelineTrackLayout(
rowGeometry,
rowGeometryRef,
rowHeights,
groups,
trackGroupOf,
};
}
@@ -227,7 +230,23 @@ function useDisplayRowHeights(
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
return useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
// A group's members sit out of raw numeric order (pulled under their
// anchor row), so a plain numeric sort here would undo that grouping the
// moment a clip drags onto a brand-new track. Insert the new preview
// track only relative to other REAL (integer) tracks, leaving any
// fractional group-anchor keys exactly where grouping placed them.
const preview = draggedClip.previewTrack;
const result: number[] = [];
let inserted = false;
for (const key of trackOrder) {
if (!inserted && Number.isInteger(key) && key > preview) {
result.push(preview);
inserted = true;
}
result.push(key);
}
if (!inserted) result.push(preview);
return result;
}, [draggedClip, trackOrder]);
}
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
@@ -67,6 +68,20 @@ function resolveClipTag(clip: ClipManifestClip): string {
return clip.tagName || clip.kind || "div";
}
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
// walks the whole tree, and a parse touches every clip in it.
const groupLabelCache = new WeakMap<Document, Map<string, string>>();
function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
if (!doc) return groupId;
let labels = groupLabelCache.get(doc);
if (!labels) {
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
groupLabelCache.set(doc, labels);
}
return labels.get(groupId) ?? groupId;
}
// fallow-ignore-next-line complexity
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
@@ -138,6 +153,11 @@ export function createTimelineElementFromManifestClip(params: {
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
const timelineRole = hostEl.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
const audioGroup = hostEl.getAttribute("data-audio-group");
if (audioGroup) {
entry.audioGroup = audioGroup;
entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
}
const fxChain = hostEl.getAttribute("data-fx-chain");
if (fxChain) entry.fxChain = fxChain;
const automation = hostEl.getAttribute("data-automation");
@@ -356,6 +376,12 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const timelineRole = el.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
const domAudioGroup = el.getAttribute("data-audio-group");
if (domAudioGroup) {
entry.audioGroup = domAudioGroup;
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
}
// Sub-compositions
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
@@ -63,6 +63,14 @@ export interface KeyframeSlice {
/** Union-expand clips (keyframed clips are expanded by default on load). */
expandClips: (ids: readonly string[]) => void;
/** Groups whose member rows the caret has shown (structural, not lanes). */
expandedGroupIds: Set<string>;
toggleGroupExpanded: (id: string) => void;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: Set<string>;
toggleLaneOwnerExpanded: (id: string) => void;
/**
* Project/session/element-scoped request. Its nonce is monotonic across store
* resets so a stale consumer can never collide with a later request.
@@ -119,6 +127,24 @@ export function createKeyframeSlice(
return { expandedClipIds: next };
}),
expandedGroupIds: new Set(),
toggleGroupExpanded: (id) =>
set((state) => {
const next = new Set(state.expandedGroupIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { expandedGroupIds: next };
}),
expandedLaneOwnerIds: new Set(),
toggleLaneOwnerExpanded: (id) =>
set((state) => {
const next = new Set(state.expandedLaneOwnerIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { expandedLaneOwnerIds: next };
}),
focusedEaseSegment: null,
focusedEaseRequestNonce: 0,
setFocusedEaseSegment: (target) =>
@@ -270,6 +270,8 @@ export function createTimelineResetState() {
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
automationSelection: null,
expandedClipIds: new Set<string>(),
expandedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null,
selectedElementIds: new Set<string>(),
requestedSeekTime: null,
@@ -67,6 +67,10 @@ export interface TimelineElement {
hidden?: boolean;
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
timelineRole?: string;
/** Verbatim `data-audio-group` — the id of the `<hf-audio-group>` this clip belongs to, when any. */
audioGroup?: string;
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
audioGroupLabel?: string;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child