From 343e6cc88ab191c16ac3a374e258ed2a3709b298 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 03:27:50 -0700 Subject: [PATCH 1/2] feat(studio): automation lanes in the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draws each automated parameter as its own lane under the audio clip, on the same disclosure caret the keyframe lanes use — that caret is the DAW automation triangle. One lane per parameter rather than a selector to swap between them, so two envelopes can be read and edited without hiding either. Editing is the DAW gesture set: double-click the line to add a point, drag to shape it, right-click a point to remove it. Dragging writes live so playback is not interrupted, and a local draft carries the point under the pointer — the live write deliberately skips the refresh, so the stored value does not move until the edit comes back around, and without the draft the point would not follow the cursor. Everything the lane knows about a parameter comes from the FX registry: range, unit, and whether it reads logarithmically. A log knob is drawn and dragged on a log axis, so the curve on screen is the curve the audio thread plays. Lanes are bound to the chain the way preview and the render bind them, so one whose effect was deleted is dropped rather than drawn against the wrong axis. Row height reserves each lane, the envelope is inset so a point at the clip's first or last frame is drawn whole rather than half outside it, and pressing a lane on an unselected clip selects it — lanes sit below the clip bar, where the timeline's own selection handler never sees the press. Co-Authored-By: Claude Opus 5 (1M context) --- .../TimelineAutomationLane.test.tsx | 471 +++++++++++++++ .../components/TimelineAutomationLane.tsx | 544 ++++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 17 + .../components/TimelinePropertyLanes.tsx | 10 +- .../player/components/automationLaneData.ts | Bin 0 -> 2394 bytes .../player/components/automationLaneHeight.ts | 11 + .../src/player/components/timelineLayout.ts | 16 +- .../components/useAutomationLanes.test.tsx | 109 ++++ .../player/components/useAutomationLanes.ts | 85 +++ .../components/useTimelineTrackLayout.ts | 12 +- packages/studio/src/player/lib/timelineDOM.ts | 4 + .../studio/src/player/store/playerStore.ts | 5 + 12 files changed, 1277 insertions(+), 7 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineAutomationLane.test.tsx create mode 100644 packages/studio/src/player/components/TimelineAutomationLane.tsx create mode 100644 packages/studio/src/player/components/automationLaneData.ts create mode 100644 packages/studio/src/player/components/automationLaneHeight.ts create mode 100644 packages/studio/src/player/components/useAutomationLanes.test.tsx create mode 100644 packages/studio/src/player/components/useAutomationLanes.ts 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} +
+ + + + {readOnly + ? "Click to select this clip, then double-click to add a point" + : "Double-click to add a point, drag to shape, right-click a point to remove"} + + + {/* Mid rail, so a value reads against something. */} + + + {lane.points.map((p, i) => ( + { + e.preventDefault(); + e.stopPropagation(); + removeAt(i); + }} + /> + ))} + {playheadSec !== null && currentValue !== null ? ( + + ) : null} + + + {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 0000000000000000000000000000000000000000..20b93bcb13988ad479a563f0763f157d92ebc573 GIT binary patch literal 2394 zcma)8?QR=45bSR~#l{E_$+$`=P~?XkJ53D-F%Sbms{k#EqM`2gbgWOLK=Lf9s(?I1 zpRiBTSsryKJ0Grp7~eN?sLAQ+fKKTrR)sbTQiizZC7bZflWKxps-UgO$XCj7 zLGvwzim6tH>HRgri}>#B8f#nIgfY+r@JAaeS}Er_26(l^!`hMx!RdJu*iRFZ>>|Tl z_#Qg9jR_R2AUXEz?wBgpiz;MYkdK>=UQyW6bA88tOz)6c&Z9l$DzCcA1+XiD0&fC& z8!>c&L+h|5Yw9iKl`;$Vw6561&oQ*HSo-(lKV8yiUH}WH7V@tQl-nf^jVQHH+IXCy zQh^TGStLwRiHM5q{7=pU@)6&$+X~VOBvxnz1)bm0+BUWDE9|PZYN?OOw4s%=3&40G zd@>m5<;uE1y8*SVzv_F3B?MX!bAK^xS4?lpM;70r)!AT=n(dNKeyFg?l@9InliWI< zsHV{N+g>UsXI(9aN)YgRb6J59bnlR~U!=#t%S{g&_wQ(?mt1SZSFKn(i1B#hgOnvq zvin({v3ZbNX~xyfyV2ha z9zr&YETSD9=5I1hU0)1GXDMk<&DRhPlXkT6^3~;=t3Tde-(1g-_WAcHr)*3v^O!{a zVsBz=>Yf$GhT2QVkc@R(j^V$ z+mrKiYD~c;=q^T(QfQnZe376Q;!f3#P!sdW$=ls6rq_QlUC_~KMk!lfvvO^C3T@M8 z6agbnN*5Q-2K&Kaa(YTLG@`eu*T2ZPiny$c!c&qRp-7p%wc+|@AoyLfyt3kjVLq3k?8XI+(l4|ddb!Bloa9N5AN~`7!vn{|` zfM26og|n*_9-V@rFFM339&UYa;=LVH;(2Qyj!`FrE9p+aBs!Gq~DIVhiW9bfIKCc zStm*Y#FlW)wo<^{W5nf>WLWgq=DJrw1pU8KkYL;gJ5-#iO`YiR4p5_UE#=B6{RgJ} BCfNW0 literal 0 HcmV?d00001 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. */ From a7f2491da1a64fa306557255c8ac07e5170f9b62 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 03:29:10 -0700 Subject: [PATCH 2/2] feat(studio): automate and un-automate from the property panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automated parameter's control is disabled, since a value set there would be overwritten by the envelope on the next tick — the lane is the value now. Each automatable parameter gets a toggle beside it: adding a lane seeds it with a single point at the value the control already holds, so switching to an envelope never changes the sound, only where the value comes from. Toggling it off deletes that lane and hands the value back. Parameters no envelope can drive have no toggle at all — the worklet-backed dynamics, a WaveShaper's curve, a convolution impulse — and neither does a chain node with no id, since a lane addresses nodes by id. Adding an effect now mints one. Writes go through a new commit that persists without reloading the preview but still re-reads the selection. Both halves are needed: the reload restarts every playing track, and without the resync the panel keeps reading the snapshot it was built with, so a second edit computes from a pre-edit value and appears to do nothing — deleting one effect made every later delete a no-op. Carve is on that path too, and decodes its source in an OfflineAudioContext: opening a second output device mid-playback makes the running track glitch while the hardware is reconfigured. Turning carve off now also drops the filters it generated, which otherwise kept dipping the bed with nothing to explain it. AudioFxGroup moves into its own module — PropertyPanelFlat was already at its size budget — and both panel sections read automation through one helper, which is what surfaced that resolving against an absent chain would have deleted every FX lane the moment someone automated a volume. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/audioAutomation.ts | 4 + .../src/components/StudioRightPanel.tsx | 2 + .../components/editor/PropertyPanelFlat.tsx | 133 +--------- .../editor/propertyPanelAudioFxGroup.test.tsx | 241 ++++++++++++++++++ .../editor/propertyPanelAudioFxGroup.tsx | 218 ++++++++++++++++ .../editor/propertyPanelAutomation.ts | 80 ++++++ .../editor/propertyPanelFlatMediaSection.tsx | 46 +++- .../editor/propertyPanelFlatProps.ts | 1 + .../editor/propertyPanelFxControls.tsx | 130 ++++++++-- .../editor/propertyPanelFxSection.test.tsx | 132 ++++++++++ .../editor/propertyPanelFxSection.tsx | 102 +++++++- .../components/editor/propertyPanelTypes.ts | 5 + .../editor/useVolumeAutomation.test.tsx | 107 ++++++++ .../components/editor/useVolumeAutomation.ts | 44 ++++ .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/hooks/useDomEditAttributeCommits.ts | 22 ++ .../studio/src/hooks/useDomEditCommits.ts | 2 + .../studio/src/hooks/useDomEditSession.ts | 2 + .../studio/src/hooks/useDomEditTextCommits.ts | 2 + 19 files changed, 1120 insertions(+), 157 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelAutomation.ts create mode 100644 packages/studio/src/components/editor/useVolumeAutomation.test.tsx create mode 100644 packages/studio/src/components/editor/useVolumeAutomation.ts diff --git a/packages/core/src/audioAutomation.ts b/packages/core/src/audioAutomation.ts index 2eadfef597..f6363f5426 100644 --- a/packages/core/src/audioAutomation.ts +++ b/packages/core/src/audioAutomation.ts @@ -88,6 +88,8 @@ export interface AutomationRange { unit: string; label: string; scale: "linear" | "log"; + /** Where an empty lane draws its flat line, and what a new point starts at. */ + default: number; } export const VOLUME_RANGE: AutomationRange = { @@ -97,6 +99,7 @@ export const VOLUME_RANGE: AutomationRange = { unit: "", label: "Volume", scale: "linear", + default: 1, }; /** @@ -124,6 +127,7 @@ export function resolveAutomationRange( unit: p.unit, label: `${def?.label ?? node.type} · ${p.label}`, scale: p.scale === "log" && p.min > 0 ? "log" : "linear", + default: p.default, }; } diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 68088311f3..8ee3b9c88d 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -124,6 +124,7 @@ export function StudioRightPanel({ handleDomStyleCommit, handleDomAttributeCommit, handleDomAttributeLiveCommit, + handleDomAttributeQuietCommit, handleDomHtmlAttributeCommit, handleDomAttributesCommit, handleDomPathOffsetCommit, @@ -361,6 +362,7 @@ export function StudioRightPanel({ onSetAttribute={handleDomAttributeCommit} onSetAttributes={handleDomAttributesCommit} onSetAttributeLive={handleDomAttributeLiveCommit} + onSetAttributeQuiet={handleDomAttributeQuietCommit} onApplyColorGradingScope={handleApplyColorGradingScope} onSetHtmlAttribute={handleDomHtmlAttributeCommit} onRemoveBackground={handleRemoveBackground} diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index dc3f4f7b3f..0b3d963e62 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -13,22 +13,11 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; -import { - HF_AUDIO_FX_ATTR, - parseAudioFxChain, - serializeAudioFxChain, - type HfAudioFxChain, -} from "@hyperframes/core/audio-fx"; -import { - analyseCarveBands, - carveBandsToChain, - HF_AUDIO_CARVE_ATTR, - normalizeCarveSettings, - type HfCarveSettings, -} from "@hyperframes/core/audio-carve"; +import { parseAudioFxChain } from "@hyperframes/core/audio-fx"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import { useVolumeAutomation } from "./useVolumeAutomation"; import { FlatMediaSection } from "./propertyPanelFlatMediaSection"; import type { DomEditSelection } from "./domEditing"; -import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { createGsapLivePreview } from "./gsapLivePreview"; import { formatTextFieldPreview } from "./propertyPanelSections"; @@ -79,6 +68,7 @@ export function PropertyPanelFlat({ onSetAttribute, onSetAttributes, onSetAttributeLive, + onSetAttributeQuiet, onApplyColorGradingScope, onSetHtmlAttribute, onRemoveBackground, @@ -267,6 +257,8 @@ export function PropertyPanelFlat({ const showMotionEffects = gsapEffectHandlers !== null; const showMotionGroup = showMotionTiming || showMotionEffects; + const volumeAutomation = useVolumeAutomation(element, onSetAttribute); + const groups: FlatGroupDescriptor[] = []; if (isTextEditable) { groups.push({ @@ -443,8 +435,7 @@ export function PropertyPanelFlat({ content: ( ), }); @@ -463,6 +454,7 @@ export function PropertyPanelFlat({ onSetAttribute={onSetAttribute} onSetHtmlAttribute={onSetHtmlAttribute} onRemoveBackground={onRemoveBackground} + {...volumeAutomation} /> ), }); @@ -561,112 +553,3 @@ function audioFxSummary(element: DomEditSelection): string { if (carve) parts.push("carve"); return parts.length > 0 ? parts.join(" + ") : "none"; } - -/** - * Bridges the FX panel to the element/attribute world. Chain and carve are - * serialised onto the element the way colour grading carries its config, so - * persistence is an ordinary attribute write with no new server route. - */ -function AudioFxGroup({ - element, - onSetAttribute, - onSetAttributeLive, -}: { - element: DomEditSelection; - onSetAttribute: (attr: string, value: string) => void | Promise; - onSetAttributeLive: (attr: string, value: string | null) => void | Promise; -}) { - const chain = ((): HfAudioFxChain => { - const raw = element.dataAttributes?.["fx-chain"]; - if (!raw) return { version: 1, nodes: [] }; - try { - return parseAudioFxChain(raw); - } catch { - // Show an unreadable chain as empty rather than breaking the panel; the - // attribute is left untouched until the user changes something. - return { version: 1, nodes: [] }; - } - })(); - - const carve = ((): HfCarveSettings | null => { - const raw = element.dataAttributes?.["fx-carve"]; - if (!raw) return null; - try { - return normalizeCarveSettings(JSON.parse(raw)); - } catch { - return null; - } - })(); - - const sourceOptions: AudioTrackOption[] = (() => { - const doc = element.element?.ownerDocument; - if (!doc) return []; - return Array.from(doc.querySelectorAll("audio[id]")) - .filter((a) => a.id !== element.id) - .map((a) => ({ id: a.id, label: a.id })); - })(); - - const [analysing, setAnalysing] = useState(false); - - /** - * Decodes the chosen voice track and turns its spectrum into peaking filters - * on this one. The bands replace any previous carve output but leave - * hand-added effects alone, so re-analysing does not discard other work. - */ - const analyse = async (): Promise => { - if (!carve?.source) return; - const doc = element.element?.ownerDocument; - const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; - const src = voice?.getAttribute("src"); - if (!src) return; - setAnalysing(true); - try { - const res = await fetch(new URL(src, doc!.baseURI).href); - const bytes = await res.arrayBuffer(); - const Ctor = - window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctor) return; - const ctx = new Ctor(); - try { - const buffer = await ctx.decodeAudioData(bytes); - const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); - const carved = carveBandsToChain(bands); - // Carve output is tagged so a re-run replaces it instead of stacking. - const kept = chain.nodes.filter((n) => !n.fromCarve); - const next = { - version: 1, - nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], - }; - onSetAttribute(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); - } finally { - void ctx.close().catch(() => undefined); - } - } catch { - // Leave the chain as it was; the button simply re-enables. - } finally { - setAnalysing(false); - } - }; - - return ( - - onSetAttribute(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : "") - } - onChainPreview={(next) => - // Live writes skip the preview refresh, so dragging a knob no longer - // reloads the composition and restarts playback on every pixel. - onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null) - } - carve={carve} - onCarveChange={(next) => - onSetAttribute(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : "") - } - sourceOptions={sourceOptions} - onAnalyseCarve={() => void analyse()} - analysing={analysing} - /> - ); -} diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx new file mode 100644 index 0000000000..974215bf8a --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx @@ -0,0 +1,241 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; +import type { DomEditSelection } from "./domEditingTypes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CHAIN = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 900, q: 1.2, poles: "2" } }], +}); + +function mount(dataAttributes: Record) { + // Every write is quiet: persisted without the preview reload that would + // restart every playing track, but with a selection resync so the panel sees + // what it just wrote. + const onSetAttributeQuiet = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const selection = { dataAttributes, element: null } as unknown as DomEditSelection; + act(() => { + createRoot(host).render( + , + ); + }); + return { host, onSetAttributeQuiet }; +} + +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; +}; + +const parseWrite = (call: unknown[]) => JSON.parse(String(call[1])); + +describe("AudioFxGroup automation", () => { + it("renders the chain's parameters", () => { + const { host } = mount({ "fx-chain": CHAIN }); + expect(rowFor(host, "Cutoff")).toBeTruthy(); + expect(rowFor(host, "Q")).toBeTruthy(); + }); + + it("seeds a new lane at the value the control already holds", () => { + // Switching to an envelope must not change the sound — only where the value + // comes from. The chain has frequency at 900, not the registry default. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": CHAIN }); + const button = rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement; + act(() => button.click()); + const [attr, value] = onSetAttributeQuiet.mock.calls[0]; + expect(attr).toBe("data-automation"); + expect(JSON.parse(String(value))).toEqual({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 900 }] }], + }); + }); + + it("keeps lanes it is not touching when adding one", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }], + }), + }); + act(() => (rowFor(host, "Q")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click()); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume", "fx.n1.q"]); + }); + + it("disables a control the timeline already drives", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + const cutoff = rowFor(host, "Cutoff")!; + expect(cutoff.querySelector('input[type="range"]')?.disabled).toBe(true); + expect(cutoff.hasAttribute("data-automated")).toBe(true); + expect( + rowFor(host, "Q")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); + + it("deletes just that lane, handing the value back to the control", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [ + { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }, + { target: "volume", points: [{ t: 0, v: 0.5 }] }, + ], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + expect( + parseWrite(onSetAttributeQuiet.mock.calls[0]).lanes.map((l: { target: string }) => l.target), + ).toEqual(["volume"]); + }); + + it("clears the attribute when the last lane goes", () => { + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + act(() => + (rowFor(host, "Cutoff")!.querySelector(".hf-fx-automate") as HTMLButtonElement).click(), + ); + // Null rather than "": the live path removes an attribute it is given null for. + expect(onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("ignores a lane for an effect that is no longer in the chain", () => { + const { host } = mount({ + "fx-chain": CHAIN, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.gone.frequency", points: [{ t: 0, v: 400 }] }], + }), + }); + // Nothing is automated, so every control stays live. + expect( + rowFor(host, "Cutoff")!.querySelector('input[type="range"]')?.disabled, + ).toBe(false); + }); +}); + +describe("AudioFxGroup carve", () => { + const carvedChain = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + ], + }); + + const carveOn = JSON.stringify({ + source: "vo", + maxCutDb: 6, + bands: 3, + q: 1.4, + intelligibilityBias: 0.7, + }); + + const carveToggle = (host: HTMLElement): HTMLButtonElement => { + const block = host.querySelector(".hf-fx-carve")!; + return block.querySelector(".hf-fx-bypass") as HTMLButtonElement; + }; + + it("removes the filters it generated when carve is switched off", () => { + // Leaving them behind would keep dipping the bed with no carve to explain it. + const { host, onSetAttributeQuiet } = mount({ + "fx-chain": carvedChain, + "fx-carve": carveOn, + }); + act(() => carveToggle(host).click()); + const chainWrite = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-chain"); + expect(chainWrite).toBeTruthy(); + const kept = JSON.parse(String(chainWrite![1])).nodes; + expect(kept.map((n: { type: string }) => n.type)).toEqual(["lowpass"]); + // And the carve settings themselves go. + expect(onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve")?.[1]).toBeNull(); + }); + + it("leaves a hand-built chain alone when carve is switched off", () => { + const handBuilt = JSON.stringify({ + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }], + }); + const { host, onSetAttributeQuiet } = mount({ "fx-chain": handBuilt, "fx-carve": carveOn }); + act(() => carveToggle(host).click()); + expect(onSetAttributeQuiet.mock.calls.some((c) => c[0] === "data-fx-chain")).toBe(false); + }); + + it("writes carve settings live, so enabling it does not reload the preview", () => { + const { host, onSetAttributeQuiet } = mount({ "fx-chain": carvedChain }); + act(() => carveToggle(host).click()); + const write = onSetAttributeQuiet.mock.calls.find((c) => c[0] === "data-fx-carve"); + expect(write).toBeTruthy(); + expect(JSON.parse(String(write![1])).bands).toBeGreaterThan(0); + }); +}); + +describe("AudioFxGroup successive edits", () => { + const three = JSON.stringify({ + version: 1, + nodes: [ + { type: "peaking", id: "n1", params: { frequency: 900, gain: -6, q: 1.4 } }, + { type: "lowpass", id: "n2", params: { frequency: 400, q: 0.9, poles: "2" } }, + { type: "delay", id: "n3", params: { time: 250, feedback: 0.3, mix: 0.4 } }, + ], + }); + + const removeButtons = (host: HTMLElement) => + Array.from(host.querySelectorAll(".hf-fx-remove")); + + /** + * The panel computes each edit from the attribute it is holding. Written + * without a selection resync, the second delete worked from the pre-delete + * chain and wrote the same result — so after deleting one effect, no further + * delete did anything. + */ + it("deletes a second effect after the first, not from a stale chain", () => { + const first = mount({ "fx-chain": three }); + act(() => removeButtons(first.host)[0]!.click()); + const afterFirst = JSON.parse(String(first.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterFirst.nodes.map((n: { id: string }) => n.id)).toEqual(["n2", "n3"]); + + // The resync hands the panel what it just wrote; the next delete starts there. + const second = mount({ "fx-chain": JSON.stringify(afterFirst) }); + act(() => removeButtons(second.host)[0]!.click()); + const afterSecond = JSON.parse(String(second.onSetAttributeQuiet.mock.calls[0][1])); + expect(afterSecond.nodes.map((n: { id: string }) => n.id)).toEqual(["n3"]); + + const third = mount({ "fx-chain": JSON.stringify(afterSecond) }); + act(() => removeButtons(third.host)[0]!.click()); + // The last one leaves no chain at all. + expect(third.onSetAttributeQuiet.mock.calls[0][1]).toBeNull(); + }); + + it("writes with the commit that resyncs the selection, not the silent one", () => { + // Both skip the preview reload; only this one re-reads the selection, which + // is what makes a following edit see the current value. + const { host, onSetAttributeQuiet } = mount({ "fx-chain": three }); + act(() => removeButtons(host)[0]!.click()); + expect(onSetAttributeQuiet).toHaveBeenCalledTimes(1); + expect(onSetAttributeQuiet.mock.calls[0][0]).toBe("data-fx-chain"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx new file mode 100644 index 0000000000..ac34e39c95 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx @@ -0,0 +1,218 @@ +/** + * The audio FX panel's bridge to the element/attribute world. + * + * Chain, carve and automation are all serialised onto the element the way colour + * grading carries its config, so persistence is an ordinary attribute write with + * no new server route. Split out of PropertyPanelFlat, which is at its size + * budget, and self-contained enough to test on its own. + */ + +import { useState } from "react"; +import { + HF_AUDIO_FX_ATTR, + parseAudioFxChain, + serializeAudioFxChain, + type HfAudioFxChain, +} from "@hyperframes/core/audio-fx"; +import { + analyseCarveBands, + carveBandsToChain, + HF_AUDIO_CARVE_ATTR, + normalizeCarveSettings, + type HfCarveSettings, +} from "@hyperframes/core/audio-carve"; +import { fxAutomationTarget, type HfAutomation } from "@hyperframes/core/audio-automation"; +import { + automatedTargetsOf, + automationAttrValue, + HF_AUDIO_AUTOMATION_ATTR, + readPanelAutomation, + resolveAutomationRange, + withoutLane, + withSeededLane, +} from "./propertyPanelAutomation"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** + * Rate the carve source is decoded at. Analysis is self-consistent because it + * reads the decoded buffer's own rate, so this only has to be a sane audio rate. + */ +const DECODE_SAMPLE_RATE = 48000; +import { FxSection, type AudioTrackOption } from "./propertyPanelFxSection.js"; + +/** + * Bridges the FX panel to the element/attribute world. Chain and carve are + * serialised onto the element the way colour grading carries its config, so + * persistence is an ordinary attribute write with no new server route. + */ +export function AudioFxGroup({ + element, + onSetAttributeQuiet, +}: { + element: DomEditSelection; + /** + * Every write here is quiet: it persists to the source and skips the preview + * reload, because the runtime applies chain and automation edits to the + * running graph — a reload would only interrupt the audio to reach the same + * state, which is heard as the track chopping. + * + * It does re-read the selection afterwards, which this panel depends on: each + * edit is computed from the current attribute, so without the resync a second + * edit would work from a pre-edit value and appear to do nothing. + */ + onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise; +}) { + const chain = ((): HfAudioFxChain => { + const raw = element.dataAttributes?.["fx-chain"]; + if (!raw) return { version: 1, nodes: [] }; + try { + return parseAudioFxChain(raw); + } catch { + // Show an unreadable chain as empty rather than breaking the panel; the + // attribute is left untouched until the user changes something. + return { version: 1, nodes: [] }; + } + })(); + + const automation = readPanelAutomation(element.dataAttributes?.["automation"], chain); + const automatedTargets = automatedTargetsOf(automation); + + // Written through the live path on purpose. It persists to the source just + // like the refreshing one, but skips the preview reload — and a reload + // restarts every playing track, which is heard as the audio chopping. The + // runtime follows the attribute and swaps the graph in place instead. + const writeAutomation = (next: HfAutomation): void => { + void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null); + }; + + /** + * Start automating one effect parameter. + * + * The lane is seeded with a single point at the value the control currently + * holds, so switching to an envelope never changes the sound — it only moves + * where the value comes from. The author then adds points in the timeline. + */ + const automateParam = (nodeId: string, paramKey: string): void => { + const target = fxAutomationTarget(nodeId, paramKey); + const node = chain.nodes.find((n) => n.id === nodeId); + const range = resolveAutomationRange(target, chain); + if (!node || !range) return; + const raw = node.params?.[paramKey]; + writeAutomation( + withSeededLane(automation, target, typeof raw === "number" ? raw : range.default), + ); + }; + + /** Stop automating it, handing the value back to the panel control. */ + const removeParamAutomation = (nodeId: string, paramKey: string): void => { + writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey))); + }; + + const carve = ((): HfCarveSettings | null => { + const raw = element.dataAttributes?.["fx-carve"]; + if (!raw) return null; + try { + return normalizeCarveSettings(JSON.parse(raw)); + } catch { + return null; + } + })(); + + const sourceOptions: AudioTrackOption[] = (() => { + const doc = element.element?.ownerDocument; + if (!doc) return []; + return Array.from(doc.querySelectorAll("audio[id]")) + .filter((a) => a.id !== element.id) + .map((a) => ({ id: a.id, label: a.id })); + })(); + + const [analysing, setAnalysing] = useState(false); + + /** + * Decodes the chosen voice track and turns its spectrum into peaking filters + * on this one. The bands replace any previous carve output but leave + * hand-added effects alone, so re-analysing does not discard other work. + */ + const analyse = async (): Promise => { + if (!carve?.source) return; + const doc = element.element?.ownerDocument; + const voice = doc?.getElementById(carve.source) as HTMLAudioElement | null; + const src = voice?.getAttribute("src"); + if (!src) return; + setAnalysing(true); + try { + const res = await fetch(new URL(src, doc!.baseURI).href); + const bytes = await res.arrayBuffer(); + // Decoded in an OfflineAudioContext, not a live one. Opening a second + // output device mid-playback makes the running track glitch while the + // hardware is reconfigured; an offline context touches no device. + const Ctor = + window.OfflineAudioContext ?? + (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + if (!Ctor) return; + const decoder = new Ctor(1, 1, DECODE_SAMPLE_RATE); + const buffer = await decoder.decodeAudioData(bytes); + const bands = analyseCarveBands(buffer.getChannelData(0), buffer.sampleRate, carve); + const carved = carveBandsToChain(bands); + // Carve output is tagged so a re-run replaces it instead of stacking. + const kept = chain.nodes.filter((n) => !n.fromCarve); + const next = { + version: 1, + nodes: [...carved.nodes.map((n) => ({ ...n, fromCarve: true })), ...kept], + }; + // Live, like every other chain write: the runtime swaps the graph in + // place, so a reload would only interrupt the audio to reach the same + // filters. + onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next)); + } catch { + // Leave the chain as it was; the button simply re-enables. + } finally { + setAnalysing(false); + } + }; + + return ( + + // Live for the same reason as automation above: adding, removing or + // bypassing an effect is applied to the running graph, so a reload would + // only interrupt the audio to reach the same state. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + onChainPreview={(next) => + // Live writes skip the preview refresh, so dragging a knob no longer + // reloads the composition and restarts playback on every pixel. + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + next.nodes.length ? serializeAudioFxChain(next) : null, + ) + } + carve={carve} + onCarveChange={(next) => { + // Turning carve off drops the filters it generated; leaving them behind + // would keep dipping the bed with no carve to explain it. + if (!next) { + const kept = chain.nodes.filter((n) => !n.fromCarve); + if (kept.length !== chain.nodes.length) { + onSetAttributeQuiet( + HF_AUDIO_FX_ATTR, + kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null, + ); + } + } + void onSetAttributeQuiet(HF_AUDIO_CARVE_ATTR, next ? JSON.stringify(next) : null); + }} + sourceOptions={sourceOptions} + onAnalyseCarve={() => void analyse()} + analysing={analysing} + /> + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelAutomation.ts b/packages/studio/src/components/editor/propertyPanelAutomation.ts new file mode 100644 index 0000000000..d940eedfab --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelAutomation.ts @@ -0,0 +1,80 @@ +/** + * Reading and editing an element's automation from the property panel. + * + * Shared by the audio FX group (per-effect parameters) and the media section + * (track volume), so both agree on what "automated" means and both write the + * attribute the same way. + */ + +import { + HF_AUDIO_AUTOMATION_ATTR, + parseAutomation, + resolveAutomation, + resolveAutomationRange, + serializeAutomation, + type HfAutomation, +} from "@hyperframes/core/audio-automation"; +import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; + +const EMPTY: HfAutomation = { version: 1, lanes: [] }; + +/** + * The element's automation as the panel should treat it. + * + * Pass the chain to have it bound: a lane whose effect has been deleted is then + * dropped rather than reported as automating something. Pass `undefined` when + * the caller genuinely does not know the chain — the volume section does not + * parse it — and every lane is preserved instead. + * + * That distinction matters because callers write this value straight back to the + * attribute. Resolving against a chain that was merely unavailable would delete + * every FX lane the moment someone automated the volume. + * + * An unreadable attribute reads as no automation rather than breaking the panel; + * it is left untouched until the author changes something. + */ +export function readPanelAutomation( + raw: string | undefined, + chain: HfAudioFxChain | undefined, +): HfAutomation { + if (!raw) return EMPTY; + try { + const parsed = parseAutomation(raw); + return chain ? resolveAutomation(parsed, chain) : parsed; + } catch { + return EMPTY; + } +} + +/** Targets the element currently automates. */ +export function automatedTargetsOf(automation: HfAutomation): Set { + return new Set(automation.lanes.map((lane) => lane.target)); +} + +/** + * Add a lane for `target`, seeded with a single point at `current`. + * + * One point is a constant, so switching a parameter to an envelope does not + * change the sound — it only moves where the value comes from. The author then + * shapes it in the timeline. + */ +export function withSeededLane( + automation: HfAutomation, + target: string, + current: number, +): HfAutomation { + if (automation.lanes.some((lane) => lane.target === target)) return automation; + return { version: 1, lanes: [...automation.lanes, { target, points: [{ t: 0, v: current }] }] }; +} + +/** Drop one lane, handing its value back to the panel control. */ +export function withoutLane(automation: HfAutomation, target: string): HfAutomation { + return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) }; +} + +/** The attribute value for an automation set; empty when nothing is automated. */ +export function automationAttrValue(automation: HfAutomation): string { + return automation.lanes.length > 0 ? serializeAutomation(automation) : ""; +} + +export { HF_AUDIO_AUTOMATION_ATTR, resolveAutomationRange }; diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 1850a48905..997483e136 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -12,6 +12,7 @@ import { } from "./propertyPanelHelpers"; import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives"; import { FlatToggle } from "./propertyPanelFlatToggle"; +import { AutomationToggle } from "./propertyPanelFxControls"; // fallow-ignore-next-line complexity export function FlatMediaSection({ @@ -22,6 +23,9 @@ export function FlatMediaSection({ onSetAttribute, onSetHtmlAttribute, onRemoveBackground, + volumeAutomated, + onAutomateVolume, + onRemoveVolumeAutomation, }: { projectDir: string | null; element: DomEditSelection; @@ -29,6 +33,10 @@ export function FlatMediaSection({ onSetStyle: (prop: string, value: string) => void | Promise; onSetAttribute: (attr: string, value: string) => void | Promise; onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise; + /** A volume lane in the timeline drives the level; the slider cannot. */ + volumeAutomated?: boolean; + onAutomateVolume?: () => void; + onRemoveVolumeAutomation?: () => void; onRemoveBackground?: ( inputPath: string, options: { @@ -197,15 +205,35 @@ export function FlatMediaSection({ )} {(isVideo || isAudio) && ( <> - void onSetAttribute("volume", formatNumericValue(next / 100))} - /> + {/* The slider is disabled while a lane owns the level: a value set + here would be overwritten by the envelope on the next tick. The + toggle beside it carries the tooltip. */} +
+
+ void onSetAttribute("volume", formatNumericValue(next / 100))} + /> +
+ onAutomateVolume() : undefined} + onRemoveAutomation={ + onRemoveVolumeAutomation ? () => onRemoveVolumeAutomation() : undefined + } + /> +
+ + + ); +} + +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,