feat(studio): a group's own automation lanes, where its ∿ said they were

Expanding a group's `∿` showed the bus strip and nothing else, while the button
beside it advertised a lane count. Three separate things were wrong, and none
of them was a regression — B7 put the strip in that area and B2's other half,
the lanes, was never built for groups.

**The count measured the wrong element.** It read
`groupAutomationLanes(memberElements)` — the MEMBERS' lanes. `∿` is per-row
(groups doc §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), so
a group advertised curves it does not own and cannot show. On the playground
that read `∿4` for a group with one lane of its own.

**The group's `data-automation` never reached the UI.** `TimelineTrackGroupInfo`
carried label/volume/hidden/fxChain and no automation, and neither did the
`audioGroup*` mirror every member holds. Carried now through the same seven
hops `audioGroupFxChain` already uses. `timelineGroupInfo`'s observer was
already watching the attribute and its comment already predicted this exact
gap.

**Nothing rendered them, and the row had no room.** `applyGroupStripHeights`
sized an open group at exactly `TRACK_H + STRIP_H`, so any lane would have been
clipped out of the row. It now adds the group's own lanes.

Rendering them needed the missing-entity problem answered (§1.9: "a group is
the first real audio entity in the system"). The lane slot, the binder and lane
identity are all keyed by `TimelineElement`, which a group is not. Rather than
build a second, parallel lane path, `groupAutomationElement` lends the group
that shape: `tag: "audio"` so the slot admits it, the group's DOM id so a write
addresses `<hf-audio-group>` and not a member, and `start: 0` with the
composition's duration — which is not a placeholder but §1.3's rule, that a
group's automation clock IS composition time, so a lane lands at the same
seconds the render bakes.

Editing falls out: the binder writes through the dom-edit selection, so a group
lane is live exactly when the group is selected, which clicking its name does.
Lanes get the accent rail §5 asks for. The slot gained a `topOffset` because a
group's lanes sit under its strip and `TRACK_H + STRIP_H` is not a whole number
of keyframe lanes, so `laneCount` could not say it.

Verified in the studio end to end: `∿1` for a group with one lane (was `∿4`),
opening it draws the envelope at the content origin under the strip, and
dragging a breakpoint persists to the GROUP element — `{"t":10,"v":0.3}` became
`{"t":10.004,"v":0.85}` on `#sfx`, with the members untouched. The geometry
test fails without the fix ("expected 88 to be 160").

Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD clean, studio suite 4337 green.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:55 -07:00
parent 926496f6b6
commit e9baba66cf
15 changed files with 419 additions and 5 deletions
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
import { usePlayerStore } from "../player";
import type { TimelineElementPatch } from "../player/store/timelineElement";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
@@ -52,6 +53,7 @@ const GROUP_ATTR_TO_MIRROR: Record<
"data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
"data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
[HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
[HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ audioGroupAutomation: value ?? undefined }),
};
/**
@@ -27,6 +27,7 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import {
resolveAutomationRange,
sampleAutomationLane,
type AutomationRange,
type HfAutomation,
@@ -61,6 +62,12 @@ const LANE_BORDER = defaultTimelineTheme.rowBorder;
/** Selection box on one lane. Value bounds included: a point at the right time
* but the wrong value is not in it. */
type SelectionBox = { t0: number; t1: number; v0: number; v1: number };
import { getTimelineLaneTop } from "./timelineLayout";
import { groupAutomationLanes } from "./automationLaneData";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
/** Is this breakpoint inside the selection box? The rule itself is shared with
* Delete and with the group drag, so what is drawn as caught is exactly what
@@ -497,3 +504,176 @@ export function TimelineAutomationLane({
</div>
);
}
/** Which shared rows one clip draws into, and with which of its lanes. */
interface ClipLaneRow {
lane: HfAutomationLane;
rowIndex: number;
}
/**
* One clip's envelopes, each in the shared row its property owns.
*
* Its own component because every clip on the row needs its own binding, its own
* gestures and its own selection box — a shared row is a shared lane track, not a
* shared envelope, and two clips' curves must never drag as one thing. Hooks
* cannot run in a loop, so the loop is over components.
*/
function ClipAutomationLanes({
element,
rows,
isSelected,
lanes,
pps,
top,
accentColor,
currentTime,
beatTimes,
}: {
element: TimelineElement;
rows: readonly ClipLaneRow[];
isSelected: boolean;
lanes: UseAutomationLanesResult;
pps: number;
/** y of the first automation row on this track. */
top: number;
accentColor: string;
currentTime: number;
beatTimes?: readonly number[];
}) {
// Beats inside this clip, in the clip's own frame — the lane's times are
// clip-local, and a beat outside the clip can never be snapped to anyway.
const snapTimes = useMemo(
() =>
(beatTimes ?? [])
.filter((t) => t >= element.start && t <= element.start + element.duration)
.map((t) => t - element.start),
[beatTimes, element.start, element.duration],
);
const bound = lanes.bind(element, isSelected);
// Stale-selection guard: the selected lane's target can vanish out from under
// it (e.g. its effect got deleted from the chain, dropping the lane), leaving
// a rectangle selecting nothing. Clear it rather than let it point at a
// target that no longer draws. Above the empty-rows return, because a clip
// that draws nothing is exactly when a selection goes stale.
useEffect(() => {
const target = bound.selection?.target;
if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) {
bound.onRangeClear();
}
}, [bound]);
if (rows.length === 0) return null;
const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
return (
<>
{rows.map(({ lane, rowIndex }) => {
const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
// A lane whose target no longer resolves was already dropped upstream;
// this is belt and braces so a row can never draw on the wrong axis.
if (!range) return null;
return (
<TimelineAutomationLane
key={lane.target}
duration={element.duration}
widthPx={Math.max(element.duration * pps, 4)}
leftPx={element.start * pps}
topPx={top + rowIndex * AUTOMATION_LANE_H}
automation={bound.automation}
target={lane.target}
range={range}
accentColor={accentColor}
playheadSec={inClip ? currentTime - element.start : null}
onPreview={bound.onPreview}
onCommit={bound.onCommit}
onSelect={bound.onSelect}
snapTimes={snapTimes}
readOnly={bound.readOnly}
rangeSelection={
bound.selection?.target === lane.target
? {
t0: bound.selection.t0,
t1: bound.selection.t1,
v0: bound.selection.v0,
v1: bound.selection.v1,
}
: null
}
onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
onRangeClear={bound.onRangeClear}
/>
);
})}
</>
);
}
export interface TimelineAutomationLaneSlotProps {
/** Every clip on the track, in row order — not just the selected one. */
elements: readonly TimelineElement[];
isSelected: (element: TimelineElement) => boolean;
lanes: UseAutomationLanesResult;
pps: number;
/** Keyframe lanes already stacked above, which automation sits under. */
laneCount: number;
/** Exact y for the first lane, overriding `laneCount`. A group's lanes sit
* under its bus strip, and TRACK_H + STRIP_H is not a whole number of
* keyframe lanes, so it cannot be said in `laneCount`. */
topOffset?: number;
accentColor: string;
/** Composition-time playhead; the slot converts it to clip-local. */
currentTime: number;
/** Composition-time beat grid; the slot converts it to clip-local too. */
beatTimes?: readonly number[];
}
/**
* Every automated parameter on this TRACK, one lane per row — the way a DAW
* stacks them, so two envelopes can be read and edited without swapping a
* control to see either.
*
* Rows belong to the track, not to a clip: clips sharing a row share a row per
* property (see `groupAutomationLanes`), each drawing over its own span, and a
* clip that does not automate that property leaves its stretch empty. Binding one
* clip at a time is what made the visible envelopes change with the selection.
*/
export function TimelineAutomationLaneSlot({
elements,
isSelected,
lanes,
pps,
laneCount,
topOffset,
accentColor,
currentTime,
beatTimes,
}: TimelineAutomationLaneSlotProps) {
const clips = elements.filter(isAudioTimelineElement);
const rowsByClip = new Map<string, ClipLaneRow[]>();
groupAutomationLanes(clips).forEach((group, rowIndex) => {
for (const entry of group.entries) {
const key = getTimelineElementIdentity(entry.element);
const rows = rowsByClip.get(key);
if (rows) rows.push({ lane: entry.lane, rowIndex });
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
}
});
const top = topOffset ?? getTimelineLaneTop(laneCount);
return (
<>
{clips.map((element) => (
<ClipAutomationLanes
key={getTimelineElementIdentity(element)}
element={element}
rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
isSelected={isSelected(element)}
lanes={lanes}
pps={pps}
top={top}
accentColor={accentColor}
currentTime={currentTime}
beatTimes={beatTimes}
/>
))}
</>
);
}
@@ -13,6 +13,15 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { groupAutomationElement } from "./groupAutomationElement";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
import { STRIP_H, TRACK_H } from "./timelineLayout";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
/** Accent rail on a group-owned lane — the same green the member rail uses, so
* "this belongs to the group" reads the same in both places (groups doc §5). */
const GROUP_LANE_ACCENT = "#3CE6AC";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
@@ -32,6 +41,14 @@ interface TimelineGroupRowProps {
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
lanes: UseAutomationLanesResult;
pps: number;
currentTime: number;
/** A group's lanes are in composition time (§1.3), so this is their span. */
compositionDuration: number;
beatTimes?: readonly number[];
contentGutter: number;
trackContentWidth: number;
}
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
@@ -50,12 +67,27 @@ export function TimelineGroupRow({
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
lanes,
pps,
currentTime,
compositionDuration,
beatTimes,
contentGutter,
trackContentWidth,
}: TimelineGroupRowProps) {
// From the group, NOT from `tracks`: a collapsed group emits no member rows
// into the display list, and every one of these reads silently degraded to
// empty in that (default) state — half-lit solo went dark, the lane count
// read 0, and the bus strip fell back to "track 1", "track 2".
const memberElements = group.memberElements;
// The group wearing a clip's shape so the lane machinery can render it — see
// `groupAutomationElement` for why that beats a second, parallel lane path.
const groupElement = groupAutomationElement(group, compositionDuration);
// The binder writes through the dom-edit selection, so a group lane is
// editable exactly when the group is the selected element — which clicking
// its name in the header does.
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
const isGroupSelected = domSelection?.id === group.id;
const memberLabels = group.memberTracks.map((track, i) => {
const owner = memberElements.find((el) => el.track === track && el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
@@ -115,7 +147,11 @@ export function TimelineGroupRow({
memberCount={group.memberTracks.length}
isExpanded={!collapsedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
// The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
// §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
// counting the members' here made the group advertise curves it does
// not own and cannot show.
laneCount={groupAutomationLanes([groupElement]).length}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
hidden={group.hidden}
@@ -163,6 +199,34 @@ export function TimelineGroupRow({
theme={theme}
/>
)}
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected — which clicking its name does. */}
{isLaneOpen && (
// The same offset content cell a track row wraps its lanes in — the
// slot positions absolutely, so mounted straight on the row it resolved
// against the row instead and drew the envelope across the label gutter
// from x=0.
<div
role="gridcell"
aria-colindex={2}
style={{ width: trackContentWidth, marginLeft: contentGutter }}
className="relative"
>
<TimelineAutomationLaneSlot
elements={[groupElement]}
isSelected={() => isGroupSelected}
lanes={lanes}
pps={pps}
// Below the strip, which sits directly under the header row.
laneCount={0}
topOffset={TRACK_H + STRIP_H}
accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
/>
</div>
)}
</TimelineTrackRow>
);
}
@@ -103,6 +103,9 @@ export function TimelineLanes({
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
// synthetic lane element spans the whole composition rather than a clip.
const compositionDuration = usePlayerStore((s) => s.duration);
useAutomationSelectionKeyboard({ lanes: automationLanes });
const expandClips = usePlayerStore((s) => s.expandClips);
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
@@ -179,6 +182,13 @@ export function TimelineLanes({
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={toggleGroupExpanded}
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
lanes={automationLanes}
pps={pps}
currentTime={currentTime}
compositionDuration={compositionDuration}
beatTimes={beatAnalysis?.beatTimes}
contentGutter={contentGutter}
trackContentWidth={trackContentWidth}
/>
);
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { groupAutomationElement } from "./groupAutomationElement";
import { groupAutomationLanes } from "./automationLaneData";
const GROUP = {
id: "voiceover",
label: "Voiceover",
anchorKey: 1.5,
automation: JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 5, v: 0.4 },
],
},
],
}),
};
describe("groupAutomationElement", () => {
// §1.3: "A group's automation clock is COMPOSITION time — decide it, do not
// inherit it." A clip-local span would land the group's fade at a different
// moment in preview than the render bakes it.
it("spans the whole composition from zero, so lane times are composition time", () => {
const el = groupAutomationElement(GROUP, 60);
expect(el.start).toBe(0);
expect(el.duration).toBe(60);
});
// The lane machinery filters with `isAudioTimelineElement`; a group that does
// not pass it renders nothing at all, silently.
it("is admitted by the lane machinery and yields the group's own lanes", () => {
const lanes = groupAutomationLanes([groupAutomationElement(GROUP, 60)]);
expect(lanes).toHaveLength(1);
expect(lanes[0]?.name).toBeTruthy();
});
// The write has to land on `<hf-audio-group>`, not on a member clip.
it("carries the group's DOM id, so a lane edit addresses the group element", () => {
expect(groupAutomationElement(GROUP, 60).domId).toBe("voiceover");
});
// A group with no automation draws no lanes — and must not throw doing it.
it("yields no lanes when the group automates nothing", () => {
const bare = { id: "sfx", label: "SFX", anchorKey: 2.5 };
expect(groupAutomationLanes([groupAutomationElement(bare, 60)])).toHaveLength(0);
});
});
@@ -0,0 +1,37 @@
/**
* The group, as the automation lanes see it.
*
* Lanes are keyed by `TimelineElement` everywhere the slot, the binder, the
* identity used for selection. A group is not one: it has no clip id, no start
* and no duration, which is §1.9's "a group is the first real audio entity in
* the system" problem showing up in the row model. Rather than give lanes a
* second, parallel path, the group borrows the shape it does not have.
*
* `start: 0` and `duration` = the composition's is not a placeholder, it is the
* clock: the design doc fixes a group's automation clock as COMPOSITION time
* (§1.3), and a missing `data-start` parses as 0, which is exactly that. So a
* lane drawn against this element lands at the same seconds the render bakes.
*/
import type { TimelineElement } from "../store/playerStore";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The synthetic element's track number — the group row's own anchor. */
export function groupAutomationElement(
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
compositionDuration: number,
): TimelineElement {
return {
id: group.id,
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
domId: group.id,
label: group.label,
// Audio, so `isAudioTimelineElement` admits it and the lanes render at all.
tag: "audio",
start: 0,
duration: compositionDuration,
track: group.anchorKey,
...(group.automation ? { automation: group.automation } : {}),
...(group.fxChain ? { fxChain: group.fxChain } : {}),
};
}
@@ -33,6 +33,9 @@ export interface TimelineTrackGroupInfo {
hidden: boolean;
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
fxChain?: string;
/** The group element's serialized `data-automation` the group's OWN lanes,
* which are what its `` discloses (groups doc §5). */
automation?: string;
}
interface GroupMembership {
@@ -42,6 +45,7 @@ interface GroupMembership {
volumeByGroup: Map<string, number>;
hiddenByGroup: Map<string, boolean>;
fxChainByGroup: Map<string, string | undefined>;
automationByGroup: Map<string, string | undefined>;
}
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
@@ -52,6 +56,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
const volumeByGroup = new Map<string, number>();
const hiddenByGroup = new Map<string, boolean>();
const fxChainByGroup = new Map<string, string | undefined>();
const automationByGroup = new Map<string, string | undefined>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
@@ -61,6 +66,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
automationByGroup.set(owner.audioGroup, owner.audioGroupAutomation);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
@@ -73,6 +79,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
volumeByGroup,
hiddenByGroup,
fxChainByGroup,
automationByGroup,
};
}
@@ -87,6 +94,7 @@ function buildGroupInfo(
(a, b) => a - b,
);
const fxChain = membership.fxChainByGroup.get(groupId);
const automation = membership.automationByGroup.get(groupId);
return {
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
@@ -96,6 +104,7 @@ function buildGroupInfo(
volume: membership.volumeByGroup.get(groupId) ?? 1,
hidden: membership.hiddenByGroup.get(groupId) ?? false,
...(fxChain ? { fxChain } : {}),
...(automation ? { automation } : {}),
};
}
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { LANE_H, TRACK_H } from "./timelineLayout";
import { LANE_H, STRIP_H, TRACK_H } from "./timelineLayout";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineTrackLayout";
@@ -74,6 +74,47 @@ describe("collapsed audio groups", () => {
return { layout, unmount: () => act(() => root.unmount()) };
}
// The `∿` area holds the bus strip (B7) AND the group's own automation rows
// (B2). Sized for only the strip, every lane the count had just promised was
// clipped out of the row — which is what "expanding automation on a group
// doesn't show the automation" looked like from outside.
it("reserves room for the group's own automation rows, not just the strip", () => {
enabledCanaries.add("audio-groups");
const automation = JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 5, v: 0.4 },
],
},
],
});
const elements = [
{ ...member("voice-1", 0), audioGroupAutomation: automation },
{ ...member("voice-2", 1), audioGroupAutomation: automation },
];
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
return null;
}
const root = createRoot(document.createElement("div"));
act(() => {
usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["voiceover"]) });
root.render(React.createElement(Probe));
});
// The group's anchor row: the first member track minus 0.5.
const anchorIndex = layout!.tracks.findIndex(([track]) => track === -0.5);
expect(anchorIndex).toBeGreaterThanOrEqual(0);
const openHeight = layout!.rowHeights[anchorIndex];
// One lane of headroom beyond header + strip.
expect(openHeight).toBe(TRACK_H + STRIP_H + AUTOMATION_LANE_H);
act(() => root.unmount());
});
// buildTimelineLogicalRows stops emitting member rows once a group is
// collapsed, so TimelineLanes renders null for them. Rows left in `tracks`
// still reserve height, turning that null into visible dead space — the row
@@ -15,6 +15,13 @@ import {
type TimelineTrackHeightClip,
} from "./timelineLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import { groupAutomationElement } from "./groupAutomationElement";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
/** Automation rows the GROUP itself owns — its `data-automation`, not its members'. */
function groupOwnLaneCount(group: TimelineTrackGroupInfo): number {
return groupAutomationLanes([groupAutomationElement(group, 0)]).length;
}
export { getTrackStyle } from "./timelineIcons";
@@ -132,8 +139,8 @@ function computeLaneCounts(
/** 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. */
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H and the group's
* own automation rows once its `` is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
@@ -145,7 +152,10 @@ function applyGroupStripHeights(
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;
// The strip AND the group's own automation rows: `∿` discloses both (B7 put
// the bus strip in this area, B2 put the lanes here), so a row sized for
// only the strip clipped every lane it had just promised in the count.
return TRACK_H + STRIP_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
});
}
@@ -168,6 +168,7 @@ function childGroupState(
audioGroupVolume: source.audioGroupVolume,
audioGroupHidden: source.audioGroupHidden,
audioGroupFxChain: source.audioGroupFxChain,
audioGroupAutomation: source.audioGroupAutomation,
};
}
@@ -107,6 +107,7 @@ function readChildAudioGroupState(child: Element): Partial<DomClipChild> {
audioGroupVolume: info.volume,
audioGroupHidden: info.hidden,
...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}),
...(info.automation ? { audioGroupAutomation: info.automation } : {}),
};
}
@@ -147,6 +147,7 @@ export function createTimelineElementFromManifestClip(params: {
entry.audioGroupVolume = info.volume;
entry.audioGroupHidden = info.hidden;
if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
if (info.automation) entry.audioGroupAutomation = info.automation;
}
const fxChain = hostEl.getAttribute("data-fx-chain");
if (fxChain) entry.fxChain = fxChain;
@@ -374,6 +375,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
entry.audioGroupVolume = domGroupInfo.volume;
entry.audioGroupHidden = domGroupInfo.hidden;
if (domGroupInfo.fxChain) entry.audioGroupFxChain = domGroupInfo.fxChain;
if (domGroupInfo.automation) entry.audioGroupAutomation = domGroupInfo.automation;
}
// Sub-compositions
@@ -21,6 +21,7 @@ interface GroupInfo {
volume: number;
hidden: boolean;
fxChain?: string;
automation?: string;
}
/**
@@ -123,6 +124,7 @@ export function groupInfoFor(doc: Document | null | undefined, groupId: string):
volume: group.volume,
hidden: group.hidden,
...(group.fxChain ? { fxChain: group.fxChain } : {}),
...(group.automation ? { automation: group.automation } : {}),
},
]),
);
@@ -245,6 +245,8 @@ export interface DomClipChild {
audioGroupVolume?: number;
audioGroupHidden?: boolean;
audioGroupFxChain?: string;
/** The group element's `data-automation`, mirrored the same way. */
audioGroupAutomation?: string;
}
interface BeatHistoryEntry {
@@ -77,6 +77,7 @@ export interface TimelineElement {
audioGroupHidden?: boolean;
/** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
audioGroupFxChain?: string;
audioGroupAutomation?: string;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child
@@ -114,5 +115,6 @@ export type TimelineElementPatch = Partial<
| "audioGroupVolume"
| "audioGroupHidden"
| "audioGroupFxChain"
| "audioGroupAutomation"
>
>;