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..d354d08a12 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/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index f3cc603ffb..f1f235dc3a 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons"; import type { TimelineElement } from "../store/playerStore"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { automationLaneCountOf } from "./useTimelineTrackLayout"; import { clipTimingStart } from "../../hooks/gsapShared"; import { LayerDisclosureRow } from "./LayerDisclosureRow"; import { TrackClipCount } from "./TrackClipCount"; @@ -310,7 +311,11 @@ export function TimelineTrackHeader({ // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx // owns the gutter past it, so a 0% diamond isn't clipped by this panel). const showTrackLabel = contentOrigin >= LABEL_COL_W; - const isKeyframeLayer = !!keyframeClip && lanes.length > 0; + // Automation counts as something to disclose: gating the caret on tweens alone + // left an audio clip's envelopes unreachable, since the track could not expand. + const disclosable = + lanes.length > 0 || (keyframeClip ? automationLaneCountOf(keyframeClip) : 0) > 0; + const isKeyframeLayer = !!keyframeClip && disclosable; return (
- {!keyframeClip || lanes.length === 0 ? ( + {!keyframeClip || !disclosable ? ( { unmount(); }); }); +const audioClip = (id: string, over: Partial = {}): TimelineElement => ({ + id, + key: id, + tag: "audio", + start: 0, + duration: 10, + track: 10, + ...over, +}); + +describe("resolveTrackKeyframeClip", () => { + const none = new Map(); + + it("picks an audio clip that has only automation, no tweens", () => { + // Gating on tweens alone left an audio clip's envelopes unreachable: no + // clip resolved, so the track got no caret, no height and no lanes. + const bgm = audioClip("bgm"); + const picked = resolveTrackKeyframeClip([bgm], none, null, new Set(), () => 1); + expect(picked).toBe(bgm); + }); + + it("still resolves nothing when a clip has neither", () => { + expect(resolveTrackKeyframeClip([audioClip("bgm")], none, null, new Set(), () => 0)).toBeNull(); + }); + + it("prefers the selected clip over the one with more to show", () => { + const a = audioClip("a"); + const b = audioClip("b"); + const picked = resolveTrackKeyframeClip([a, b], new Map([["b", 4]]), "a", new Set(), (e) => + e.id === "a" ? 1 : 0, + ); + expect(picked).toBe(a); + }); + + it("counts tweens and automation together when breaking a tie", () => { + const a = audioClip("a"); + const b = audioClip("b"); + const picked = resolveTrackKeyframeClip( + [a, b], + new Map([ + ["a", 1], + ["b", 1], + ]), + null, + new Set(), + (e) => (e.id === "b" ? 3 : 0), + ); + expect(picked).toBe(b); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index bfa1170ea9..990884ee72 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -16,6 +16,17 @@ import { export { getTrackStyle } from "./timelineIcons"; +/** + * Automation lanes on one clip, or 0 for anything that is not audio. + * + * An audio clip can be worth expanding without carrying a single tween, so this + * counts toward whether a track has anything to disclose. A function rather than + * a map so every caller reads the same cached parse and none can drift. + */ +export function automationLaneCountOf(element: TimelineElement): number { + return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0; +} + /** * The single keyframed element whose property lanes a track shows when expanded. * A track can hold several elements (same z-index is common), but keyframes are @@ -29,10 +40,15 @@ export function resolveTrackKeyframeClip( laneCounts: ReadonlyMap, selectedElementId: string | null, selectedElementIds: ReadonlySet, + automationLaneCount: (element: TimelineElement) => number = automationLaneCountOf, ): TimelineElement | null { - const keyframed = elements.filter( - (element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1, - ); + // Automation counts toward "has something to disclose". Without it an audio + // clip carrying envelopes but no tweens resolved to null, so its track got no + // caret, no reserved height and no lanes — the automation was unreachable for + // exactly the tracks the feature is for. + const disclosable = (element: TimelineElement): number => + (laneCounts.get(element.key ?? element.id) ?? 0) + automationLaneCount(element); + const keyframed = elements.filter((element) => disclosable(element) >= 1); if (keyframed.length === 0) return null; const selected = keyframed.find((element) => { const key = element.key ?? element.id; @@ -41,8 +57,9 @@ export function resolveTrackKeyframeClip( if (selected) return selected; // Most lanes wins, first one on a tie (same as the old stable sort), but as a // reduce over the already non-empty list so there's no index to assert on. - const lanesOf = (element: TimelineElement) => laneCounts.get(element.key ?? element.id) ?? 0; - return keyframed.reduce((best, element) => (lanesOf(element) > lanesOf(best) ? element : best)); + return keyframed.reduce((best, element) => + disclosable(element) > disclosable(best) ? element : best, + ); } /** Lanes per clip: the count of distinct property groups whose tween contributes @@ -91,9 +108,7 @@ function useTimelineRowHeights( { clipId, laneCount: laneCounts.get(clipId) ?? 0, - automationLaneCount: isAudioTimelineElement(active) - ? elementAutomationLanes(active).length - : 0, + automationLaneCount: automationLaneCountOf(active), }, ]; });