feat(studio,core): reach presets and the rack from the timeline

C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).

The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).

The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).

Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
  (data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
  (timelineElementFxAttribute.ts), addressed by the TimelineElement
  itself rather than the current selection. This is the one real
  architectural gap the step doc's assumption didn't survive: the
  property panel's onSetAttributeQuiet closes over domEditSelection,
  so writing a clip that isn't already selected has no synchronous
  path through it. Extracted the shared live-patch-then-persist core
  (persistElementAttribute, timelineEditingHelpers.ts) out of both
  this new path and the existing setAudioGroupAttribute, which the
  fallow duplication gate flagged as a 66-line clone on first pass —
  now a single ~50-line core parameterized by patchLive/readLive, with
  each caller a ~15-line wrapper resolving its own patch target
  (buildPatchTarget({domId}) for a group, buildPatchTarget(element)
  for an arbitrary clip) and live-DOM lookup.

Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.

Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.

Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:31 -07:00
co-authored by Claude Sonnet 5
parent 301cb8b51f
commit 1d5405e3e0
22 changed files with 963 additions and 82 deletions
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
import { TimelineFxButton } from "./TimelineFxButton.js";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
}
function mount(node: React.ReactElement) {
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(node));
return host;
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("TimelineFxButton", () => {
it("reads FX with no count when the chain is empty", () => {
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(byTextButton(host, "FX")?.textContent).toBe("FX");
});
it("counts only enabled nodes", () => {
const chain = {
version: 1 as const,
nodes: [
{ type: "peaking", params: {}, enabled: true },
{ type: "gain", params: {}, enabled: false },
],
};
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={serializeAudioFxChain(chain)}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(byTextButton(host, "FX 1")).toBeDefined();
});
it("opens the popover on click, anchored off the button", () => {
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
onChainChange={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
expect(host.querySelector('[role="dialog"]')).toBeNull();
act(() => byTextButton(host, "FX")?.click());
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
});
it("group-pointer variant offers Group instead of a popover", () => {
const onGroupClips = vi.fn();
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
act(() => byTextButton(host, "FX")?.click());
const groupButton = document.body.querySelectorAll("button");
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
expect(group).toBeDefined();
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,143 @@
/**
* The FX entry point from the timeline (C1) — a track or group header button
* that opens the same preset shelf the property panel's rack uses, without
* requiring a trip through the panel first.
*
* Rendered on group rows and on track rows holding exactly one audio clip
* (see `plans/audio-mixer-groups.md` §1.6 and the execution runbook's C1 step
* — a track with several ungrouped clips has no single chain to point at, so
* it gets the grouping pointer instead of the popover).
*/
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
enabledAudioFxNodes,
parseAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
try {
return parseAudioFxChain(raw);
} catch {
return { version: 1, nodes: [] };
}
}
interface TimelineFxButtonChainProps {
variant?: "chain";
trackKind?: HfAudioNameKind;
onOpenRack: () => void;
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
onAuditionTransport?: (on: boolean) => void;
}
interface TimelineFxButtonGroupPointerProps {
variant: "group-pointer";
onGroupClips: () => void;
}
type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
export function TimelineFxButton(props: TimelineFxButtonProps) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const openAt = () => {
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
setOpen(true);
};
if (props.variant === "group-pointer") {
return (
<div className="relative shrink-0">
<button
type="button"
tabIndex={-1}
ref={buttonRef}
aria-label="Effects — group these clips first"
title="Group these clips to add effects to all of them"
className="flex h-6 items-center justify-center rounded border-0 bg-transparent px-1 text-[10px] font-semibold text-white/35 hover:text-white/75"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
openAt();
}}
>
FX
</button>
{open &&
anchorRect &&
createPortal(
<div
role="dialog"
aria-label="Group these clips to add effects"
className="z-50 w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
onPointerDown={(event) => event.stopPropagation()}
>
<p>Group these clips to add effects to all of them.</p>
<button
type="button"
className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
onClick={() => {
setOpen(false);
props.onGroupClips();
}}
>
Group
</button>
</div>,
document.body,
)}
</div>
);
}
const chain = parseFxChainOrEmpty(props.fxChainRaw);
const nodeCount = enabledAudioFxNodes(chain).length;
return (
<div className="relative shrink-0">
<button
type="button"
tabIndex={-1}
ref={buttonRef}
aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
title="Effects"
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
open || nodeCount > 0 ? "text-[#3CE6AC]" : "text-white/35 hover:text-white/75"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
openAt();
}}
>
FX{nodeCount > 0 ? ` ${nodeCount}` : ""}
</button>
{open &&
anchorRect &&
createPortal(
<TimelineFxPopover
anchorRect={anchorRect}
chain={chain}
trackKind={props.trackKind}
onClose={() => setOpen(false)}
onChainChange={props.onChainChange}
onChainPreview={props.onChainPreview}
onAuditionTransport={props.onAuditionTransport}
onOpenRack={props.onOpenRack}
/>,
document.body,
)}
</div>
);
}
@@ -1,6 +1,8 @@
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
interface TimelineGroupHeaderProps {
label: string;
@@ -21,14 +23,19 @@ interface TimelineGroupHeaderProps {
isHalfLitSolo: boolean;
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
onToggleSolo: (options?: { add?: boolean }) => void;
/** C1: the group's serialized `data-fx-chain`, when set. */
fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void;
onFxAuditionTransport?: (on: boolean) => void;
onOpenFxRack: () => void;
columnWidth: number;
theme: TimelineTheme;
}
/**
* A group's own row header: caret (member disclosure) + `▤` + label + count +
* mute + solo + `∿ n` (lane disclosure). The FX entry point (C1) lands here
* as a sibling once that step exists.
* mute + solo + FX + `∿ n` (lane disclosure).
*/
export function TimelineGroupHeader({
label,
@@ -43,6 +50,11 @@ export function TimelineGroupHeader({
isSoloed,
isHalfLitSolo,
onToggleSolo,
fxChain,
onFxChainChange,
onFxChainPreview,
onFxAuditionTransport,
onOpenFxRack,
columnWidth,
theme,
}: TimelineGroupHeaderProps) {
@@ -132,6 +144,13 @@ export function TimelineGroupHeader({
>
<span aria-hidden="true"></span>
</button>
<TimelineFxButton
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
onAuditionTransport={onFxAuditionTransport}
onOpenRack={onOpenFxRack}
/>
<button
type="button"
tabIndex={-1}
@@ -1,6 +1,11 @@
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
@@ -10,6 +15,7 @@ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
interface TimelineGroupRowProps {
index: number;
@@ -56,9 +62,24 @@ export function TimelineGroupRow({
});
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
const domEditActions = useDomEditActionsContextOptional();
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
const memberIds = memberElements.map((el) => el.key ?? el.id);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
const openGroupFxRack = () => {
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id,
);
if (!target) return;
void domEditActions
?.buildDomSelectionFromTarget(target)
.then((selection) => selection && domEditActions.applyDomSelection(selection));
};
return (
<TimelineTrackRow
index={index}
@@ -94,6 +115,10 @@ export function TimelineGroupRow({
isSoloed={soloed.has(group.id)}
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
onToggleSolo={(options) => toggleSolo(group.id, options)}
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
onOpenFxRack={openGroupFxRack}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
@@ -1,7 +1,17 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
import { TimelineFxButton } from "./TimelineFxButton";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
@@ -380,6 +390,31 @@ export function TimelineTrackHeader({
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// C1: the FX entry point. A single audio clip has one chain to point at; a
// track holding several ungrouped ones has no single chain — the design
// doc refuses to build "N clips = N chains", so that case gets a pointer
// at grouping (B6's normative rule) instead of a popover.
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const singleAudioClip =
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
const openClipFxRack = (clip: TimelineElement) => {
void domEditActions?.handleTimelineElementSelect(clip);
};
const groupUngroupedClips = () => {
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
if (!doc || !onGroupClips) return;
const clipIds = trackElements.map((el) => el.key ?? el.id);
void onGroupClips(clipIds, mintGroupId(doc));
};
return (
<div
role="rowheader"
@@ -398,19 +433,34 @@ export function TimelineTrackHeader({
}}
>
{!keyframeClip || !disclosable ? (
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
/>
<>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
/>
{singleAudioClip && (
<TimelineFxButton
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
)}
</>
) : (
<>
<LayerDisclosureRow
@@ -77,6 +77,23 @@ export interface TimelineEditCallbacks {
value: string | null,
label: string,
) => Promise<void>;
/** C1's ungrouped-track FX pointer: "Group these clips" — write
* `data-audio-group` on every one of them, atomically. Same shape B6's
* carve auto-grouping uses. */
onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
/** C1's single-clip FX write: addressed by the clip itself rather than the
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
onSetElementAttributeLive?: (
element: TimelineElement,
attr: string,
value: string | null,
) => void;
onSetElementAttributeQuiet?: (
element: TimelineElement,
attr: string,
value: string | null,
label: string,
) => Promise<void>;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -20,6 +20,8 @@ export interface TimelineTrackGroupInfo {
volume: number;
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
hidden: boolean;
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
fxChain?: string;
}
interface GroupMembership {
@@ -28,15 +30,17 @@ interface GroupMembership {
labelByGroup: Map<string, string>;
volumeByGroup: Map<string, number>;
hiddenByGroup: Map<string, boolean>;
fxChainByGroup: Map<string, string | undefined>;
}
/** Which track belongs to which group, and each group's label/volume/hidden — one pass over raw tracks. */
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
const trackToGroupId = new Map<number, string>();
const memberTracksByGroup = new Map<string, number[]>();
const labelByGroup = new Map<string, string>();
const volumeByGroup = new Map<string, number>();
const hiddenByGroup = new Map<string, boolean>();
const fxChainByGroup = new Map<string, string | undefined>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
@@ -45,12 +49,20 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
memberTracksByGroup.set(owner.audioGroup, members);
}
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup, hiddenByGroup };
return {
trackToGroupId,
memberTracksByGroup,
labelByGroup,
volumeByGroup,
hiddenByGroup,
fxChainByGroup,
};
}
/** One group's resolved row info, built once the first time its id is seen. */
@@ -62,6 +74,7 @@ function buildGroupInfo(
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
);
const fxChain = membership.fxChainByGroup.get(groupId);
return {
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
@@ -69,6 +82,7 @@ function buildGroupInfo(
memberTracks,
volume: membership.volumeByGroup.get(groupId) ?? 1,
hidden: membership.hiddenByGroup.get(groupId) ?? false,
...(fxChain ? { fxChain } : {}),
};
}