Files
hyperframes/packages/studio/src/player/components/useTimelineTrackLayout.ts
T
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00

309 lines
12 KiB
TypeScript

import { useMemo, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationLaneGroups } from "./TimelinePropertyLanes";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import {
STRIP_H,
TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
trackHeights,
type TimelineTrackHeightClip,
} from "./timelineLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export { getTrackStyle } from "./timelineIcons";
/**
* Whether this track draws the beat-dot strip: only where there are beats to
* draw, and only on the track the user is working in — the selected clip's, or
* the music track's when nothing is selected.
*/
export function trackShowsBeatStrip(
els: readonly TimelineElement[],
beatTimes: readonly number[] | undefined,
ctx: {
selectedElementId: string | null;
isMusicTrack(element: TimelineElement): boolean;
},
): boolean {
if ((beatTimes?.length ?? 0) < 2) return false;
return ctx.selectedElementId
? els.some((e) => (e.key ?? e.id) === ctx.selectedElementId)
: els.some((e) => ctx.isMusicTrack(e));
}
/**
* Automation lanes on one clip, or 0 for anything that is not audio.
*
* An audio clip can be worth expanding without carrying a single tween, so this
* counts toward whether a track has anything to disclose. A function rather than
* a map so every caller reads the same cached parse and none can drift.
*/
function automationLaneCountOf(element: TimelineElement): number {
return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
}
/**
* Automation rows a TRACK reserves: the union over the clips sharing it, since
* clips on one row share a lane row per property. Counting only the active clip's
* lanes reserved a height that changed with the selection.
*/
function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
return groupAutomationLanes(elements).length;
}
/**
* Is this row disclosed? Expansion is stored per clip, but it reads as a property
* of the ROW: the active clip changes with the selection, so asking only about it
* collapsed the row the moment you clicked a sibling. Any expanded clip on the
* track holds the row open — and the caret expands and collapses all of them
* together (see TimelineLanes), so the two can only disagree on state predating
* this rule or written by the keyframe auto-expand.
*/
export function isTrackRowExpanded(
elements: readonly TimelineElement[],
expandedClipIds: ReadonlySet<string>,
): boolean {
return elements.some((element) => expandedClipIds.has(element.key ?? element.id));
}
/**
* The single keyframed element whose property lanes a track shows when expanded.
* A track can hold several elements (same z-index is common), but keyframes are
* per-element, so we scope to ONE active element — the selected one if it's on
* this track, otherwise the element with the most lanes. Selecting a clip is how
* you switch which element you're keyframing. Returns null when no element on the
* track has keyframes.
*/
export function resolveTrackKeyframeClip(
elements: readonly TimelineElement[],
laneCounts: ReadonlyMap<string, number>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
automationLaneCount: (element: TimelineElement) => number = automationLaneCountOf,
): TimelineElement | null {
// Automation counts toward "has something to disclose". Without it an audio
// clip carrying envelopes but no tweens resolved to null, so its track got no
// caret, no reserved height and no lanes — the automation was unreachable for
// exactly the tracks the feature is for.
const disclosable = (element: TimelineElement): number =>
(laneCounts.get(element.key ?? element.id) ?? 0) + automationLaneCount(element);
const keyframed = elements.filter((element) => disclosable(element) >= 1);
if (keyframed.length === 0) return null;
const selected = keyframed.find((element) => {
const key = element.key ?? element.id;
return key === selectedElementId || selectedElementIds.has(key);
});
if (selected) return selected;
// Most lanes wins, first one on a tie (same as the old stable sort), but as a
// reduce over the already non-empty list so there's no index to assert on.
return keyframed.reduce((best, element) =>
disclosable(element) > disclosable(best) ? element : best,
);
}
/** Lanes per clip: the count of distinct property groups whose tween contributes
* a lane (real keyframes or a synthesizable flat tween). */
function computeLaneCounts(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
): Map<string, number> {
const laneCounts = new Map<string, number>();
for (const [, elements] of tracks) {
for (const element of elements) {
const clipId = element.key ?? element.id;
const propertyGroups = new Set<string>();
for (const animation of gsapAnimations.get(clipId) ?? []) {
// Same helper the rendered lanes count through, so a reserved row and a
// drawn lane can never disagree.
for (const group of animationLaneGroups(animation)) propertyGroups.add(group);
}
laneCounts.set(clipId, propertyGroups.size);
}
}
return laneCounts;
}
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
* looks at a row's clips — always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
* group's own `∿` (bus strip) is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet<string>,
): number[] {
if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
return TRACK_H + STRIP_H;
});
}
function useTimelineRowHeights(
tracks: [number, TimelineElement[]][],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
groups: readonly TimelineTrackGroupInfo[],
) {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several
// keyframed elements never reserves empty lanes for the ones not shown.
// Automation lanes follow the whole row: they are shared per property.
const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
const active = resolveTrackKeyframeClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
);
if (!active) return [];
const clipId = active.key ?? active.id;
// `trackHeights` gates the reserved lanes on this id being expanded, and the
// row is expanded when ANY of its clips is — so hand it whichever clip holds
// the row open, while the lane counts stay the active clip's (keyframes) and
// the track's (automation, shared across the row).
const holdingOpen = elements.find((element) =>
expandedClipIds.has(element.key ?? element.id),
);
return [
{
clipId: holdingOpen ? (holdingOpen.key ?? holdingOpen.id) : clipId,
laneCount: laneCounts.get(clipId) ?? 0,
automationLaneCount: trackAutomationLaneCount(elements),
},
];
});
const rowHeights = applyGroupStripHeights(
tracks,
trackHeights(heightTracks, expandedClipIds),
groups,
expandedLaneOwnerIds,
);
return {
laneCounts,
rowGeometry: createTimelineRowGeometry(
tracks.map(([track]) => track),
rowHeights,
),
};
}, [
expandedClipIds,
expandedLaneOwnerIds,
gsapAnimations,
groups,
tracks,
selectedElementId,
selectedElementIds,
]);
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
rowGeometryRef.current = rowGeometry;
return {
laneCounts,
rowGeometry,
rowGeometryRef,
rowHeights: rowGeometry.rowHeights,
};
}
export function useTimelineTrackLayout(
expandedElements: TimelineElement[],
gsapAnimations: Map<string, GsapAnimation[]>,
selectedElementId: string | null,
selectedElementIds: ReadonlySet<string>,
) {
const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
tracks,
gsapAnimations,
selectedElementId,
selectedElementIds,
groups,
);
return {
tracks,
trackStyles,
trackOrder,
trackOrderRef,
laneCounts,
rowGeometry,
rowGeometryRef,
rowHeights,
groups,
trackGroupOf,
};
}
function useDisplayRowHeights(
displayTrackOrder: readonly number[],
rowGeometry: TimelineRowGeometry,
) {
return useMemo(
() =>
displayTrackOrder.map((track) => {
const row = rowGeometry.getRowIndex(track);
return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
}),
[displayTrackOrder, rowGeometry],
);
}
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
return useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
// 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]);
}
export function useTimelineDisplayLayout(
draggedClip: DraggedClipState | null,
trackOrder: number[],
rowGeometry: TimelineRowGeometry,
) {
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
const displayRowGeometry = useMemo(
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights),
[displayTrackOrder, displayRowHeights],
);
return {
displayTrackOrder,
displayRowHeights: displayRowGeometry.rowHeights,
rowGeometry: displayRowGeometry,
totalH: displayRowGeometry.canvasHeight,
};
}