+
+
+ );
+}
+
+export function FxParamRow({
+ param,
+ value,
+ onChange,
+ onCommit,
+ disabled,
+ automated,
+ onAutomate,
+ onRemoveAutomation,
+}: ParamRowProps) {
// While dragging, the slider is driven locally. Waiting for the value to come
// back through the element attribute makes the control feel laggy and fights
// the pointer.
@@ -105,9 +163,19 @@ export function FxParamRow({ param, value, onChange, onCommit, disabled }: Param
const numeric = typeof shown === "number" ? shown : Number(shown);
const current = Number.isFinite(numeric) ? numeric : param.default;
+ const locked = Boolean(disabled) || Boolean(automated);
+
return (
-
);
}
@@ -157,10 +232,24 @@ interface FxParamsProps {
onChange(params: HfAudioFxParamValues): void;
onCommit?(params: HfAudioFxParamValues): void;
disabled?: boolean;
+ /** Parameter keys this effect currently has a lane for. */
+ automatedKeys?: ReadonlySet;
+ /** Absent when the effect cannot be automated at all, or nothing can write. */
+ onAutomate?(key: string): void;
+ onRemoveAutomation?(key: string): void;
}
/** Every knob the effect declares, in registry order. */
-export function FxParams({ def, params, onChange, onCommit, disabled }: FxParamsProps) {
+export function FxParams({
+ def,
+ params,
+ onChange,
+ onCommit,
+ disabled,
+ automatedKeys,
+ onAutomate,
+ onRemoveAutomation,
+}: FxParamsProps) {
const set = useCallback(
(key: string, value: number | string) => onChange({ ...params, [key]: value }),
[params, onChange],
@@ -171,16 +260,25 @@ export function FxParams({ def, params, onChange, onCommit, disabled }: FxParams
);
return (
- {def.params.map((p) => (
-
- ))}
+ {def.params.map((p) => {
+ // Only a parameter the registry marks automatable has an AudioParam
+ // behind it for an envelope to write to.
+ const canAutomate = p.kind === "number" && p.automatable === true;
+ const automated = automatedKeys?.has(p.key) ?? false;
+ return (
+
+ );
+ })}
);
}
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
index b77cae9201..f278ec82ec 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.test.tsx
@@ -45,6 +45,9 @@ function mount(overrides: Partial[0]> = {}) {
onAnalyseCarve={overrides.onAnalyseCarve ?? noop}
analysing={overrides.analysing}
disabled={overrides.disabled}
+ automatedTargets={overrides.automatedTargets}
+ onAutomateParam={overrides.onAutomateParam}
+ onRemoveParamAutomation={overrides.onRemoveParamAutomation}
/>,
);
return { host, onChainChange, onChainPreview, onCarveChange };
@@ -246,3 +249,132 @@ describe("FxSection carve", () => {
}
});
});
+
+describe("automation in the panel", () => {
+ const automatable = (chain: HfAudioFxChain, over = {}) =>
+ mount({
+ chain,
+ automatedTargets: new Set(),
+ onAutomateParam: vi.fn(),
+ onRemoveParamAutomation: vi.fn(),
+ ...over,
+ });
+
+ const idChain = (type: string, id = "n1"): HfAudioFxChain => ({
+ version: 1,
+ nodes: [{ type, id, enabled: true, params: defaultAudioFxParams(type) }],
+ });
+
+ const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
+ for (const row of Array.from(host.querySelectorAll(".hf-fx-row"))) {
+ if (row.querySelector(".hf-fx-label")?.textContent === label) return row;
+ }
+ return null;
+ };
+
+ it("offers an automate button only for parameters an envelope can drive", () => {
+ // Saturate: `output` is a make-up gain, but the curve's type and threshold
+ // are rebuilt wholesale and cannot be scheduled.
+ const { host } = automatable(idChain("saturate"));
+ expect(rowFor(host, "Output")?.querySelector(".hf-fx-automate")).toBeTruthy();
+ expect(rowFor(host, "Threshold")?.querySelector(".hf-fx-automate")).toBeNull();
+ });
+
+ it("offers nothing for a worklet effect, which exposes no AudioParams", () => {
+ const { host } = automatable(idChain("compressor"));
+ expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0);
+ });
+
+ it("asks to automate a parameter by node id and key", () => {
+ const onAutomateParam = vi.fn();
+ const { host } = automatable(idChain("lowpass", "n7"), { onAutomateParam });
+ const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
+ expect(button.hasAttribute("title")).toBe(false);
+ act(() => button.click());
+ expect(onAutomateParam).toHaveBeenCalledWith("n7", "frequency");
+ });
+
+ it("disables an automated control, since a value typed here would be overwritten", () => {
+ const { host } = automatable(idChain("lowpass"), {
+ automatedTargets: new Set(["fx.n1.frequency"]),
+ });
+ const row = rowFor(host, "Cutoff")!;
+ expect(row.querySelector('input[type="range"]')?.disabled).toBe(true);
+ expect(row.querySelector('input[type="number"]')?.disabled).toBe(true);
+ expect(row.hasAttribute("data-automated")).toBe(true);
+ // A sibling parameter on the same effect stays editable.
+ const q = rowFor(host, "Q")!;
+ expect(q.querySelector('input[type="range"]')?.disabled).toBe(false);
+ });
+
+ it("turns the automated parameter's button into a delete", () => {
+ const onRemoveParamAutomation = vi.fn();
+ const { host } = automatable(idChain("lowpass"), {
+ automatedTargets: new Set(["fx.n1.frequency"]),
+ onRemoveParamAutomation,
+ });
+ const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
+ expect(button.getAttribute("aria-pressed")).toBe("true");
+ expect(button.getAttribute("aria-label")).toMatch(/remove/i);
+ // The wording lives in the Tooltip component, which only renders its bubble
+ // on hover; the button itself carries no native title hover.
+ expect(button.hasAttribute("title")).toBe(false);
+ act(() => button.click());
+ expect(onRemoveParamAutomation).toHaveBeenCalledWith("n1", "frequency");
+ });
+
+ it("shows the wording in a tooltip bubble, not a native browser hover", async () => {
+ vi.useFakeTimers();
+ try {
+ const { host } = automatable(idChain("lowpass"), {
+ automatedTargets: new Set(["fx.n1.frequency"]),
+ });
+ const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement;
+ // Tooltip positions itself from the trigger's box and gives up on a 0x0
+ // one, which is every element in happy-dom.
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue({
+ x: 100,
+ y: 300,
+ left: 100,
+ top: 300,
+ right: 116,
+ bottom: 316,
+ width: 16,
+ height: 16,
+ toJSON: () => ({}),
+ } as DOMRect);
+ // React synthesises pointer-enter from pointerover and delegates focus via
+ // focusin; focus is also how a keyboard user reaches the same tooltip.
+ act(() => {
+ button.dispatchEvent(new Event("focusin", { bubbles: true }));
+ });
+ act(() => {
+ vi.advanceTimersByTime(600);
+ });
+ const bubble = document.querySelector('[role="tooltip"]');
+ expect(bubble?.textContent).toBe("Automated");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("cannot automate a node with no id, which a lane could not address", () => {
+ const { host } = automatable({
+ version: 1,
+ nodes: [{ type: "lowpass", enabled: true, params: defaultAudioFxParams("lowpass") }],
+ });
+ expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0);
+ });
+
+ it("gives a newly added effect an id, so its parameters can be automated", () => {
+ const onChainChange = vi.fn();
+ const { host } = mount({ chain: { version: 1, nodes: [] }, onChainChange });
+ const add = host.querySelector(".hf-fx-add") as HTMLButtonElement;
+ act(() => add.click());
+ const item = Array.from(host.querySelectorAll(".hf-fx-add-item")).find(
+ (b) => b.textContent === "Low-pass",
+ )!;
+ act(() => item.click());
+ expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1");
+ });
+});
diff --git a/packages/studio/src/components/editor/propertyPanelFxSection.tsx b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
index 16e60ea5af..d7c97946cb 100644
--- a/packages/studio/src/components/editor/propertyPanelFxSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFxSection.tsx
@@ -13,12 +13,15 @@ import {
defaultAudioFxParams,
getAudioFxDef,
HF_AUDIO_FX,
+ mintAudioFxNodeId,
type HfAudioFxChain,
+ type HfAudioFxDef,
type HfAudioFxGroup,
type HfAudioFxNode,
type HfAudioFxParamValues,
} from "@hyperframes/core/audio-fx";
import { DEFAULT_CARVE, type HfCarveSettings } from "@hyperframes/core/audio-carve";
+import { fxAutomationTarget } from "@hyperframes/core/audio-automation";
import { FxParams, FxParamRow } from "./propertyPanelFxControls.js";
const GROUP_ORDER: HfAudioFxGroup[] = ["filter", "dynamics", "nonlinear", "time"];
@@ -37,6 +40,9 @@ export interface AudioTrackOption {
interface FxNodeRowProps {
node: HfAudioFxNode;
index: number;
+ automatedTargets?: ReadonlySet;
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
open: boolean;
/** Last in the chain, so it cannot move further down. */
last: boolean;
@@ -142,10 +148,73 @@ function FxNodeHeader({
);
}
+/**
+ * Which of an effect's knobs already have a lane.
+ *
+ * A lane addresses a node by id, so a node the panel has not yet given one
+ * cannot be automated at all. Adding an effect mints the id, so this only
+ * affects chains written before ids existed.
+ */
+function automatedKeysOf(
+ node: HfAudioFxNode,
+ params: readonly { key: string }[],
+ automatedTargets: ReadonlySet | undefined,
+): Set {
+ if (!node.id || !automatedTargets) return new Set();
+ const nodeId = node.id;
+ return new Set(
+ params.filter((p) => automatedTargets.has(fxAutomationTarget(nodeId, p.key))).map((p) => p.key),
+ );
+}
+
+/** An open effect's knobs, with whatever automation surface applies to them. */
+function FxNodeParams({
+ node,
+ def,
+ index,
+ disabled,
+ automatedTargets,
+ onUpdate,
+ onPreview,
+ onAutomateParam,
+ onRemoveParamAutomation,
+}: {
+ node: HfAudioFxNode;
+ def: HfAudioFxDef;
+ index: number;
+ disabled: boolean;
+ automatedTargets?: ReadonlySet;
+ onUpdate(index: number, patch: Partial): void;
+ onPreview(index: number, params: HfAudioFxParamValues): void;
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
+}) {
+ const nodeId = node.id;
+ return (
+ onPreview(index, params)}
+ onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })}
+ automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
+ onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined}
+ onRemoveAutomation={
+ nodeId && onRemoveParamAutomation
+ ? (key) => onRemoveParamAutomation(nodeId, key)
+ : undefined
+ }
+ />
+ );
+}
+
/** One effect in the chain: its header controls, and its knobs when open. */
function FxNodeRow({
node,
index,
+ automatedTargets,
+ onAutomateParam,
+ onRemoveParamAutomation,
open,
last,
disabled,
@@ -176,12 +245,16 @@ function FxNodeRow({
onRemove={() => onRemove(index)}
/>
{open ? (
- onPreview(index, params)}
- onCommit={(params: HfAudioFxParamValues) => onUpdate(index, { params })}
+ index={index}
+ disabled={Boolean(disabled) || bypassed}
+ automatedTargets={automatedTargets}
+ onUpdate={onUpdate}
+ onPreview={onPreview}
+ onAutomateParam={onAutomateParam}
+ onRemoveParamAutomation={onRemoveParamAutomation}
/>
) : null}
@@ -190,6 +263,12 @@ function FxNodeRow({
export interface FxSectionProps {
chain: HfAudioFxChain;
+ /** Targets this track already automates, as `fx..` strings. */
+ automatedTargets?: ReadonlySet;
+ /** Add a lane for one effect parameter, seeded at its current value. */
+ onAutomateParam?(nodeId: string, paramKey: string): void;
+ /** Delete one effect parameter's lane. */
+ onRemoveParamAutomation?(nodeId: string, paramKey: string): void;
/** Structural edits and gesture-end writes; this is the one that persists. */
onChainChange(chain: HfAudioFxChain): void;
/** Continuous updates while a control is being dragged. */
@@ -206,6 +285,9 @@ export interface FxSectionProps {
export function FxSection({
chain,
+ automatedTargets,
+ onAutomateParam,
+ onRemoveParamAutomation,
onChainChange,
onChainPreview,
carve,
@@ -240,11 +322,14 @@ export function FxSection({
const addEffect = useCallback(
(type: string) => {
- mutate([...chain.nodes, { type, enabled: true, params: defaultAudioFxParams(type) }]);
+ mutate([
+ ...chain.nodes,
+ { type, id: mintAudioFxNodeId(chain), enabled: true, params: defaultAudioFxParams(type) },
+ ]);
setOpenNode(chain.nodes.length);
setAdding(false);
},
- [chain.nodes, mutate],
+ [chain, mutate],
);
const updateNode = useCallback(
@@ -287,6 +372,9 @@ export function FxSection({
key={`${node.type}-${i}`}
node={node}
index={i}
+ automatedTargets={automatedTargets}
+ onAutomateParam={onAutomateParam}
+ onRemoveParamAutomation={onRemoveParamAutomation}
open={openNode === i}
last={i === chain.nodes.length - 1}
disabled={disabled}
diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts
index 935ceb3f67..2756d0d00a 100644
--- a/packages/studio/src/components/editor/propertyPanelTypes.ts
+++ b/packages/studio/src/components/editor/propertyPanelTypes.ts
@@ -58,6 +58,11 @@ export interface PropertyPanelProps {
value: string | null,
onSettled?: (ok: boolean) => void,
) => void | Promise;
+ /** Persists without reloading the preview, but re-reads the selection after —
+ * for attributes the runtime applies to the live graph itself, where a reload
+ * would only interrupt playback, and where the panel still has to see the
+ * value it just wrote to compute the next edit from. */
+ onSetAttributeQuiet?: (attr: string, value: string | null) => void | Promise;
onApplyColorGradingScope?: (
scope: "source-file" | "project",
value: string | null,
diff --git a/packages/studio/src/components/editor/useVolumeAutomation.test.tsx b/packages/studio/src/components/editor/useVolumeAutomation.test.tsx
new file mode 100644
index 0000000000..580bdbc25f
--- /dev/null
+++ b/packages/studio/src/components/editor/useVolumeAutomation.test.tsx
@@ -0,0 +1,107 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { createRoot } from "react-dom/client";
+import { useVolumeAutomation, type VolumeAutomationBinding } from "./useVolumeAutomation";
+import type { DomEditSelection } from "./domEditingTypes";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+function bind(dataAttributes: Record) {
+ const onSetAttribute = vi.fn();
+ const captured: { current: VolumeAutomationBinding | null } = { current: null };
+ function Probe() {
+ captured.current = useVolumeAutomation(
+ { dataAttributes } as unknown as DomEditSelection,
+ onSetAttribute,
+ );
+ return null;
+ }
+ const host = document.createElement("div");
+ document.body.append(host);
+ act(() => {
+ createRoot(host).render();
+ });
+ if (!captured.current) throw new Error("hook never ran");
+ return { binding: captured.current, onSetAttribute };
+}
+
+const volumeLane = (v: number) =>
+ JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
+
+describe("useVolumeAutomation", () => {
+ it("reports an unautomated track", () => {
+ expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
+ });
+
+ it("reports a track with a volume lane", () => {
+ expect(bind({ volume: "0.55", automation: volumeLane(0.2) }).binding.volumeAutomated).toBe(
+ true,
+ );
+ });
+
+ it("does not count an FX lane as automating the volume", () => {
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
+ });
+ expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
+ });
+
+ it("seeds a new lane at the level the slider already shows", () => {
+ // Automating a track must not change how loud it is.
+ const { binding, onSetAttribute } = bind({ volume: "0.55" });
+ act(() => binding.onAutomateVolume());
+ expect(onSetAttribute).toHaveBeenCalledWith(
+ "data-automation",
+ JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
+ );
+ });
+
+ it("treats a missing data-volume as unity", () => {
+ const { binding, onSetAttribute } = bind({});
+ act(() => binding.onAutomateVolume());
+ expect(JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
+ });
+
+ it("keeps FX lanes when adding the volume one", () => {
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
+ });
+ const { binding, onSetAttribute } = bind({ volume: "0.4", automation });
+ act(() => binding.onAutomateVolume());
+ expect(
+ JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes.map(
+ (l: { target: string }) => l.target,
+ ),
+ ).toEqual(["fx.n1.frequency", "volume"]);
+ });
+
+ it("deletes only the volume lane", () => {
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [
+ { target: "volume", points: [{ t: 0, v: 0.2 }] },
+ { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
+ ],
+ });
+ const { binding, onSetAttribute } = bind({ volume: "0.4", automation });
+ act(() => binding.onRemoveVolumeAutomation());
+ expect(
+ JSON.parse(String(onSetAttribute.mock.calls[0][1])).lanes.map(
+ (l: { target: string }) => l.target,
+ ),
+ ).toEqual(["fx.n1.frequency"]);
+ });
+
+ it("clears the attribute when the volume lane was the only one", () => {
+ const { binding, onSetAttribute } = bind({ volume: "0.4", automation: volumeLane(0.2) });
+ act(() => binding.onRemoveVolumeAutomation());
+ expect(onSetAttribute).toHaveBeenCalledWith("data-automation", "");
+ });
+
+ it("reads an unreadable attribute as no automation", () => {
+ expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
+ });
+});
diff --git a/packages/studio/src/components/editor/useVolumeAutomation.ts b/packages/studio/src/components/editor/useVolumeAutomation.ts
new file mode 100644
index 0000000000..477d98a0b9
--- /dev/null
+++ b/packages/studio/src/components/editor/useVolumeAutomation.ts
@@ -0,0 +1,44 @@
+/**
+ * The volume lane's state and edits for the media section.
+ *
+ * Volume lives in a different panel section from the FX chain, but is automated
+ * the same way, so it reads and writes through the same helper the FX group uses
+ * rather than a second interpretation of the attribute.
+ */
+
+import { VOLUME_TARGET } from "@hyperframes/core/audio-automation";
+import type { DomEditSelection } from "./domEditingTypes";
+import {
+ automationAttrValue,
+ HF_AUDIO_AUTOMATION_ATTR,
+ readPanelAutomation,
+ withoutLane,
+ withSeededLane,
+} from "./propertyPanelAutomation";
+
+export interface VolumeAutomationBinding {
+ volumeAutomated: boolean;
+ onAutomateVolume: () => void;
+ onRemoveVolumeAutomation: () => void;
+}
+
+export function useVolumeAutomation(
+ element: DomEditSelection,
+ onSetAttribute: (attr: string, value: string) => void | Promise,
+): VolumeAutomationBinding {
+ // The chain is not needed to resolve a volume lane — volume is always a valid
+ // target — so this deliberately does not parse it.
+ const automation = readPanelAutomation(element.dataAttributes?.["automation"], undefined);
+ const write = (next: Parameters[0]): void => {
+ void onSetAttribute(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next));
+ };
+ const current = Number(element.dataAttributes?.["volume"] ?? "1");
+ return {
+ volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
+ // Seeded at the level the slider already shows, so automating the track does
+ // not change how loud it is.
+ onAutomateVolume: () =>
+ write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
+ onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
+ };
+}
diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx
index f89e27e915..b12134fd84 100644
--- a/packages/studio/src/contexts/DomEditContext.tsx
+++ b/packages/studio/src/contexts/DomEditContext.tsx
@@ -15,6 +15,7 @@ export interface DomEditActionsValue extends Pick<
| "handleDomStyleCommit"
| "handleDomAttributeCommit"
| "handleDomAttributeLiveCommit"
+ | "handleDomAttributeQuietCommit"
| "handleDomHtmlAttributeCommit"
| "handleDomAttributesCommit"
| "handleDomPathOffsetCommit"
@@ -139,6 +140,7 @@ export function DomEditProvider({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
@@ -227,6 +229,7 @@ export function DomEditProvider({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
@@ -296,6 +299,7 @@ export function DomEditProvider({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit,
diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
index 0c3e7a39c7..d4e76c2cb8 100644
--- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts
+++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts
@@ -284,6 +284,27 @@ export function useDomEditAttributeCommits({
[commitDataAttribute],
);
+ /**
+ * Persist without reloading the preview, but re-read the selection afterwards.
+ *
+ * For attributes the runtime applies to the live graph itself — an audio FX
+ * chain, its automation — a reload would only interrupt playback to reach the
+ * state the preview already has. The resync is still needed: without it the
+ * panel keeps reading the selection snapshot it was built with, so a second
+ * edit computes from a pre-edit value and appears to do nothing.
+ */
+ const handleDomAttributeQuietCommit = useCallback(
+ async (attr: string, value: string | null) => {
+ await commitDataAttribute(attr, value, {
+ label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`,
+ coalescePrefix: "attr-quiet",
+ skipRefresh: true,
+ refreshAfter: true,
+ });
+ },
+ [commitDataAttribute],
+ );
+
const handleDomHtmlAttributeCommit = useCallback(
async (attr: string, value: string | null) => {
if (!domEditSelection) return;
@@ -343,6 +364,7 @@ export function useDomEditAttributeCommits({
return {
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
};
diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts
index 7a02be735d..12192452f4 100644
--- a/packages/studio/src/hooks/useDomEditCommits.ts
+++ b/packages/studio/src/hooks/useDomEditCommits.ts
@@ -374,6 +374,7 @@ export function useDomEditCommits({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
@@ -437,6 +438,7 @@ export function useDomEditCommits({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index efec011f95..02ecbe4f78 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -222,6 +222,7 @@ export function useDomEditSession({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
@@ -484,6 +485,7 @@ export function useDomEditSession({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
diff --git a/packages/studio/src/hooks/useDomEditTextCommits.ts b/packages/studio/src/hooks/useDomEditTextCommits.ts
index 7111356279..dbfe9551db 100644
--- a/packages/studio/src/hooks/useDomEditTextCommits.ts
+++ b/packages/studio/src/hooks/useDomEditTextCommits.ts
@@ -148,6 +148,7 @@ export function useDomEditTextCommits({
const {
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
} = useDomEditAttributeCommits({
@@ -474,6 +475,7 @@ export function useDomEditTextCommits({
handleDomStyleCommit,
handleDomAttributeCommit,
handleDomAttributeLiveCommit,
+ handleDomAttributeQuietCommit,
handleDomHtmlAttributeCommit,
handleDomAttributesCommit,
handleDomTextCommit,
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
new file mode 100644
index 0000000000..ae1b9884bf
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
@@ -0,0 +1,471 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { createRoot } from "react-dom/client";
+import { TimelineAutomationLane, automationTargets } from "./TimelineAutomationLane";
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import {
+ resolveAutomationRange,
+ VOLUME_RANGE,
+ type HfAutomation,
+} from "@hyperframes/core/audio-automation";
+
+const chain: HfAudioFxChain = {
+ version: 1,
+ nodes: [
+ { type: "lowpass", id: "n1", enabled: true, params: {} },
+ // No id: the panel has not touched it, so nothing can address it.
+ { type: "peaking", enabled: true, params: {} },
+ // Worklet-backed: no AudioParams to schedule.
+ { type: "compressor", id: "n3", enabled: true, params: {} },
+ ],
+};
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+function renderRerenderable(node: React.ReactElement): {
+ container: HTMLElement;
+ rerender(next: React.ReactElement): void;
+} {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(node);
+ });
+ return {
+ container: host,
+ rerender: (next) => {
+ act(() => {
+ root.render(next);
+ });
+ },
+ };
+}
+
+function render(node: React.ReactElement): { container: HTMLElement } {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(node);
+ });
+ return { container: host };
+}
+
+/**
+ * Mount inside a wrapper so propagation can be observed from a real ancestor.
+ * A listener on React's own root node is no test of it: two native listeners on
+ * one element both run regardless of stopPropagation.
+ */
+function renderNested(node: React.ReactElement): {
+ container: HTMLElement;
+ ancestor: HTMLElement;
+} {
+ const ancestor = document.createElement("div");
+ const host = document.createElement("div");
+ ancestor.append(host);
+ document.body.append(ancestor);
+ const root = createRoot(host);
+ act(() => {
+ root.render(node);
+ });
+ return { container: host, ancestor };
+}
+
+/** happy-dom has no pointer-event constructors wired to React's synthetic ones,
+ * so events are dispatched as plain typed events with the coordinates React
+ * reads off them. */
+function fire(
+ el: Element,
+ type: string,
+ init: { clientX?: number; clientY?: number; button?: number } = {},
+): void {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.assign(event, { clientX: 0, clientY: 0, button: 0, pointerId: 1, ...init });
+ act(() => {
+ el.dispatchEvent(event);
+ });
+}
+
+/** Slack the lane insets its drawing by, so an end point is not half clipped. */
+const PAD = 9;
+
+const EMPTY: HfAutomation = { version: 1, lanes: [] };
+
+const ramp: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 4, v: 0 },
+ ],
+ },
+ ],
+};
+
+function laneProps(over: Partial[0]> = {}) {
+ const target = over.target ?? "volume";
+ return {
+ duration: 4,
+ widthPx: 400,
+ leftPx: 100,
+ topPx: 28,
+ automation: EMPTY,
+ accentColor: "#0af",
+ playheadSec: null,
+ onPreview: vi.fn(),
+ onCommit: vi.fn(),
+ ...over,
+ target,
+ range: over.range ?? resolveAutomationRange(target, chain) ?? VOLUME_RANGE,
+ };
+}
+
+/** happy-dom gives every element a zero-size box; the lane maps pointers
+ * through it, so tests that click need a real one. */
+function stubBox(el: Element, box: { left: number; top: number; width: number; height: number }) {
+ vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
+ ...box,
+ right: box.left + box.width,
+ bottom: box.top + box.height,
+ x: box.left,
+ y: box.top,
+ toJSON: () => ({}),
+ } as DOMRect);
+}
+
+describe("automationTargets", () => {
+ it("offers volume plus every addressable automatable knob", () => {
+ const targets = automationTargets(chain).map((t) => t.target);
+ expect(targets[0]).toBe("volume");
+ expect(targets).toContain("fx.n1.frequency");
+ expect(targets).toContain("fx.n1.q");
+ });
+
+ it("skips a node with no id — a lane could not address it stably", () => {
+ expect(automationTargets(chain).some((t) => t.target.includes("peaking"))).toBe(false);
+ });
+
+ it("skips a worklet effect, which exposes no AudioParams", () => {
+ expect(automationTargets(chain).some((t) => t.target.startsWith("fx.n3."))).toBe(false);
+ });
+
+ it("offers just the fader for a track with no chain", () => {
+ expect(automationTargets(null).map((t) => t.target)).toEqual(["volume"]);
+ });
+
+ it("labels an fx target with its effect and knob", () => {
+ const found = automationTargets(chain).find((t) => t.target === "fx.n1.frequency");
+ expect(found?.label).toMatch(/Cutoff/);
+ expect(found?.range.scale).toBe("log");
+ });
+});
+
+describe("TimelineAutomationLane", () => {
+ it("draws a point per breakpoint", () => {
+ const { container } = render();
+ expect(container.querySelectorAll("circle").length).toBe(2);
+ });
+
+ it("draws a dimmed flat line when the lane is empty", () => {
+ const { container } = render();
+ expect(container.querySelectorAll("circle").length).toBe(0);
+ const path = container.querySelector("path");
+ expect(Number(path?.getAttribute("opacity"))).toBeLessThan(0.5);
+ });
+
+ it("keeps an end point clear of the lane's edges", () => {
+ // A point at t=0 drawn at x=0 is half outside the svg and unclickable; the
+ // lane insets its drawing so both ends are whole.
+ const ends: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "volume",
+ points: [
+ { t: 0, v: 1 },
+ { t: 4, v: 0 },
+ ],
+ },
+ ],
+ };
+ const { container } = render();
+ const svg = container.querySelector("svg")!;
+ const points = Array.from(container.querySelectorAll("[data-automation-point]"));
+ const radius = Number(points[0]!.getAttribute("r"));
+ const first = Number(points[0]!.getAttribute("cx"));
+ const last = Number(points[1]!.getAttribute("cx"));
+ const svgWidth = Number(svg.getAttribute("width"));
+ expect(first).toBeGreaterThanOrEqual(radius);
+ expect(last).toBeLessThanOrEqual(svgWidth - radius);
+ // Wider than the clip by the padding on both sides, so clip time still
+ // lines up with screen position.
+ expect(svgWidth).toBe(400 + PAD * 2);
+ });
+
+ it("shows the parameter name in full, never clamped to a narrow gutter", () => {
+ // A clip starting at zero leaves no gutter; the label used to be clamped to
+ // 60px there and read "Low-pass ...".
+ const { container } = render(
+ ,
+ );
+ const name = container.querySelector(".hf-automation-name")!;
+ expect(name.textContent).toBe("Low-pass · Cutoff");
+ expect(name.className).not.toMatch(/truncate/);
+ expect(name.style.maxWidth).toBe("");
+ });
+
+ it("names the parameter it draws, rather than offering a control to swap it", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("select")).toBeNull();
+ expect(container.querySelector(".hf-automation-name")?.textContent).toMatch(/Cutoff/);
+ });
+
+ it("adds a point on double-click, at the value the pointer was at", () => {
+ const onCommit = vi.fn();
+ const { container } = render();
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ // Half way across, and at the very top of the lane => t=2, v=1.
+ fire(svg, "dblclick", { clientX: PAD + 200, clientY: 6 });
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ const lane = onCommit.mock.calls[0][0].lanes[0];
+ expect(lane.target).toBe("volume");
+ // Seeded at 0 so the envelope has somewhere to come from.
+ expect(lane.points.length).toBe(2);
+ expect(lane.points[1].t).toBeCloseTo(2, 5);
+ expect(lane.points[1].v).toBeCloseTo(1, 2);
+ });
+
+ it("previews while dragging and persists once on release", () => {
+ const onPreview = vi.fn();
+ const onCommit = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ // Grab the first point, at x=0 / top of the lane.
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
+ fire(svg, "pointermove", { clientX: 100, clientY: 42 });
+ fire(svg, "pointermove", { clientX: 120, clientY: 40 });
+ expect(onPreview).toHaveBeenCalledTimes(2);
+ expect(onCommit).not.toHaveBeenCalled();
+ fire(svg, "pointerup", { clientX: 120, clientY: 40 });
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ });
+
+ it("moves the dragged point on screen without waiting for the prop", () => {
+ // The live write skips the preview refresh on purpose, so `automation` does
+ // not change under the pointer. Before the draft state existed the circle
+ // stayed put and only the audio moved.
+ const { container } = render();
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ const cyBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cy"));
+ const cxBefore = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
+
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
+
+ const dragged = container.querySelectorAll("circle")[0]!;
+ expect(Number(dragged.getAttribute("cy"))).toBeGreaterThan(cyBefore + 10);
+ expect(Number(dragged.getAttribute("cx"))).toBeGreaterThan(cxBefore + 100);
+ });
+
+ it("keeps the dragged position after release, rather than snapping back", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
+ const during = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"));
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
+ expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo(
+ during,
+ 5,
+ );
+ });
+
+ it("follows the prop again once the store catches up", () => {
+ const { container, rerender } = renderRerenderable(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
+ fire(svg, "pointermove", { clientX: 160, clientY: 40 });
+ fire(svg, "pointerup", { clientX: 160, clientY: 40 });
+ // The persisted edit lands and the store hands back a different envelope;
+ // the lane must defer to it instead of holding the stale draft forever.
+ const persisted: HfAutomation = {
+ version: 1,
+ lanes: [{ target: "volume", points: [{ t: 3, v: 0.25 }] }],
+ };
+ rerender();
+ const circles = container.querySelectorAll("circle");
+ expect(circles.length).toBe(1);
+ expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0);
+ });
+
+ it("keeps lane order when editing, so the view does not switch parameters", () => {
+ // The displayed lane defaults to the first one. Moving the edited lane to
+ // the end of the list swapped the lane out from under the pointer on the
+ // first edit — a 4-point filter sweep became a 2-point one mid-gesture.
+ const onCommit = vi.fn();
+ const two: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.frequency",
+ points: [
+ { t: 0, v: 400 },
+ { t: 4, v: 8000 },
+ ],
+ },
+ { target: "volume", points: [{ t: 0, v: 1 }] },
+ ],
+ };
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "dblclick", { clientX: 200, clientY: 20 });
+ const next: HfAutomation = onCommit.mock.calls[0][0];
+ expect(next.lanes.map((l) => l.target)).toEqual(["fx.n1.frequency", "volume"]);
+ expect(next.lanes[0]!.points.length).toBe(3);
+ });
+
+ it("appends a lane that did not exist yet", () => {
+ const onCommit = vi.fn();
+ const only: HfAutomation = {
+ version: 1,
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
+ };
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "dblclick", { clientX: 200, clientY: 20 });
+ expect(onCommit.mock.calls[0][0].lanes.map((l: { target: string }) => l.target)).toEqual([
+ "volume",
+ "fx.n1.frequency",
+ ]);
+ });
+
+ it("removes a point on right-click", () => {
+ const onCommit = vi.fn();
+ const { container } = render(
+ ,
+ );
+ fire(container.querySelectorAll("circle")[0]!, "contextmenu");
+ expect(onCommit.mock.calls[0][0].lanes[0].points.length).toBe(1);
+ });
+
+ it("drops the lane entirely once its last point is removed", () => {
+ const onCommit = vi.fn();
+ const single: HfAutomation = {
+ version: 1,
+ lanes: [{ target: "volume", points: [{ t: 1, v: 0.5 }] }],
+ };
+ const { container } = render(
+ ,
+ );
+ fire(container.querySelector("circle")!, "contextmenu");
+ expect(onCommit.mock.calls[0][0].lanes).toEqual([]);
+ });
+
+ it("writes nothing when read-only, and lets the press through to select", () => {
+ const onCommit = vi.fn();
+ const onPreview = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "dblclick", { clientX: 200, clientY: 6 });
+ fire(svg, "pointerdown", { clientX: 0, clientY: 6 });
+ fire(svg, "pointermove", { clientX: 100, clientY: 42 });
+ fire(container.querySelectorAll("circle")[0]!, "contextmenu");
+ expect(onCommit).not.toHaveBeenCalled();
+ expect(onPreview).not.toHaveBeenCalled();
+ });
+
+ it("selects the clip when pressed read-only, the only route to editing it", () => {
+ // The lane sits below the clip bar, so the timeline's own selection handler
+ // never sees this press. Without selecting here the lane could never be
+ // made editable at all.
+ const onSelect = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ fire(svg, "pointerdown", { clientX: 40, clientY: 24 });
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("owns a press it cannot act on too, so the timeline does not scrub under it", () => {
+ const { container, ancestor } = renderNested(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ let reachedAncestor = false;
+ ancestor.addEventListener("pointerdown", () => {
+ reachedAncestor = true;
+ });
+ fire(svg, "pointerdown", { clientX: 40, clientY: 24 });
+ expect(reachedAncestor).toBe(false);
+ });
+
+ it("owns the press once live, so a double-click is not eaten by the timeline", () => {
+ const { container, ancestor } = renderNested(
+ ,
+ );
+ const svg = container.querySelector("svg")!;
+ stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
+ let reachedAncestor = false;
+ ancestor.addEventListener("pointerdown", () => {
+ reachedAncestor = true;
+ });
+ fire(svg, "pointerdown", { clientX: 200, clientY: 24 });
+ expect(reachedAncestor).toBe(false);
+ });
+
+ it("maps a log-read knob so its geometric middle sits mid-lane", () => {
+ const sweep: HfAutomation = {
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.frequency",
+ points: [
+ { t: 0, v: 100 },
+ { t: 4, v: 20000 },
+ ],
+ },
+ ],
+ };
+ const { container } = render(
+ ,
+ );
+ const circles = container.querySelectorAll("circle");
+ // 100 Hz is the range floor and 20 kHz its ceiling, so the two points sit at
+ // the lane's bottom and top.
+ const ys = Array.from(circles).map((c) => Number(c.getAttribute("cy")));
+ expect(Math.max(...ys)).toBeGreaterThan(Math.min(...ys) + 30);
+ });
+});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
new file mode 100644
index 0000000000..c982edca91
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -0,0 +1,544 @@
+/**
+ * Breakpoint automation over an audio clip, edited the way a DAW edits it:
+ * double-click the line to add a point, drag one to shape it, right-click a
+ * point to remove it.
+ *
+ * The lane knows nothing about any particular effect. Which parameters it can
+ * offer, their ranges, units and whether they read logarithmically all come
+ * from the FX registry, so an effect gained upstream needs no change here — the
+ * same principle the property panel's controls follow.
+ */
+
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+} from "react";
+import {
+ fxAutomationTarget,
+ resolveAutomationRange,
+ sampleAutomationLane,
+ VOLUME_RANGE,
+ VOLUME_TARGET,
+ type AutomationRange,
+ type HfAutomation,
+ type HfAutomationLane,
+ type HfAutomationPoint,
+} from "@hyperframes/core/audio-automation";
+import { getAudioFxDef, type HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import { AUTOMATION_LANE_H } from "./automationLaneHeight";
+import { getTimelineLaneTop } from "./timelineLayout";
+import type { TimelineElement } from "../store/playerStore";
+import type { UseAutomationLanesResult } from "./useAutomationLanes";
+
+/** Points nearer than this in clip seconds are the same point, not two. */
+const POINT_MERGE_SEC = 0.02;
+/** Hit radius for grabbing a point, in px. */
+const GRAB_PX = 7;
+/** Samples used to draw a segment the eye should see as curved. */
+const DRAW_SAMPLES = 64;
+/**
+ * Slack on each side of the envelope, so a point sitting exactly at the clip's
+ * start or end is drawn whole instead of half outside the lane. Wide enough for
+ * the grab circle plus its stroke.
+ */
+const PAD_X = GRAB_PX + 2;
+
+export interface AutomationTargetOption {
+ target: string;
+ label: string;
+ range: AutomationRange;
+}
+
+/**
+ * Everything this clip could automate: its fader, then each automatable knob of
+ * each effect in its chain. Effects with no chain node id are skipped — a lane
+ * has nothing stable to address them by (the panel mints ids as it adds nodes).
+ */
+export function automationTargets(chain: HfAudioFxChain | null): AutomationTargetOption[] {
+ const out: AutomationTargetOption[] = [
+ { target: VOLUME_TARGET, label: "Volume", range: VOLUME_RANGE },
+ ];
+ for (const node of chain?.nodes ?? []) {
+ out.push(...nodeTargets(node, chain));
+ }
+ return out;
+}
+
+/** One effect's automatable knobs. Empty for a node no lane could address. */
+function nodeTargets(
+ node: HfAudioFxChain["nodes"][number],
+ chain: HfAudioFxChain | null,
+): AutomationTargetOption[] {
+ const nodeId = node.id;
+ const def = nodeId ? getAudioFxDef(node.type) : undefined;
+ if (!nodeId || !def) return [];
+ const out: AutomationTargetOption[] = [];
+ for (const param of def.params) {
+ if (param.kind !== "number" || !param.automatable) continue;
+ const target = fxAutomationTarget(nodeId, param.key);
+ const range = resolveAutomationRange(target, chain ?? undefined);
+ if (range) out.push({ target, label: range.label, range });
+ }
+ return out;
+}
+
+/** Value → 0..1 up the lane, honouring a log-read knob's own scale. */
+function toUnit(range: AutomationRange, value: number): number {
+ const { min, max } = range;
+ if (max <= min) return 0;
+ if (range.scale === "log" && min > 0 && value > 0) {
+ return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min));
+ }
+ return (value - min) / (max - min);
+}
+
+function fromUnit(range: AutomationRange, unit: number): number {
+ const t = Math.min(1, Math.max(0, unit));
+ const { min, max } = range;
+ if (range.scale === "log" && min > 0) {
+ return Math.exp(Math.log(min) + t * (Math.log(max) - Math.log(min)));
+ }
+ return min + t * (max - min);
+}
+
+function formatValue(range: AutomationRange, value: number): string {
+ const decimals = range.step >= 1 ? 0 : range.step >= 0.1 ? 1 : 2;
+ const shown =
+ range.unit === "" && range.max === 1 ? `${Math.round(value * 100)}%` : value.toFixed(decimals);
+ return range.unit ? `${shown} ${range.unit}` : shown;
+}
+
+function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
+ return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
+}
+
+/**
+ * Replace one lane in place, dropping it when it has no points left.
+ *
+ * Order is preserved deliberately. A lane with no explicitly chosen parameter
+ * shows whichever comes first, so moving the edited one to the end would switch
+ * the lane out from under the pointer on the first edit.
+ */
+function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation {
+ const empty = lane.points.length === 0;
+ const exists = automation.lanes.some((l) => l.target === lane.target);
+ const lanes = automation.lanes
+ .map((l) => (l.target === lane.target ? lane : l))
+ .filter((l) => l.points.length > 0);
+ if (!exists && !empty) lanes.push(lane);
+ return { version: 1, lanes };
+}
+
+export interface TimelineAutomationLaneProps {
+ /** Clip-local duration the lane spans. */
+ duration: number;
+ widthPx: number;
+ leftPx: number;
+ topPx: number;
+ automation: HfAutomation;
+ /** Which lane of that automation this row draws. */
+ target: string;
+ /** Axis, unit and label for the target, resolved against the chain. */
+ range: AutomationRange;
+ accentColor: string;
+ /** Clip-local seconds of the playhead, or null when it is outside the clip. */
+ playheadSec: number | null;
+ /** Continuous write while dragging; does not persist. */
+ onPreview(automation: HfAutomation): void;
+ /** Gesture-end write; this is the one that persists and lands in undo. */
+ onCommit(automation: HfAutomation): void;
+ /** Editing writes to the selected element, so an unselected clip is read-only. */
+ readOnly?: boolean;
+ /** Called when a read-only lane is pressed: selects the clip so it goes live. */
+ onSelect?(): void;
+}
+
+export function TimelineAutomationLane({
+ duration,
+ widthPx,
+ leftPx,
+ topPx,
+ automation,
+ target,
+ range,
+ accentColor,
+ playheadSec,
+ onPreview,
+ onCommit,
+ readOnly,
+ onSelect,
+}: TimelineAutomationLaneProps) {
+ const stored = laneFor(automation, target);
+
+ const svgRef = useRef(null);
+ const [dragIndex, setDragIndex] = useState(null);
+ const [hint, setHint] = useState(null);
+
+ /**
+ * Points as the user is shaping them, before the edit has come back around.
+ *
+ * A live write sets the preview attribute but deliberately skips the refresh —
+ * that is what keeps dragging from reloading the composition and restarting
+ * playback. So the automation prop does not move under the pointer, and
+ * without a local draft the point would not either.
+ */
+ const [draft, setDraft] = useState<{ points: HfAutomationPoint[]; basedOn: HfAutomation } | null>(
+ null,
+ );
+ const lane: HfAutomationLane = useMemo(
+ () => (draft ? { target, points: draft.points } : stored),
+ [draft, target, stored],
+ );
+
+ // The draft is released when the automation it was drawn over actually
+ // changes — the persisted edit landing, or an edit from elsewhere. Releasing
+ // it merely because the drag ended would snap the point back to where it
+ // started for as long as the write takes to come around.
+ useEffect(() => {
+ if (draft && draft.basedOn !== automation) setDraft(null);
+ }, [automation, draft]);
+
+ // A different parameter is a different envelope; the draft does not carry over.
+ useEffect(() => {
+ setDraft(null);
+ }, [target]);
+
+ const h = AUTOMATION_LANE_H;
+ const pad = 6;
+ const inner = h - pad * 2;
+ // Drawing is inset by PAD_X and the svg is widened to match, so screen
+ // position still lines up with clip time — the lane just has margins.
+ const xOf = useCallback(
+ (t: number): number => PAD_X + (duration > 0 ? (t / duration) * widthPx : 0),
+ [duration, widthPx],
+ );
+ const yOf = useCallback(
+ (v: number): number => pad + (1 - toUnit(range, v)) * inner,
+ [range, inner],
+ );
+
+ /** Pointer position as a clip-local time and a parameter value. */
+ const pointAt = useCallback(
+ (clientX: number, clientY: number): { t: number; v: number } => {
+ const box = svgRef.current?.getBoundingClientRect();
+ if (!box || box.width <= 0) return { t: 0, v: range.default ?? range.min };
+ const t = Math.min(
+ duration,
+ Math.max(0, ((clientX - box.left - PAD_X) / widthPx) * duration),
+ );
+ const unit = 1 - (clientY - box.top - pad) / inner;
+ return { t, v: fromUnit(range, unit) };
+ },
+ [duration, inner, range, widthPx],
+ );
+
+ /**
+ * The line the lane draws. A flat line at the current value stands in for a
+ * lane with no points, so the first click has something to land on.
+ */
+ const path = useMemo(() => {
+ if (lane.points.length === 0) {
+ const y = yOf(range.default ?? (range.min + range.max) / 2);
+ return `M ${PAD_X} ${y} L ${PAD_X + widthPx} ${y}`;
+ }
+ const pts: string[] = [];
+ const first = lane.points[0]!;
+ pts.push(`M ${PAD_X} ${yOf(first.v)}`);
+ pts.push(`L ${xOf(first.t)} ${yOf(first.v)}`);
+ for (let i = 0; i + 1 < lane.points.length; i += 1) {
+ const a = lane.points[i]!;
+ const b = lane.points[i + 1]!;
+ if (!a.curve && range.scale === "linear") {
+ pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
+ continue;
+ }
+ // Curved or log-read: sample it, or the drawing would lie about the
+ // envelope the audio thread is going to play.
+ for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
+ const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
+ pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
+ }
+ }
+ const last = lane.points[lane.points.length - 1]!;
+ pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
+ return pts.join(" ");
+ }, [lane, range, widthPx, xOf, yOf]);
+
+ const commitPoints = useCallback(
+ (points: HfAutomationLane["points"], persist: boolean): void => {
+ // Draw from the draft immediately; the write is what eventually agrees.
+ setDraft({ points, basedOn: automation });
+ const next = withLane(automation, { target, points });
+ if (persist) onCommit(next);
+ else onPreview(next);
+ },
+ [automation, target, onCommit, onPreview],
+ );
+
+ /** Index of a point under the pointer, or null. */
+ const hitIndex = useCallback(
+ (clientX: number, clientY: number): number | null => {
+ const box = svgRef.current?.getBoundingClientRect();
+ if (!box) return null;
+ const px = clientX - box.left;
+ const py = clientY - box.top;
+ for (let i = 0; i < lane.points.length; i += 1) {
+ const p = lane.points[i]!;
+ if (Math.hypot(xOf(p.t) - px, yOf(p.v) - py) <= GRAB_PX * 1.6) return i;
+ }
+ return null;
+ },
+ [lane, xOf, yOf],
+ );
+
+ const onPointerDown = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (e.button !== 0) return;
+ // The lane owns this region either way. Letting a press through starts the
+ // timeline's own gesture (scrub / marquee / clip drag), which then eats the
+ // rest of the sequence — including the second half of a double-click.
+ e.stopPropagation();
+ if (readOnly) {
+ // The lane sits below the clip bar, so the timeline's selection handler
+ // never sees this press; selecting here is the only way in.
+ onSelect?.();
+ return;
+ }
+ const index = hitIndex(e.clientX, e.clientY);
+ if (index === null) return;
+ e.preventDefault();
+ (e.target as Element).setPointerCapture?.(e.pointerId);
+ setDragIndex(index);
+ },
+ [hitIndex, readOnly, onSelect],
+ );
+
+ const onPointerMove = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (dragIndex === null) return;
+ e.stopPropagation();
+ const { t, v } = pointAt(e.clientX, e.clientY);
+ const next = lane.points.map((p, i) => (i === dragIndex ? { ...p, t, v } : p));
+ // Re-sort so dragging a point past a neighbour behaves, and keep the
+ // dragged one addressable by following where it landed.
+ const moved = next[dragIndex]!;
+ next.sort((a, b) => a.t - b.t);
+ setDragIndex(next.indexOf(moved));
+ setHint(`${formatValue(range, v)} @ ${t.toFixed(2)}s`);
+ commitPoints(next, false);
+ },
+ [dragIndex, lane, pointAt, range, commitPoints],
+ );
+
+ const endDrag = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (dragIndex === null) return;
+ e.stopPropagation();
+ setDragIndex(null);
+ setHint(null);
+ commitPoints(lane.points, true);
+ },
+ [dragIndex, lane, commitPoints],
+ );
+
+ const onDoubleClick = useCallback(
+ (e: ReactPointerEvent): void => {
+ if (readOnly) return;
+ e.stopPropagation();
+ e.preventDefault();
+ const { t, v } = pointAt(e.clientX, e.clientY);
+ const kept = lane.points.filter((p) => Math.abs(p.t - t) > POINT_MERGE_SEC);
+ // A lane's first point alone would be a constant, which is not what
+ // clicking an empty lane means: seed the far end at the same value so the
+ // envelope has somewhere to go.
+ const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
+ commitPoints(
+ [...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
+ true,
+ );
+ },
+ [lane, pointAt, commitPoints, readOnly],
+ );
+
+ const removeAt = useCallback(
+ (index: number): void => {
+ if (readOnly) return;
+ commitPoints(
+ lane.points.filter((_, i) => i !== index),
+ true,
+ );
+ },
+ [lane, commitPoints, readOnly],
+ );
+
+ const currentValue =
+ lane.points.length > 0 && playheadSec !== null
+ ? sampleAutomationLane(lane, playheadSec, range.scale)
+ : null;
+
+ return (
+
+ {/* Name at the lane's top-left, like a DAW's lane header. Shown in full —
+ a clip starting at zero leaves no gutter to clamp it into — and
+ click-through, so it can sit over the envelope without blocking it. */}
+
+ {range.label}
+
+
+
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
+
+export interface TimelineAutomationLaneSlotProps {
+ element: TimelineElement;
+ isSelected: boolean;
+ lanes: UseAutomationLanesResult;
+ pps: number;
+ /** Keyframe lanes already stacked above, which automation sits under. */
+ laneCount: number;
+ accentColor: string;
+ /** Composition-time playhead; the slot converts it to clip-local. */
+ currentTime: number;
+}
+
+/**
+ * Every automated parameter on this clip, one lane per row — the way a DAW
+ * stacks them, so two envelopes can be read and edited without swapping a
+ * control to see either.
+ */
+export function TimelineAutomationLaneSlot({
+ element,
+ isSelected,
+ lanes,
+ pps,
+ laneCount,
+ accentColor,
+ currentTime,
+}: TimelineAutomationLaneSlotProps) {
+ const bound = lanes.bind(element, isSelected);
+ if (bound.lanes.length === 0) return null;
+ const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
+ const top = getTimelineLaneTop(laneCount);
+ return (
+ <>
+ {bound.lanes.map((lane, index) => {
+ const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
+ // A lane whose target no longer resolves was already dropped upstream;
+ // this is belt and braces so a row can never draw on the wrong axis.
+ if (!range) return null;
+ return (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index bd4de63b88..1119f02d68 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -3,6 +3,8 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
+import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
+import { useAutomationLanes } from "./useAutomationLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
@@ -106,6 +108,7 @@ export function TimelineLanes({
// a CSS `#id` selector, so they come out here and the prefix stays plain.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
+ const automationLanes = useAutomationLanes();
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
const toggleClipExpandedTracked = (key: string) => {
const willExpand = !expandedClipIds.has(key);
@@ -542,8 +545,22 @@ export function TimelineLanes({
Promise.resolve(false)
}
suppressClickRef={suppressClickRef}
+ footer={
+ showsLanes && isAudioTimelineElement(el) ? (
+
+ ) : null
+ }
/>
);
+
// Keep one keyed top-level child per element. Returning an
// array here makes React reconcile the outer array by
// position, so a window shift remounts otherwise stable
diff --git a/packages/studio/src/player/components/TimelinePropertyLanes.tsx b/packages/studio/src/player/components/TimelinePropertyLanes.tsx
index f54064a931..28a83bdbad 100644
--- a/packages/studio/src/player/components/TimelinePropertyLanes.tsx
+++ b/packages/studio/src/player/components/TimelinePropertyLanes.tsx
@@ -1,4 +1,4 @@
-import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
+import { useMemo, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject } from "react";
import {
classifyPropertyGroup,
type GsapAnimation,
@@ -33,6 +33,12 @@ export interface TimelinePropertyLanesProps {
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise;
suppressClickRef?: RefObject;
+ /**
+ * Rendered after the keyframe lanes, inside this wrapper. An audio clip's
+ * automation lane lives here so it shares the same disclosure — and so the
+ * header caret's `aria-controls` covers it too.
+ */
+ footer?: ReactNode;
}
/**
@@ -193,6 +199,7 @@ export function TimelinePropertyLanes({
onContextMenuKeyframe,
onMoveKeyframe,
suppressClickRef,
+ footer,
}: TimelinePropertyLanesProps) {
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
// a fresh keyframesData literal per lane) on every render would re-render every
@@ -256,6 +263,7 @@ export function TimelinePropertyLanes({
/>
))}
+ {footer}
);
}
diff --git a/packages/studio/src/player/components/automationLaneData.ts b/packages/studio/src/player/components/automationLaneData.ts
new file mode 100644
index 0000000000..20b93bcb13
Binary files /dev/null and b/packages/studio/src/player/components/automationLaneData.ts differ
diff --git a/packages/studio/src/player/components/automationLaneHeight.ts b/packages/studio/src/player/components/automationLaneHeight.ts
new file mode 100644
index 0000000000..3ad43c4282
--- /dev/null
+++ b/packages/studio/src/player/components/automationLaneHeight.ts
@@ -0,0 +1,11 @@
+/**
+ * Height of one audio automation lane.
+ *
+ * Its own module because both the row layout and the lane itself need it, and
+ * putting it in either would have the layout importing a component or the
+ * component's constant living somewhere it is not used.
+ *
+ * Taller than a keyframe lane because it carries a value axis rather than a row
+ * of diamonds: a fader envelope drawn 28px high cannot be aimed.
+ */
+export const AUTOMATION_LANE_H = 48;
diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts
index 853f812566..682f0f50f1 100644
--- a/packages/studio/src/player/components/timelineLayout.ts
+++ b/packages/studio/src/player/components/timelineLayout.ts
@@ -1,3 +1,4 @@
+import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { ZoomMode } from "../store/playerStore";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
@@ -86,6 +87,8 @@ export const TRACKS_LEFT_PAD = 48;
export interface TimelineTrackHeightClip {
clipId: string;
laneCount: number;
+ /** Audio automation lanes shown when expanded, reserved at their own height. */
+ automationLaneCount?: number;
}
type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
@@ -101,12 +104,15 @@ export function trackHeights(
): number[] {
return tracks.map((clips) => {
let laneCount = 0;
- if (expandedClipIds) {
- for (const clip of clips) {
- if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
- }
+ let automationLanes = 0;
+ for (const clip of clips) {
+ if (!expandedClipIds?.has(clip.clipId)) continue;
+ laneCount = Math.max(laneCount, clip.laneCount);
+ automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
}
- return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
+ return (
+ TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
+ );
});
}
diff --git a/packages/studio/src/player/components/useAutomationLanes.test.tsx b/packages/studio/src/player/components/useAutomationLanes.test.tsx
new file mode 100644
index 0000000000..2003035ea2
--- /dev/null
+++ b/packages/studio/src/player/components/useAutomationLanes.test.tsx
@@ -0,0 +1,109 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { describe, expect, it } from "vitest";
+import { createRoot } from "react-dom/client";
+import { useAutomationLanes, type AutomationLaneBinding } from "./useAutomationLanes";
+import type { TimelineElement } from "../store/playerStore";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+/** Bind one element through the hook and hand back what the lane would get. */
+function bindOnce(element: TimelineElement): AutomationLaneBinding {
+ let captured: AutomationLaneBinding | null = null;
+ function Probe() {
+ captured = useAutomationLanes().bind(element, true);
+ return null;
+ }
+ const host = document.createElement("div");
+ document.body.append(host);
+ act(() => {
+ createRoot(host).render();
+ });
+ if (!captured) throw new Error("bind never ran");
+ return captured;
+}
+
+const el = (over: Partial = {}): TimelineElement => ({
+ id: "music",
+ key: "music",
+ tag: "audio",
+ start: 0,
+ duration: 12,
+ track: 10,
+ ...over,
+});
+
+const CHAIN = JSON.stringify({
+ version: 1,
+ nodes: [{ type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }],
+});
+
+describe("useAutomationLanes", () => {
+ it("drops a lane whose effect is no longer in the chain", () => {
+ // n1 was deleted from the chain but its lane survived in the attribute.
+ // Drawn as-is it landed on the volume axis, with points off the lane and
+ // a selector that had no option for it.
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n1.speed",
+ points: [
+ { t: 0, v: 0.4 },
+ { t: 12, v: 6 },
+ ],
+ },
+ { target: "volume", points: [{ t: 0, v: 0.55 }] },
+ ],
+ });
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
+ expect(bound.automation.lanes.map((l) => l.target)).toEqual(["volume"]);
+ });
+
+ it("keeps a lane whose effect is still there", () => {
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [
+ {
+ target: "fx.n2.frequency",
+ points: [
+ { t: 0, v: 400 },
+ { t: 4, v: 8000 },
+ ],
+ },
+ ],
+ });
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
+ expect(bound.lanes.map((l) => l.target)).toEqual(["fx.n2.frequency"]);
+ });
+
+ it("gives one lane per automated parameter, in draw order", () => {
+ const automation = JSON.stringify({
+ version: 1,
+ lanes: [
+ { target: "volume", points: [{ t: 0, v: 0.5 }] },
+ { target: "fx.n2.frequency", points: [{ t: 0, v: 400 }] },
+ { target: "fx.n2.q", points: [{ t: 0, v: 1 }] },
+ ],
+ });
+ const bound = bindOnce(el({ automation, fxChain: CHAIN }));
+ expect(bound.lanes.map((l) => l.target)).toEqual(["volume", "fx.n2.frequency", "fx.n2.q"]);
+ });
+
+ it("reads an element with neither attribute as an empty volume lane", () => {
+ const bound = bindOnce(el());
+ expect(bound.lanes).toEqual([]);
+ expect(bound.chain).toBeNull();
+ });
+
+ it("is read-only without an edit session, whatever the selection", () => {
+ // No DomEditProvider in this tree — the bare player case.
+ expect(bindOnce(el({ automation: undefined })).readOnly).toBe(true);
+ });
+
+ it("survives an unreadable attribute instead of breaking the row", () => {
+ const bound = bindOnce(el({ automation: "{not json", fxChain: "{also not}" }));
+ expect(bound.automation.lanes).toEqual([]);
+ expect(bound.chain).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts
new file mode 100644
index 0000000000..0e23ce8336
--- /dev/null
+++ b/packages/studio/src/player/components/useAutomationLanes.ts
@@ -0,0 +1,85 @@
+/**
+ * Writes for the timeline's audio automation lanes.
+ *
+ * Kept out of TimelineLanes so that component does not grow another concern.
+ * Reading lives in `automationLaneData`, shared with the row layout, which needs
+ * the lane count to reserve height.
+ *
+ * Edits go to the *selected* element, because that is what the attribute commit
+ * path targets. An unselected clip still draws its envelopes — they are just
+ * read only, which is also what stops a stray drag from editing the wrong track.
+ */
+
+import { useCallback, useMemo } from "react";
+import {
+ HF_AUDIO_AUTOMATION_ATTR,
+ serializeAutomation,
+ type HfAutomation,
+ type HfAutomationLane,
+} from "@hyperframes/core/audio-automation";
+import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
+import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
+import type { TimelineElement } from "../store/playerStore";
+import { elementAutomation, elementFxChain } from "./automationLaneData";
+
+export interface AutomationLaneBinding {
+ automation: HfAutomation;
+ /** One entry per lane, in draw order — each gets its own row. */
+ lanes: HfAutomationLane[];
+ chain: HfAudioFxChain | null;
+ /** Continuous write while dragging; does not persist. */
+ onPreview(next: HfAutomation): void;
+ /** Gesture-end write; this is the one that persists and lands in undo. */
+ onCommit(next: HfAutomation): void;
+ /**
+ * Select this clip, which is what makes its lanes editable. A lane calls this
+ * instead of writing when it is read-only — pressing the lane is the only
+ * route in, since lanes sit below the clip bar where the timeline's own
+ * selection handler never sees them.
+ */
+ onSelect(): void;
+ readOnly: boolean;
+}
+
+export interface UseAutomationLanesResult {
+ bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
+}
+
+export function useAutomationLanes(): UseAutomationLanesResult {
+ // Optional: the player also runs outside Studio, where there is no edit
+ // session. There the lanes render read-only, which is the right fallback.
+ const domEdit = useDomEditActionsContextOptional();
+
+ const bind = useCallback(
+ (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
+ const chain = elementFxChain(element);
+ const automation = elementAutomation(element);
+
+ const write = (next: HfAutomation, persist: boolean): void => {
+ if (!domEdit || !isSelected) return;
+ const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
+ if (persist) void domEdit.handleDomAttributeCommit(HF_AUDIO_AUTOMATION_ATTR, value);
+ // Dragging a point writes live: no preview refresh, so the composition
+ // does not reload and restart playback on every pixel.
+ else void domEdit.handleDomAttributeLiveCommit(HF_AUDIO_AUTOMATION_ATTR, value || null);
+ };
+
+ return {
+ automation,
+ lanes: automation.lanes,
+ chain,
+ onPreview: (next) => write(next, false),
+ onCommit: (next) => write(next, true),
+ // Deliberately not awaited before an edit: the commit handlers close
+ // over the selection as it was when they were built, so writing in the
+ // same tick would land on whichever element was selected before.
+ // Selecting is its own gesture; the lane goes live after it.
+ onSelect: () => void domEdit?.handleTimelineElementSelect(element),
+ readOnly: !domEdit || !isSelected,
+ };
+ },
+ [domEdit],
+ );
+
+ return useMemo(() => ({ bind }), [bind]);
+}
diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts
index 0455293994..bfa1170ea9 100644
--- a/packages/studio/src/player/components/useTimelineTrackLayout.ts
+++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts
@@ -1,6 +1,8 @@
import { useMemo, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationLaneGroups } from "./TimelinePropertyLanes";
+import { isAudioTimelineElement } from "../../utils/timelineInspector";
+import { elementAutomationLanes } from "./automationLaneData";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
@@ -85,7 +87,15 @@ function useTimelineRowHeights(
);
if (!active) return [];
const clipId = active.key ?? active.id;
- return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
+ return [
+ {
+ clipId,
+ laneCount: laneCounts.get(clipId) ?? 0,
+ automationLaneCount: isAudioTimelineElement(active)
+ ? elementAutomationLanes(active).length
+ : 0,
+ },
+ ];
});
const rowHeights = trackHeights(heightTracks, expandedClipIds);
return {
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts
index a8730caa4b..b430a9889a 100644
--- a/packages/studio/src/player/lib/timelineDOM.ts
+++ b/packages/studio/src/player/lib/timelineDOM.ts
@@ -138,6 +138,10 @@ export function createTimelineElementFromManifestClip(params: {
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
const timelineRole = hostEl.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
+ const fxChain = hostEl.getAttribute("data-fx-chain");
+ if (fxChain) entry.fxChain = fxChain;
+ const automation = hostEl.getAttribute("data-automation");
+ if (automation) entry.automation = automation;
entry.zIndex = readTimelineElementZIndex(hostEl);
}
if (clip.assetUrl) entry.src = clip.assetUrl;
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 26323e458b..28edc29e76 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -58,6 +58,11 @@ export interface TimelineElement {
playbackRate?: number;
sourceDuration?: number;
volume?: number;
+ /** Verbatim `data-fx-chain` / `data-automation`, when set. Kept raw: the lane
+ * reads and writes the attribute, which is what keeps it, the property panel
+ * and the running audio graph on one source of truth. */
+ fxChain?: string;
+ automation?: string;
/** Path from data-composition-src — identifies sub-composition elements */
compositionSrc?: string;
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */