mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
co-authored by
Claude Opus 5
parent
46ca2f3e56
commit
f8b6f35171
@@ -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 };
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user