From 66df209b5e6bcc26c1886aae14610d4c86ee8da3 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 10:39:21 -0700 Subject: [PATCH 1/2] feat(studio): the automation lane itself 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. Double-click the line to add a point, drag to shape it, right-click a point to remove it. Three things here took more than one attempt, and the comments say why: - **A dragged point did not move.** The live write deliberately skips the preview refresh โ€” that is what keeps dragging from restarting playback โ€” so the stored value does not move under the pointer. The lane keeps a local draft. - **Releasing snapped it back.** The draft was dropped when the drag ended, which is before the persisted write comes around; it now lives until the automation it was drawn over actually changes. - **A press was eaten.** Not stopping propagation let the timeline start its own gesture and swallow the second half of a double-click. The lane owns the press once it is live โ€” and when it is not, it selects its clip instead, since lanes sit below the clip bar where the timeline's own selection handler never sees them. The envelope is inset by the grab radius so a point at the clip's first or last frame is drawn whole rather than half outside the lane, and clip time still lines up with screen position because the inset and the offset cancel. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../TimelineAutomationLane.test.tsx | 445 +++++++++++++++++ .../components/TimelineAutomationLane.tsx | 452 ++++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 17 + 3 files changed, 914 insertions(+) create mode 100644 packages/studio/src/player/components/TimelineAutomationLane.test.tsx create mode 100644 packages/studio/src/player/components/TimelineAutomationLane.tsx 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..a905a30c49 --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -0,0 +1,445 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { TimelineAutomationLane } from "./TimelineAutomationLane"; +import { PAD_X } from "./automationLaneGeometry"; +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 = PAD_X; + +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("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..37ffb4dc23 --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -0,0 +1,452 @@ +/** + * 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 { + resolveAutomationRange, + sampleAutomationLane, + type AutomationRange, + type HfAutomation, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { + DRAW_SAMPLES, + formatValue, + fromUnit, + GRAB_PX, + laneFor, + PAD_X, + POINT_MERGE_SEC, + toUnit, + withLane, +} from "./automationLaneGeometry"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { getTimelineLaneTop } from "./timelineLayout"; +import type { TimelineElement } from "../store/playerStore"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; + +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 From ac16be625649c5879f4e4b5e58ff55530bdc9a31 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 4 Aug 2026 14:03:08 -0700 Subject: [PATCH 2/2] fix(studio): let a track disclose its automation without a tween MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane was mounted inside the property-lanes wrapper, which renders only for a track's GSAP keyframe clip โ€” so an audio clip with no tween resolved to nothing: no disclosure caret, no reserved height, no lanes. Verified on a composition with one `