mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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"
|
||||
>
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user