mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8052f3e68f
commit
e1c9b50948
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { groupLevels } from "../store/groupLevels";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
groupLevels.notify(new Map());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
|
||||
const onVolumeChange = vi.fn();
|
||||
const onVolumeCommit = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineGroupBusStrip
|
||||
groupId="vo"
|
||||
volume={1}
|
||||
memberLabels={["vo-1", "vo-2"]}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeCommit={onVolumeCommit}
|
||||
theme={defaultTimelineTheme}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { onVolumeChange, onVolumeCommit };
|
||||
}
|
||||
|
||||
function slider(): HTMLInputElement {
|
||||
return container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
}
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
/** React tracks the DOM's own value setter to detect real changes — a plain
|
||||
* `input.value = ...` assignment is invisible to it, so onChange never
|
||||
* fires. Go through the native setter, same as this codebase's other
|
||||
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
|
||||
function setSliderValue(input: HTMLInputElement, value: string) {
|
||||
nativeInputValueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("TimelineGroupBusStrip", () => {
|
||||
it('renders "Holds …" from the member labels, comma-joined', () => {
|
||||
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
|
||||
expect(container.textContent).toContain("Holds vo-1, vo-2");
|
||||
});
|
||||
|
||||
it("falls back to a neutral line when a group has no members yet", () => {
|
||||
renderStrip({ memberLabels: [] });
|
||||
expect(container.textContent).toContain("Holds nothing yet");
|
||||
});
|
||||
|
||||
it("live-writes on every drag tick, but only commits once on release", () => {
|
||||
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
|
||||
const input = slider();
|
||||
|
||||
act(() => setSliderValue(input, "1.2"));
|
||||
act(() => setSliderValue(input, "1.5"));
|
||||
expect(onVolumeChange).toHaveBeenCalledTimes(2);
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
|
||||
expect(onVolumeCommit).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
input.value = "1.5";
|
||||
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
||||
});
|
||||
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
|
||||
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
|
||||
});
|
||||
|
||||
it("clamps the volume slider to the 0..2 range", () => {
|
||||
const { onVolumeChange } = renderStrip();
|
||||
const input = slider();
|
||||
expect(input.min).toBe("0");
|
||||
expect(input.max).toBe("2");
|
||||
|
||||
act(() => setSliderValue(input, "2"));
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
// Clipping stops — the warning must still hold for a couple seconds.
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2001);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip({ volume: 1.5 });
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent ?? "").not.toMatch(/dB/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* B7: the group's own volume slider + a living level bar + "Holds …" — the
|
||||
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
|
||||
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
|
||||
* the sound, and the words "Too loud" when it clips.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGroupLevel } from "../../hooks/useGroupLevel";
|
||||
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
|
||||
/** How long "Too loud" stays lit after the last clipped block. */
|
||||
const CLIP_HOLD_MS = 2000;
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
return Math.min(2, Math.max(0, value));
|
||||
}
|
||||
|
||||
interface TimelineGroupBusStripProps {
|
||||
groupId: string;
|
||||
volume: number;
|
||||
memberLabels: readonly string[];
|
||||
onVolumeChange: (value: number) => void;
|
||||
onVolumeCommit: (value: number) => void;
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
export function TimelineGroupBusStrip({
|
||||
groupId,
|
||||
volume,
|
||||
memberLabels,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
theme,
|
||||
}: TimelineGroupBusStripProps) {
|
||||
const [dragValue, setDragValue] = useState<number | null>(null);
|
||||
const reading = useGroupLevel(groupId);
|
||||
const [clipped, setClipped] = useState(false);
|
||||
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reading?.clipped) return;
|
||||
setClipped(true);
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
|
||||
}, [reading?.clipped]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const shownVolume = dragValue ?? volume;
|
||||
const level = Math.min(1, reading?.level ?? 0);
|
||||
const holdsText =
|
||||
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
|
||||
style={{ top: TRACK_H, height: STRIP_H }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Group volume"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={shownVolume}
|
||||
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
|
||||
onChange={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(next);
|
||||
onVolumeChange(next);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(null);
|
||||
onVolumeCommit(next);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
||||
style={{ background: theme.gutterBorder }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${level * 100}%`,
|
||||
background: clipped ? "#ff5c5c" : "#3CE6AC",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
||||
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
||||
{holdsText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ 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 { LABEL_COL_W } from "./timelineLayout";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
|
||||
interface TimelineGroupRowProps {
|
||||
index: number;
|
||||
@@ -46,6 +48,12 @@ export function TimelineGroupRow({
|
||||
const memberElements = group.memberTracks.flatMap(
|
||||
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
|
||||
);
|
||||
const memberLabels = group.memberTracks.map((track, i) => {
|
||||
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
|
||||
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
|
||||
});
|
||||
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
||||
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
||||
return (
|
||||
<TimelineTrackRow
|
||||
index={index}
|
||||
@@ -67,11 +75,30 @@ export function TimelineGroupRow({
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||
laneCount={groupAutomationLanes(memberElements).length}
|
||||
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
|
||||
isLaneOpen={isLaneOpen}
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</TimelineTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@ export interface TimelineEditCallbacks {
|
||||
options?: { coalesceKey?: string },
|
||||
) => Promise<void> | void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
||||
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
||||
/** ...and persist one undo entry on release. */
|
||||
onSetAudioGroupAttributeQuiet?: (
|
||||
groupId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
|
||||
@@ -7,10 +7,10 @@ export const GUTTER = 32;
|
||||
export const LABEL_COL_W = 232;
|
||||
export const TRACK_H = 48;
|
||||
export const LANE_H = 28;
|
||||
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
|
||||
export const RULER_H = 24;
|
||||
export const CLIP_Y = 3;
|
||||
export const CLIP_HANDLE_W = 18;
|
||||
|
||||
export interface TimelineBeatEntry {
|
||||
readonly index: number;
|
||||
readonly time: number;
|
||||
|
||||
@@ -16,31 +16,68 @@ export interface TimelineTrackGroupInfo {
|
||||
anchorKey: number;
|
||||
/** Member track numbers, ascending. */
|
||||
memberTracks: number[];
|
||||
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface GroupMembership {
|
||||
trackToGroupId: Map<number, string>;
|
||||
memberTracksByGroup: Map<string, number[]>;
|
||||
labelByGroup: Map<string, string>;
|
||||
volumeByGroup: Map<string, number>;
|
||||
}
|
||||
|
||||
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
|
||||
/** Which track belongs to which group, and each group's label/volume — 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>();
|
||||
const volumeByGroup = new Map<string, number>();
|
||||
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);
|
||||
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
||||
}
|
||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||
members.push(trackNum);
|
||||
memberTracksByGroup.set(owner.audioGroup, members);
|
||||
}
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup };
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup };
|
||||
}
|
||||
|
||||
/** One group's resolved row info, built once the first time its id is seen. */
|
||||
function buildGroupInfo(
|
||||
groupId: string,
|
||||
fallbackTrackNum: number,
|
||||
membership: GroupMembership,
|
||||
): TimelineTrackGroupInfo {
|
||||
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
return {
|
||||
id: groupId,
|
||||
label: membership.labelByGroup.get(groupId) ?? groupId,
|
||||
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
|
||||
memberTracks,
|
||||
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a group's synthetic anchor row plus its members' rows, contiguously. */
|
||||
function emitGroupRows(
|
||||
info: TimelineTrackGroupInfo,
|
||||
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
|
||||
tracks: [number, TimelineElement[]][],
|
||||
): void {
|
||||
tracks.push([info.anchorKey, []]);
|
||||
for (const member of info.memberTracks) {
|
||||
trackGroupOf.set(member, info);
|
||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +91,7 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
||||
groups: TimelineTrackGroupInfo[];
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
||||
} {
|
||||
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
|
||||
const membership = resolveGroupMembership(rawTracks);
|
||||
const rawByTrack = new Map(rawTracks);
|
||||
const groups: TimelineTrackGroupInfo[] = [];
|
||||
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
|
||||
@@ -62,26 +99,16 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
||||
const tracks: [number, TimelineElement[]][] = [];
|
||||
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const groupId = trackToGroupId.get(trackNum);
|
||||
const groupId = membership.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,
|
||||
};
|
||||
const info = buildGroupInfo(groupId, trackNum, membership);
|
||||
groups.push(info);
|
||||
tracks.push([info.anchorKey, []]);
|
||||
for (const member of memberTracks) {
|
||||
trackGroupOf.set(member, info);
|
||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
||||
}
|
||||
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
|
||||
}
|
||||
return { tracks, groups, trackGroupOf };
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ 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";
|
||||
|
||||
@@ -127,13 +129,35 @@ function computeLaneCounts(
|
||||
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
|
||||
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
|
||||
},
|
||||
];
|
||||
});
|
||||
const rowHeights = trackHeights(heightTracks, expandedClipIds);
|
||||
const rowHeights = applyGroupStripHeights(
|
||||
tracks,
|
||||
trackHeights(heightTracks, expandedClipIds),
|
||||
groups,
|
||||
expandedLaneOwnerIds,
|
||||
);
|
||||
return {
|
||||
laneCounts,
|
||||
rowGeometry: createTimelineRowGeometry(
|
||||
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
|
||||
rowHeights,
|
||||
),
|
||||
};
|
||||
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
||||
}, [
|
||||
expandedClipIds,
|
||||
expandedLaneOwnerIds,
|
||||
gsapAnimations,
|
||||
groups,
|
||||
tracks,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
]);
|
||||
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
||||
rowGeometryRef.current = rowGeometry;
|
||||
return {
|
||||
@@ -197,6 +234,7 @@ export function useTimelineTrackLayout(
|
||||
gsapAnimations,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
groups,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user