diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 0d9289ff6..9af2af372 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -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(null); + if (revealNonceForThisPanel !== null && revealNonceForThisPanel !== consumedRevealNonce) { + setConsumedRevealNonce(revealNonceForThisPanel); + setOpenGroupId("audio-fx"); + } + const [justToggledIds, setJustToggledIds] = useState([]); const justToggledTimeoutRef = useRef | null>(null); const panelBodyRef = useRef(null); diff --git a/packages/studio/src/components/editor/audioFxRevealTarget.test.ts b/packages/studio/src/components/editor/audioFxRevealTarget.test.ts new file mode 100644 index 000000000..943ce69fa --- /dev/null +++ b/packages/studio/src/components/editor/audioFxRevealTarget.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/components/editor/audioFxRevealTarget.ts b/packages/studio/src/components/editor/audioFxRevealTarget.ts new file mode 100644 index 000000000..c6df5d11d --- /dev/null +++ b/packages/studio/src/components/editor/audioFxRevealTarget.ts @@ -0,0 +1,57 @@ +/** + * Where in the rack an automation lane's parameter actually lives. + * + * A lane names `fx..`, 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 }; +} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx index 11873d6c3..c36e2c2ed 100644 --- a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -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 => { const values = new Map(); @@ -277,6 +282,20 @@ export function AudioFxGroup({ return ( (); // 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(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(null); + const rootRef = useRef(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(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 (
.` / `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; diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 9e0fbfb7c..2aa0a0794 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -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 (
+ {/* 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. */} + {/* 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) => ( - - ))} + 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 ( + { + // 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 + } + /> + ); + })}
); } diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index d18bd31b4..049f41561 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -34,6 +34,38 @@ export interface FocusedEaseSegment { type FocusedEaseSegmentTarget = Omit; +/** + * 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..` / `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; /** 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) => { diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 3cae18b6d..6dd727ad9 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -279,6 +279,7 @@ export function createTimelineResetState() { collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), focusedEaseSegment: null, + revealedAudioFxTarget: null, selectedElementIds: new Set(), requestedSeekTime: null, lintFindingsByElement: new Map(), @@ -547,6 +548,7 @@ export const usePlayerStore = create((set, get) => ({ activeKeyframePct: null, motionPathArmed: false, focusedEaseSegment: null, + revealedAudioFxTarget: null, } : { selectedElementId: id, selectedElementIds }; }),