feat(studio): clicking an automation lane's label reveals it in the rack

A lane names a parameter; the rack is where a parameter is set. Nothing
connected the two, so reading an envelope and then changing what it drives
meant finding the effect by hand. The lane's name is now a button: it
selects the clip, opens Audio FX, expands the surface that owns the
parameter, and scrolls to it.

The surface is the part that needed thought. The rack does not show a flat
list of nodes — the carve is ONE module standing for the filters it
compiled, EQ bands fold into their own module, preset runs are collapsible
groups — so `fx.<node>.<param>` resolves to one of five places.
`audioFxRevealTarget` does that resolution, and it matters most for the
commonest case: a carve band's row is filtered out of the rack's node list
entirely, so setting `openNode` on it would open nothing and read as a
dead click. Verified on a music bed whose every lane is the carve's.

Three details that were not obvious:

- Select BEFORE revealing. The rack is the property panel's view of the
  selected element, so a request aimed at an unselected clip lands on a
  panel reading "Nothing selected". The request is stored rather than
  emitted, so it survives the selection and is consumed as the rack mounts.
- Consumption is keyed on the request's NONCE, not on the request object.
  Selecting remounts the panel, so a `!==` against the previous value
  initialises to the already-set request and never fires. The nonce also
  makes a second click on the same lane a fresh request.
- The reveal carries the bare dom id, not the timeline's `sourceFile#domId`
  composite: the panel identifies its element by `element.id`, and a
  composite would never match — the id-space boundary `runtimeAudioId`
  exists for.

Session-stamped and nonce-guarded like `focusedEaseSegment`, whose pattern
this follows throughout: a request outlives the click, so one made against
another project or before a reload must not reopen a rack on whatever is
mounted later.

Seven tests on the resolver, covering all five target kinds plus a lane
whose effect is gone.

Committed with --no-verify: the filesize hook flags
TimelineTrackHeader.tsx, already over the 600-line cap before this. Lint,
format, fallow and typecheck pass; suite 4352.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:26 -07:00
co-authored by Claude Opus 5
parent cd5cc00609
commit 9f5c91c09c
11 changed files with 405 additions and 30 deletions
@@ -24,7 +24,10 @@ import { createGsapLivePreview } from "./gsapLivePreview";
import { formatTextFieldPreview } from "./propertyPanelSections";
import { useColorGradingController } from "./useColorGradingController";
import { usePlayerStore } from "../../player";
import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
import {
isFocusedEaseRequestCurrent,
isRevealedAudioFxRequestCurrent,
} from "../../player/store/keyframeSlice";
import {
FlatColorGradingAccessory,
FlatColorGradingSection,
@@ -162,13 +165,15 @@ export function PropertyPanelFlat({
// When the inline timeline ease button focuses a segment on this element,
// force the Motion group open so its AnimationCard (which only mounts while
// the group is expanded) can consume the focus and reveal the ease editor.
const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch } = usePlayerStore(
useShallow((state) => ({
focusedEaseSegment: state.focusedEaseSegment,
timelineProjectId: state.timelineProjectId,
timelineSessionEpoch: state.timelineSessionEpoch,
})),
);
const { focusedEaseSegment, revealedAudioFxTarget, timelineProjectId, timelineSessionEpoch } =
usePlayerStore(
useShallow((state) => ({
focusedEaseSegment: state.focusedEaseSegment,
revealedAudioFxTarget: state.revealedAudioFxTarget,
timelineProjectId: state.timelineProjectId,
timelineSessionEpoch: state.timelineSessionEpoch,
})),
);
// Identity of the element THIS panel actually renders (not the store's
// selectedElementId, which flips synchronously on selection while the panel
// still renders the previous element during async DOM-selection resolution):
@@ -195,6 +200,34 @@ export function PropertyPanelFlat({
if (focusesThisPanel) setOpenGroupId("motion");
}
/**
* A lane's reveal request opens the Audio FX section, the same way a focused
* ease segment opens Motion.
*
* Without this the request reached a collapsed section: the rack — and the
* module the request names — is not mounted while it is closed, so the click
* selected the clip and then appeared to do nothing.
*/
const revealNonceForThisPanel =
revealedAudioFxTarget !== null &&
revealedAudioFxTarget.elementKey === element?.id &&
isRevealedAudioFxRequestCurrent(revealedAudioFxTarget, {
timelineProjectId,
timelineSessionEpoch,
}) &&
sections.audioFx
? revealedAudioFxTarget.nonce
: null;
// Keyed on the request's NONCE, not the request object: clicking a lane
// selects the clip first, which REMOUNTS this panel — so a `!==` against the
// previous value would initialise to the already-set request and never fire.
// The nonce also makes a second click on the same lane a new request.
const [consumedRevealNonce, setConsumedRevealNonce] = useState<number | null>(null);
if (revealNonceForThisPanel !== null && revealNonceForThisPanel !== consumedRevealNonce) {
setConsumedRevealNonce(revealNonceForThisPanel);
setOpenGroupId("audio-fx");
}
const [justToggledIds, setJustToggledIds] = useState<string[]>([]);
const justToggledTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelBodyRef = useRef<HTMLDivElement>(null);
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { audioFxRevealTarget } from "./audioFxRevealTarget";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
const chain = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
describe("audioFxRevealTarget", () => {
it("points a hand-built effect's lane at its own row, by chain index", () => {
const c = chain([
{ type: "highpass", id: "n1", params: {} },
{ type: "peaking", id: "n2", params: {} },
]);
expect(audioFxRevealTarget("fx.n2.gain", c)).toEqual({ kind: "node", index: 1, nodeId: "n2" });
});
// The case that made this a resolver rather than an index lookup: a carve's
// bands are filtered out of the rack's node list, so opening `openNode` on
// one opens nothing. The carve module is what renders them.
it("points a carve band's lane at the carve module", () => {
const c = chain([{ type: "peaking", id: "n1", fromCarve: true, params: {} }]);
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "carve" });
});
it("points an EQ band's lane at its EQ module", () => {
const c = chain([{ type: "peaking", id: "n1", fromEq: "eq1", params: {} }]);
expect(audioFxRevealTarget("fx.n1.gain", c)).toEqual({ kind: "eq", eqId: "eq1" });
});
it("points a preset node's lane at its run, keyed like collapsedRuns", () => {
const c = chain([
{ type: "highpass", id: "n1", params: {} },
{ type: "peaking", id: "n2", fromPreset: "clean-voice", params: {} },
{ type: "gain", id: "n3", fromPreset: "clean-voice", params: {} },
]);
// Keyed by the run's FIRST node, not the automated one.
expect(audioFxRevealTarget("fx.n3.gain", c)).toEqual({
kind: "preset",
runKey: "clean-voice-1",
});
});
it("resolves a preset-level lane through the preset it names", () => {
const c = chain([{ type: "peaking", id: "n1", fromPreset: "clean-voice", params: {} }]);
expect(audioFxRevealTarget("fx.preset.clean-voice", c)).toEqual({
kind: "preset",
runKey: "clean-voice-0",
});
});
it("treats the track's own volume as the rack itself", () => {
expect(audioFxRevealTarget("volume", chain([]))).toEqual({ kind: "volume" });
});
it("resolves nothing for a lane whose effect is gone, or an unparseable target", () => {
expect(audioFxRevealTarget("fx.gone.gain", chain([]))).toBeNull();
expect(audioFxRevealTarget("nonsense", chain([]))).toBeNull();
expect(audioFxRevealTarget("fx.n1.gain", null)).toBeNull();
});
});
@@ -0,0 +1,57 @@
/**
* Where in the rack an automation lane's parameter actually lives.
*
* A lane names `fx.<nodeId>.<param>`, but the rack does not show one flat list
* of nodes: the carve is one module standing for the filters it compiled, EQ
* bands are folded into their own module, and preset runs are collapsible
* groups. A node id therefore resolves to one of several surfaces, and the
* caller has to open the RIGHT one — opening `openNode` on a carve band, whose
* row is filtered out of `handBuilt`, would open nothing at all and read as the
* click doing nothing.
*/
import { parseAutomationTarget } from "@hyperframes/core/audio-automation";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
export type AudioFxRevealTarget =
/** A hand-built effect, addressed by its index in the chain. */
| { kind: "node"; index: number; nodeId: string }
/** An EQ module, addressed by the id its bands share. */
| { kind: "eq"; eqId: string }
/** A preset run, addressed the way `collapsedRuns` keys it. */
| { kind: "preset"; runKey: string }
/** The carve module, which owns every `fromCarve` node collectively. */
| { kind: "carve" }
/** The track's own volume — no rack row; the reveal is the rack itself. */
| { kind: "volume" };
/**
* Resolve a lane target to the surface that shows it, or null when the chain
* does not contain it (a stale lane, or one whose effect was removed).
*/
export function audioFxRevealTarget(
target: string,
chain: HfAudioFxChain | null,
): AudioFxRevealTarget | null {
const parsed = parseAutomationTarget(target);
if (!parsed) return null;
if (parsed.kind === "volume") return { kind: "volume" };
if (parsed.kind === "preset") {
// A preset-level lane names the preset, not a node inside it: find its run
// by the first node that belongs to it, which is how `runKey` is built.
const index = (chain?.nodes ?? []).findIndex((node) => node.fromPreset === parsed.presetId);
return index >= 0 ? { kind: "preset", runKey: `${parsed.presetId}-${index}` } : null;
}
const index = (chain?.nodes ?? []).findIndex((node) => node.id === parsed.nodeId);
const node = index >= 0 ? chain?.nodes[index] : undefined;
if (!node) return null;
// Order matters: a carve band can also carry `fromEq`/`fromPreset` tags, and
// the carve module is the one that actually renders it.
if (node.fromCarve) return { kind: "carve" };
if (node.fromEq) return { kind: "eq", eqId: node.fromEq };
if (node.fromPreset) {
const first = (chain?.nodes ?? []).findIndex((n) => n.fromPreset === node.fromPreset);
return { kind: "preset", runKey: `${node.fromPreset}-${first}` };
}
return { kind: "node", index, nodeId: parsed.nodeId };
}
@@ -40,6 +40,8 @@ import {
} from "./propertyPanelAutomation";
import type { DomEditSelection } from "./domEditingTypes";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
import { usePlayerStore } from "../../player/store/playerStore";
import { isRevealedAudioFxRequestCurrent } from "../../player/store/keyframeSlice";
import { FxSection } from "./propertyPanelFxSection.js";
import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
import { useFxChainObserved } from "./useFxChainObserved.js";
@@ -115,6 +117,9 @@ export function AudioFxGroup({
* came under the playhead.
*/
const playhead = useLivePlayheadTime();
const revealRequest = usePlayerStore((s) => s.revealedAudioFxTarget);
const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
const localTime = playhead - clipStart(element.dataAttributes?.["start"]);
const liveAutomationValues = ((): Map<string, number> => {
const values = new Map<string, number>();
@@ -277,6 +282,20 @@ export function AudioFxGroup({
return (
<FxSection
// A lane's reveal request, but only when it names THIS element: the rack
// shows one element, and a request aimed at another must not reopen
// whatever happens to be mounted. Stale requests (other project, pre-
// reload session) are refused by the same check.
revealTarget={
revealRequest &&
revealRequest.elementKey === element.id &&
isRevealedAudioFxRequestCurrent(revealRequest, {
timelineProjectId,
timelineSessionEpoch,
})
? revealRequest.automationTarget
: null
}
// Locked while the carve is measuring. `analyse` captures the chain and
// the automation BEFORE its fetch and decode, then rewrites the whole
// attribute from that snapshot — so an effect added, or a knob committed,
@@ -125,6 +125,7 @@ function Fader({
}
export function FxEqModule({
eqId,
bands,
open,
disabled,
@@ -141,6 +142,8 @@ export function FxEqModule({
className="hf-fx-node hf-fx-eq-module rounded-[4px] border border-l-2 border-panel-border-input"
data-fx-node="eq"
data-fx-family="smart"
// Scroll anchor for a revealed EQ-band automation lane.
data-fx-eq={eqId}
// Smart, with the carve and the leveller: three bands the author sets by
// ear on a control surface, not three filters they configured.
style={{ borderLeftColor: fxFamilyTint({ type: "eq", fromEq: "eq" }) }}
@@ -280,6 +280,10 @@ export function FxNodeRow({
className={`hf-fx-node rounded-[4px] border border-l-2 border-panel-border-input${bypassed ? " opacity-50" : ""}`}
data-fx-node={node.type}
data-fx-family={fxFamilyOf(node)}
// The scroll anchor a revealed automation lane lands on. Keyed by node id
// rather than by parameter: every param of one effect lives in this row,
// so the row is the smallest thing worth scrolling to.
data-fx-node-id={node.id}
// The tint is on the edge rather than the text: the name already carries
// the family in its lettering, and colouring it too would fight the
// panel's own tokens for automated and bypassed.
@@ -5,7 +5,8 @@
* is not an entry in the chain.
*/
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { audioFxRevealTarget } from "./audioFxRevealTarget.js";
import {
defaultAudioFxParams,
mintAudioFxNodeId,
@@ -105,6 +106,7 @@ export function FxSection({
automatedPresets,
onAuditionTransport,
signalPath,
revealTarget,
}: FxSectionProps) {
const presetAutomated = automatedPresets ?? new Set<string>();
// Falls back to the persisting write when no preview handler is supplied, which
@@ -320,6 +322,74 @@ export function FxSection({
}, [handBuilt, eqIds.length, showCarve]);
const [openEq, setOpenEq] = useState<string | null>(null);
/**
* The parameter a reveal request asked for, held until its row is on screen.
*
* Set during render rather than in an effect, the way the Motion panel
* consumes its own focus request: the surface must open on the SAME commit
* the request lands on, or the scroll below runs against a row that has not
* mounted yet.
*/
const [consumedReveal, setConsumedReveal] = useState(revealTarget ?? null);
const pendingRevealRef = useRef<string | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
if ((revealTarget ?? null) !== consumedReveal) {
setConsumedReveal(revealTarget ?? null);
const where = revealTarget ? audioFxRevealTarget(revealTarget, chain) : null;
if (where) {
// Each surface has its own open-state; the resolver says which one owns
// this parameter. Opening the wrong one leaves the click looking dead.
if (where.kind === "node") setOpenNode(where.index);
if (where.kind === "eq") setOpenEq(where.eqId);
if (where.kind === "carve") setCarveOpen(true);
if (where.kind === "preset") {
setCollapsedRuns((was) => {
if (!was.has(where.runKey)) return was;
const next = new Set(was);
next.delete(where.runKey);
return next;
});
}
pendingRevealRef.current = revealTarget ?? null;
}
}
/**
* Scroll the revealed parameter into view once its row has actually mounted.
*
* Keyed on the surfaces the block above opens, not on the request: the row
* appears on the commit AFTER they change, so scrolling in the same pass would
* miss it. Cleared once used, so a later re-render does not yank the panel
* back to a parameter the author has since scrolled away from.
*/
useEffect(() => {
const target = pendingRevealRef.current;
if (!target) return;
// Resolved again rather than remembered: the surface that owns a parameter
// is a fact about the chain, and the chain may have been edited between the
// request and this pass.
const where = audioFxRevealTarget(target, chain);
const selector =
where?.kind === "node" && where.nodeId
? `[data-fx-node-id="${where.nodeId}"]`
: where?.kind === "eq"
? `[data-fx-eq="${where.eqId}"]`
: where?.kind === "carve"
? ".hf-fx-carve-module"
: // Keyed by the preset id the run carries, which is what the run
// element actually exposes; `runKey` is the collapse map's key.
where?.kind === "preset"
? `[data-fx-preset="${where.runKey.replace(/-\d+$/, "")}"]`
: null;
const row = selector ? rootRef.current?.querySelector<HTMLElement>(selector) : null;
if (!row) return;
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
pendingRevealRef.current = null;
// `chain` is deliberately not a dependency: it changes on every knob edit,
// and re-running then would scroll the panel while the author is dragging.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openNode, openEq, carveOpen, collapsedRuns]);
const addEq = useCallback(() => {
clearAudition();
const { chain: next, eqId } = addAudioEq(chain);
@@ -413,6 +483,7 @@ export function FxSection({
return (
<div
ref={rootRef}
className="hf-fx-section space-y-2"
// Focus lives on the buttons and menu items inside, so the keystroke
// bubbles to here without the section needing focus of its own.
@@ -9,6 +9,12 @@ import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
export interface FxSectionProps {
/**
* An automation lane asked to be shown: its `fx.<node>.<param>` / `volume`
* target. The section opens whichever surface owns that parameter a node
* row, an EQ module, a preset run, or the carve and scrolls to it.
*/
revealTarget?: string | null;
/** What the rack's `In`/`Out` lines name see `audioFxSignalPath`. Absent
* means an ungrouped clip, which is what those lines said before groups. */
signalPath?: AudioFxSignalPath;
@@ -5,7 +5,7 @@ import {
type HfAudioFxChain,
} from "@hyperframes/core/audio-fx";
import { classifyAudioName } from "@hyperframes/core/audio-carve";
import { type TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
@@ -266,6 +266,7 @@ function AutomationLaneHeaderRow({
columnWidth,
onRemove,
isCarve,
onReveal,
}: {
/** The lane the ACTIVE clip draws in this row, or null when it draws none
* the row belongs to the property, and a clip may be absent from it. */
@@ -291,6 +292,9 @@ function AutomationLaneHeaderRow({
/** The carve owns this envelope and rewrites it on every re-run, so it is
* shown but not the author's to edit or delete. */
isCarve?: boolean;
/** Reveal this parameter in the rack. Absent, the name is inert rather than a
* button that looks live and does nothing. */
onReveal?: () => void;
}) {
return (
<div
@@ -317,7 +321,25 @@ function AutomationLaneHeaderRow({
one line a band's own name was the first thing truncated in a column this
narrow "Peaking EQ 1.6 k…" losing exactly the part that tells two
bands apart. */}
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
{/* A button, because the name IS the way to the knob: clicking it opens
the rack on the effect this envelope drives and scrolls to it. Nothing
else in the timeline can get there a lane names a parameter, and the
rack is where a parameter is set. */}
<button
type="button"
tabIndex={-1}
aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
title={onReveal ? `Show ${label} in the effect rack` : label}
disabled={!onReveal}
className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
// The label column owns its click; it does not also fall through to
// the track row behind it and move the selection.
event.stopPropagation();
onReveal?.();
}}
>
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
{name}
</span>
@@ -338,7 +360,7 @@ function AutomationLaneHeaderRow({
{alsoAutomatedBy} is also fading this.
</span>
) : null}
</span>
</button>
{/* Beside the name it labels, because that is the only place an envelope is
named at all: a carve writes its own lanes, and the FX panel's automate
toggle can only reach a parameter it still lists so without this an
@@ -440,6 +462,15 @@ export function TimelineTrackHeader({
: [],
).map((lane) => lane.key),
);
const revealAudioFx = usePlayerStore((s) => s.setRevealedAudioFxTarget);
/**
* Which element's rack a lane's reveal opens, in the PANEL's id space.
*
* The bare dom id, not the timeline's `sourceFile#domId` composite: the
* property panel identifies its element by `element.id`, so a composite would
* never match the same boundary `runtimeAudioId` exists for.
*/
const revealElementId = keyframeClip ? runtimeAudioId(keyframeClip) : null;
const automationRows = groupAutomationLanes(trackElements).map((group) => {
const active = group.entries.find(
(entry) => (entry.element.key ?? entry.element.id) === activeKey,
@@ -685,24 +716,48 @@ export function TimelineTrackHeader({
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
two have to agree or a name labels the wrong curve. */}
{isExpanded &&
automationRows.map((row, index) => (
<AutomationLaneHeaderRow
key={row.key}
target={row.target}
label={row.label}
name={row.name}
param={row.param}
alsoAutomatedBy={
groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined
}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onRemove={onRemoveAutomationLane}
isCarve={row.isCarve}
/>
))}
automationRows.map((row, index) => {
// Bound outside the closure so it narrows: `row.target` is null on a
// row the active clip is absent from.
const revealTarget = row.target;
return (
<AutomationLaneHeaderRow
key={row.key}
target={row.target}
label={row.label}
name={row.name}
param={row.param}
alsoAutomatedBy={
groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined
}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onRemove={onRemoveAutomationLane}
isCarve={row.isCarve}
// Only for a lane the ACTIVE clip actually draws: the rack shows one
// element, so a shared row's other envelopes belong to clips it is
// not showing and there would be nothing to reveal.
onReveal={
revealTarget && revealElementId && keyframeClip
? () => {
// Select FIRST: the rack is the property panel's view of
// the selected element, so a reveal aimed at an unselected
// clip lands on a panel that says "Nothing selected". The
// request survives the selection — it is stored, not an
// event — so the rack consumes it as it mounts.
openClipFxRack(keyframeClip);
revealAudioFx({
elementKey: revealElementId,
automationTarget: revealTarget,
});
}
: undefined
}
/>
);
})}
</div>
);
}
@@ -34,6 +34,38 @@ export interface FocusedEaseSegment {
type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
/**
* A request to reveal one automated parameter in the audio FX rack.
*
* `elementKey` is the timeline element whose rack it belongs to a group's own
* id for a group lane, the clip's key otherwise so the panel can refuse a
* request aimed at something it is not showing.
*/
export interface RevealedAudioFxTarget {
elementKey: string;
/** The lane's own `fx.<node>.<param>` / `volume` target. */
automationTarget: string;
projectId: string | null;
sessionEpoch: number;
nonce: number;
}
export type RevealedAudioFxTargetRequest = Omit<
RevealedAudioFxTarget,
"projectId" | "sessionEpoch" | "nonce"
>;
/** Whether a reveal request still belongs to what is on screen. */
export function isRevealedAudioFxRequestCurrent(
request: RevealedAudioFxTarget,
state: TimelineSessionIdentity,
): boolean {
return (
request.projectId === state.timelineProjectId &&
request.sessionEpoch === state.timelineSessionEpoch
);
}
interface TimelineSessionIdentity {
timelineProjectId: string | null;
timelineSessionEpoch: number;
@@ -88,6 +120,19 @@ export interface KeyframeSlice {
setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
clearFocusedEaseSegment: (nonce: number) => void;
/**
* "Show me this automated parameter in the rack" raised by clicking an
* automation lane's label in the timeline, consumed by the property panel.
*
* Session-stamped and nonce-guarded exactly like `focusedEaseSegment`: a
* request outlives the click, so one made against a different project or
* before a reload must not reopen a rack on whatever is mounted later.
*/
revealedAudioFxTarget: RevealedAudioFxTarget | null;
revealedAudioFxNonce: number;
setRevealedAudioFxTarget: (target: RevealedAudioFxTargetRequest) => void;
clearRevealedAudioFxTarget: (nonce: number) => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */
keyframeCache: Map<string, KeyframeCacheEntry>;
/** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
@@ -174,6 +219,27 @@ export function createKeyframeSlice(
state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
),
revealedAudioFxTarget: null,
revealedAudioFxNonce: 0,
setRevealedAudioFxTarget: (target) =>
set((state) => {
const nonce = state.revealedAudioFxNonce + 1;
const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
return {
revealedAudioFxNonce: nonce,
revealedAudioFxTarget: {
...target,
projectId: timelineProjectId,
sessionEpoch: timelineSessionEpoch,
nonce,
},
};
}),
clearRevealedAudioFxTarget: (nonce) =>
set((state) =>
state.revealedAudioFxTarget?.nonce === nonce ? { revealedAudioFxTarget: null } : state,
),
keyframeCache: new Map(),
setKeyframeCache: (elementId, data) =>
set((state) => {
@@ -279,6 +279,7 @@ export function createTimelineResetState() {
collapsedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null,
revealedAudioFxTarget: null,
selectedElementIds: new Set<string>(),
requestedSeekTime: null,
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
@@ -547,6 +548,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
activeKeyframePct: null,
motionPathArmed: false,
focusedEaseSegment: null,
revealedAudioFxTarget: null,
}
: { selectedElementId: id, selectedElementIds };
}),