From e9baba66cf414b9173ae70545ddb7fa7cd94e73f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 18 Aug 2026 21:07:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20a=20group's=20own=20automation?= =?UTF-8?q?=20lanes,=20where=20its=20=E2=88=BF=20said=20they=20were?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- .../src/hooks/timelineAudioGroupVolume.ts | 2 + .../components/TimelineAutomationLane.tsx | 180 ++++++++++++++++++ .../player/components/TimelineGroupRow.tsx | 66 ++++++- .../src/player/components/TimelineLanes.tsx | 10 + .../components/groupAutomationElement.test.ts | 51 +++++ .../components/groupAutomationElement.ts | 37 ++++ .../components/useTimelineTrackDerivations.ts | 9 + .../components/useTimelineTrackLayout.test.ts | 43 ++++- .../components/useTimelineTrackLayout.ts | 16 +- .../hooks/useExpandedTimelineElements.ts | 1 + .../player/hooks/useTimelineSyncCallbacks.ts | 1 + packages/studio/src/player/lib/timelineDOM.ts | 2 + .../src/player/lib/timelineGroupInfo.ts | 2 + .../studio/src/player/store/playerStore.ts | 2 + .../src/player/store/timelineElement.ts | 2 + 15 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/studio/src/player/components/groupAutomationElement.test.ts create mode 100644 packages/studio/src/player/components/groupAutomationElement.ts diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.ts index dc76d86f4..d2d9dbaa0 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.ts @@ -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 }), }; /** diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 061fea8e4..250cbc28a 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -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({ ); } + +/** 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 ( + 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(); + 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) => ( + + ))} + + ); +} diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 6195663cf..e793aa5f1 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -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; 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. +
+ 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} + /> +
+ )} ); } diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 898405e1f..6e1cc2895 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -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} /> ); } diff --git a/packages/studio/src/player/components/groupAutomationElement.test.ts b/packages/studio/src/player/components/groupAutomationElement.test.ts new file mode 100644 index 000000000..1044d16fe --- /dev/null +++ b/packages/studio/src/player/components/groupAutomationElement.test.ts @@ -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 ``, 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); + }); +}); diff --git a/packages/studio/src/player/components/groupAutomationElement.ts b/packages/studio/src/player/components/groupAutomationElement.ts new file mode 100644 index 000000000..c6954c9b8 --- /dev/null +++ b/packages/studio/src/player/components/groupAutomationElement.ts @@ -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, + compositionDuration: number, +): TimelineElement { + return { + id: group.id, + // The DOM id, so a write lands on the `` 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 } : {}), + }; +} diff --git a/packages/studio/src/player/components/useTimelineTrackDerivations.ts b/packages/studio/src/player/components/useTimelineTrackDerivations.ts index 6d33391ca..0c230dfd8 100644 --- a/packages/studio/src/player/components/useTimelineTrackDerivations.ts +++ b/packages/studio/src/player/components/useTimelineTrackDerivations.ts @@ -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; hiddenByGroup: Map; fxChainByGroup: Map; + automationByGroup: Map; } /** 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(); const hiddenByGroup = new Map(); const fxChainByGroup = new Map(); + const automationByGroup = new Map(); 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 } : {}), }; } diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index 3a072e3d2..2a8da0ab0 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -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 | 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 diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index ed6742080..6597f561f 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -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; }); } diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 14e0a6ea0..c9410db4c 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -168,6 +168,7 @@ function childGroupState( audioGroupVolume: source.audioGroupVolume, audioGroupHidden: source.audioGroupHidden, audioGroupFxChain: source.audioGroupFxChain, + audioGroupAutomation: source.audioGroupAutomation, }; } diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 9b21562c3..ab95bf5b7 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -107,6 +107,7 @@ function readChildAudioGroupState(child: Element): Partial { audioGroupVolume: info.volume, audioGroupHidden: info.hidden, ...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}), + ...(info.automation ? { audioGroupAutomation: info.automation } : {}), }; } diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index b699f3b59..0131ffe60 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -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 diff --git a/packages/studio/src/player/lib/timelineGroupInfo.ts b/packages/studio/src/player/lib/timelineGroupInfo.ts index 4a186d391..8c4f3026b 100644 --- a/packages/studio/src/player/lib/timelineGroupInfo.ts +++ b/packages/studio/src/player/lib/timelineGroupInfo.ts @@ -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 } : {}), }, ]), ); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 8e23092cb..9055b4257 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -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 { diff --git a/packages/studio/src/player/store/timelineElement.ts b/packages/studio/src/player/store/timelineElement.ts index b73491835..76e40c13e 100644 --- a/packages/studio/src/player/store/timelineElement.ts +++ b/packages/studio/src/player/store/timelineElement.ts @@ -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" > >;