mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +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> * 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>
309 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|