feat(studio): drag automation segments (#3465)

* feat(studio): drag automation segments

* fix(studio): clear clip selection for group effects

* fix(studio): replace clip selection with audio bus

* fix(studio): make audio bus selection authoritative
This commit is contained in:
Vance Ingalls
2026-08-24 12:44:05 -07:00
committed by GitHub
parent 21dc4aed0c
commit 7caf4b8871
12 changed files with 703 additions and 74 deletions
@@ -1,4 +1,5 @@
import type { SelectElementOptions, TimelineElement } from "../player";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
import type { DomEditSelection } from "../components/editor/domEditing";
import { logSelect } from "../utils/selectDebug";
@@ -60,9 +61,17 @@ export function announceTimelineSelection(
anchor,
anchorPublished: anchor != null && publishedMembers.has(anchor),
});
// A canvas target can be editable without owning a timeline row. Preserve that
// canvas-only selection when the timeline has nothing truthful to represent.
if (!timelineAnchor) return;
// A canvas target can be editable without owning a timeline row. Most such
// targets live inside a clip and must not erase its timeline context. A mixer
// bus is different: it is itself the editing target, so its title replaces any
// selected clips even though the bus has no clip row of its own.
if (!timelineAnchor) {
if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
setTimelineSelectionSet(new Set());
setSelectedTimelineElementId(null);
}
return;
}
// A late async primary that already belongs to the live set must preserve the
// group. A fresh single click does not belong to it, so publish the singleton
// first; otherwise `preserveSet` clears the set and sync wipes the canvas.
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
});
});
describe("useDomSelection — canvas-only targets replace timeline clips", () => {
beforeEach(() => {
deferreds.clear();
usePlayerStore.getState().clearSelection();
});
afterEach(() => {
deferreds.clear();
usePlayerStore.getState().clearSelection();
});
it("deselects every clip when an audio bus is selected", () => {
const store = usePlayerStore.getState();
store.setSelectedElementId("voice-1");
store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
const bus = document.createElement("hf-audio-group");
bus.id = "voiceover";
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe: null,
timelineElements: [
{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
{ id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
expect(harness.current().domEditSelection?.id).toBe("voiceover");
expect(usePlayerStore.getState().selectedElementId).toBeNull();
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
harness.cleanup();
});
it("lets a bus supersede a clip selection that is still resolving", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument!;
const clipNode = doc.createElement("audio");
clipNode.id = "voice-1";
const busNode = doc.createElement("hf-audio-group");
busNode.id = "voiceover";
doc.body.append(clipNode, busNode);
const clip: TimelineElement = {
id: "voice-1",
domId: "voice-1",
tag: "audio",
start: 0,
duration: 1,
track: 0,
};
const bus: TimelineElement = {
id: "voiceover",
domId: "voiceover",
tag: "audio",
start: 0,
duration: 10,
track: -0.5,
};
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe,
// The bus is a synthetic row target, not a clip in the store.
timelineElements: [clip],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
let pendingClip = Promise.resolve();
let pendingBus = Promise.resolve();
act(() => {
pendingClip = harness.current().handleTimelineElementSelect(clip);
pendingBus = harness.current().handleTimelineElementSelect(bus);
});
await act(async () => {
deferreds.get("voiceover")?.resolve();
await pendingBus;
deferreds.get("voice-1")?.resolve();
await pendingClip;
});
expect(harness.current().domEditSelection?.id).toBe("voiceover");
expect(usePlayerStore.getState().selectedElementId).toBeNull();
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
harness.cleanup();
iframe.remove();
});
it("preserves clip context for a non-bus canvas-only selection", () => {
const store = usePlayerStore.getState();
store.setSelectedElementId("voice-1");
const decoration = document.createElement("div");
decoration.id = "decoration";
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe: null,
timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
harness.cleanup();
});
});
describe("useDomSelection — timeline-select race guard", () => {
beforeEach(() => deferreds.clear());
afterEach(() => deferreds.clear());
@@ -0,0 +1,54 @@
/** The base automation envelope plus the heavier segment grab affordance. */
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
import { envelopeSegmentPath } from "./automationLaneGeometry";
interface AutomationEnvelopePathsProps {
path: string;
lane: HfAutomationLane;
range: AutomationRange;
accentColor: string;
activeSegment: number | null;
xOf(t: number): number;
yOf(v: number): number;
}
export function AutomationEnvelopePaths({
path,
lane,
range,
accentColor,
activeSegment,
xOf,
yOf,
}: AutomationEnvelopePathsProps) {
const activePath =
activeSegment === null
? null
: envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
return (
<>
<path
data-automation-envelope=""
d={path}
fill="none"
stroke={accentColor}
strokeWidth={1.5}
opacity={lane.points.length === 0 ? 0.35 : 0.95}
/>
{activePath ? (
<path
data-automation-segment-active={activeSegment ?? undefined}
d={activePath}
fill="none"
stroke={accentColor}
strokeWidth={3}
strokeLinecap="round"
opacity={1}
pointerEvents="none"
/>
) : null}
</>
);
}
@@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import {
normalizeAutomation,
resolveAutomationRange,
sampleAutomationLane,
VOLUME_RANGE,
type HfAutomation,
} from "@hyperframes/core/audio-automation";
@@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
});
});
describe("TimelineAutomationLane segment drag", () => {
const four: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.8 },
{ t: 2, v: 0.6 },
{ t: 3.5, v: 0.2 },
],
},
],
};
const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
t: number;
v: number;
viaX?: number;
viaY?: number;
}[];
it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
const { container, svg } = mount(ramp);
const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
expect(envelope?.getAttribute("stroke-width")).toBe("1.5");
fire(svg, "pointermove", at(2, 0.5));
const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
expect(active).not.toBeNull();
expect(active?.getAttribute("stroke-width")).toBe("3");
expect(svg.style.cursor).toBe("grab");
// Same time span, but far enough above the drawn ramp to be background.
fire(svg, "pointermove", at(2, 0.9));
expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
expect(svg.style.cursor).toBe("crosshair");
});
it("moves both segment endpoints by the same time and value delta", () => {
const { svg, props } = mount(four);
// Midpoint of the segment from (1, .8) to (2, .6).
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });
const points = previewedPoints(props);
expect(points[0]).toEqual({ t: 0, v: 1 });
expect(points[1]!.t).toBeCloseTo(1.5, 2);
expect(points[2]!.t).toBeCloseTo(2.5, 2);
expect(points[1]!.v).toBeCloseTo(0.6, 2);
expect(points[2]!.v).toBeCloseTo(0.4, 2);
expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
});
it("treats a press outside the line's proximity as a background range drag", () => {
const onRangeSelect = vi.fn();
const { svg, props } = mount(four, { onRangeSelect });
fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
expect(onRangeSelect).toHaveBeenCalled();
expect(props.onPreview).not.toHaveBeenCalled();
});
it("preserves the segment's curve while translating its endpoints", () => {
const curved: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
{ t: 2, v: 0.6 },
{ t: 3.5, v: 0.2 },
],
},
],
};
const { svg, props } = mount(curved);
const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
const points = previewedPoints(props);
expect(points[1]?.viaX).toBe(0.4);
expect(points[1]?.viaY).toBe(0.7);
});
it("stops both endpoints together before the next breakpoint", () => {
const { svg, props } = mount(four);
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
const points = previewedPoints(props);
expect(points[2]!.t).toBeLessThan(points[3]!.t);
expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
});
it("previews every move and persists the segment once on release", () => {
const { svg, props } = mount(four);
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
for (const t of [1.7, 1.9, 2.1]) {
fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
}
expect(props.onPreview).toHaveBeenCalledTimes(3);
expect(props.onCommit).not.toHaveBeenCalled();
fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
expect(props.onCommit).toHaveBeenCalledTimes(1);
});
});
/** `mount`, plus the re-render a real store update causes the persisted
* automation and the new selection coming back down as props. */
const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
@@ -1,21 +1,15 @@
/**
* 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 or
* Shift+click a point to remove it, Alt-drag the line between two points to bend
* it, and double-click a point to type an exact value.
* Shift+click a point to remove it, drag a line segment to move both endpoints,
* Alt-drag the line to bend it, and double-click a point to type an exact value.
*
* Modifiers follow Ableton's, because that is the muscle memory an automation
* lane inherits: Shift locks a drag to one axis and fines the value down, Alt
* over a segment curves it, and Alt during a point drag ignores the grid.
* Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a
* segment or ignores the grid during a point drag. Background drags select a
* set that can be moved, deleted, or shaped together.
*
* Drag the background to draw a selection box around a set of breakpoints, then
* Delete to remove them, drag any one of them to move the whole set, or
* right-click inside the box for shapes over its span.
*
* 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.
* Effect parameters, ranges, units, and scaling come from the FX registry, so
* an upstream effect needs no lane-specific code here.
*/
import {
@@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes";
import { simplifyPoints } from "./automationSimplify";
import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
import { defaultTimelineTheme } from "./timelineTheme";
import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths";
/**
* Drawn radius of a breakpoint.
@@ -102,7 +97,7 @@ function pointCircleStyle(
function laneTitle(readOnly: boolean | undefined): string {
return readOnly
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag a line segment to move both endpoints. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
}
/**
@@ -155,13 +150,19 @@ function pointHandleOpacity(args: {
return !args.readOnly && args.hovered ? 1 : 0;
}
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
function laneCursor(
readOnly: boolean | undefined,
dragging: boolean,
stretching: boolean,
segmentHovering: boolean,
): string {
// A stretch handle wins over everything it might also sit above: the handle is
// a few px wide and always overlaps whatever is under the selection edge, so
// any other cursor there would advertise a gesture the press will not start.
if (stretching) return "col-resize";
if (readOnly) return "pointer";
return dragging ? "grabbing" : "crosshair";
if (dragging) return "grabbing";
return segmentHovering ? "grab" : "crosshair";
}
export interface TimelineAutomationLaneProps {
@@ -318,7 +319,16 @@ export function TimelineAutomationLane({
duration,
rangeSelection,
});
const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;
const {
dragIndex,
curveIndex,
segmentDragIndex,
segmentHoverIndex,
edgeDrag,
edgeHover,
hint,
editing,
} = gestures;
const removeAt = useCallback(
(index: number): void => {
@@ -426,8 +436,9 @@ export function TimelineAutomationLane({
height: h,
cursor: laneCursor(
readOnly,
dragIndex !== null || curveIndex !== null,
dragIndex !== null || curveIndex !== null || segmentDragIndex !== null,
edgeDrag !== null || edgeHover,
segmentHoverIndex !== null,
),
opacity: readOnly ? 0.55 : 1,
touchAction: "none",
@@ -435,7 +446,10 @@ export function TimelineAutomationLane({
width={widthPx + PAD_X * 2}
height={h}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onPointerLeave={() => {
setHovered(false);
gestures.onPointerLeave();
}}
onPointerDown={gestures.onPointerDown}
onPointerMove={gestures.onPointerMove}
onPointerUp={gestures.endDrag}
@@ -478,12 +492,14 @@ export function TimelineAutomationLane({
pointerEvents="none"
/>
) : null}
<path
d={path}
fill="none"
stroke={accentColor}
strokeWidth={1.5}
opacity={lane.points.length === 0 ? 0.35 : 0.95}
<AutomationEnvelopePaths
path={path}
lane={lane}
range={range}
accentColor={accentColor}
activeSegment={segmentDragIndex ?? segmentHoverIndex}
xOf={xOf}
yOf={yOf}
/>
{lane.points.map((p, i) => {
// Endpoint-inclusive, the same rule Delete uses, so what looks caught by
@@ -25,6 +25,7 @@ export function TimelineGroupLaneLabels({
columnWidth,
gutterBackground,
accentColor,
onReveal,
}: {
/** The group wearing a clip's shape — see `groupAutomationElement`. */
groupElement: TimelineElement;
@@ -34,6 +35,8 @@ export function TimelineGroupLaneLabels({
columnWidth: number;
gutterBackground: string;
accentColor: string;
/** Select the bus and reveal this lane's exact parameter in its rack. */
onReveal?: (target: string) => void;
}) {
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
// on seek, so the readout sat frozen while the curve was audibly working,
@@ -55,10 +58,13 @@ export function TimelineGroupLaneLabels({
// clip-local rebase here — unlike a clip's lane.
const value = sampleAutomationLane(lane, currentTime);
return (
<div
<button
type="button"
tabIndex={-1}
key={lane.target}
data-group-lane-label={lane.target}
className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
style={{
top: top + index * AUTOMATION_LANE_H,
width: columnWidth,
@@ -67,6 +73,11 @@ export function TimelineGroupLaneLabels({
borderLeft: `2px solid ${accentColor}`,
}}
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onReveal?.(lane.target);
}}
>
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
@@ -80,7 +91,7 @@ export function TimelineGroupLaneLabels({
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
{value.toFixed(2)}
</span>
</div>
</button>
);
})}
</>
@@ -6,14 +6,23 @@ import { TimelineGroupRow } from "./TimelineGroupRow";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true }));
const domEditMocks = vi.hoisted(() => ({
handleTimelineElementSelect: vi.fn(async () => undefined),
}));
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditSelectionContextOptional: () => null,
useDomEditActionsContextOptional: () => domEditMocks,
}));
afterEach(() => {
document.body.innerHTML = "";
domEditMocks.handleTimelineElementSelect.mockClear();
usePlayerStore.setState({ revealedAudioFxTarget: null });
});
const member = (id: string, track: number): TimelineElement => ({
@@ -36,7 +45,10 @@ const GROUP: TimelineTrackGroupInfo = {
hidden: false,
};
function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
function renderRow(
overrides: Partial<TimelineTrackGroupInfo> = {},
expandedLaneOwnerIds = new Set<string>(),
) {
const onSetAudioGroupAttributeQuiet = vi.fn();
const onSetElementAttributeQuiet = vi.fn();
const host = document.createElement("div");
@@ -55,10 +67,31 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
contentOrigin={232}
theme={defaultTimelineTheme}
collapsedGroupIds={new Set()}
expandedLaneOwnerIds={new Set()}
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={vi.fn()}
toggleLaneOwnerExpanded={vi.fn()}
lanes={{ bind: () => ({ lanes: [] }) } as never}
lanes={
{
bind: (element: TimelineElement) => {
const automation = element.automation
? JSON.parse(element.automation)
: { version: 1, lanes: [] };
return {
automation,
lanes: automation.lanes,
chain: element.fxChain ? JSON.parse(element.fxChain) : null,
onPreview: vi.fn(),
onCommit: vi.fn(),
onSelect: vi.fn(),
readOnly: true,
commitTargetKey: null,
selection: null,
onRangeSelect: vi.fn(),
onRangeClear: vi.fn(),
};
},
} as never
}
pps={10}
currentTime={0}
compositionDuration={60}
@@ -72,6 +105,49 @@ function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
}
describe("TimelineGroupRow", () => {
it("routes the group title through the guarded selection path", () => {
const { host } = renderRow();
const title = host.querySelector<HTMLButtonElement>(
'button[aria-label="Open Voiceover effects"]',
);
act(() => title?.click());
expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
);
});
it("opens a group automation lane on its exact rack parameter", async () => {
const { host } = renderRow(
{
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
}),
automation: JSON.stringify({
version: 1,
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
}),
},
new Set(["voiceover"]),
);
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
await act(async () => {
laneTitle?.click();
await Promise.resolve();
});
expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: "voiceover", domId: "voiceover" }),
);
expect(usePlayerStore.getState().revealedAudioFxTarget).toMatchObject({
elementKey: "voiceover",
automationTarget: "fx.p1.gain",
});
});
// C1 names this as the step's own definition of done: "opening the popover on
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
// on the group element, and zero writes on members". A group IS a bus — a
@@ -17,6 +17,7 @@ import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../store/playerStore";
/** Accent rail on a group-owned lane the same green the member rail uses, so
* "this belongs to the group" reads the same in both places (groups doc §5). */
@@ -92,19 +93,27 @@ export function TimelineGroupRow({
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
};
const openGroupFxRack = () => {
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
group.id,
const openGroupFxRack = (automationTarget?: string) => {
// Use the guarded timeline-selection path even though the bus is synthetic:
// it invalidates an older clip selection that may still be resolving. The
// old direct build/apply path let that late clip reclaim the rack.
const selection = domEditActions?.handleTimelineElementSelect(groupElement);
if (!selection || !automationTarget) return;
// Bus selection clears the clip selection, which intentionally retires any
// old reveal request. Publish this one afterwards so the newly mounted bus
// rack can consume it rather than losing it during that clear.
void selection.then(() =>
revealAudioFx({
elementKey: group.id,
automationTarget,
}),
);
if (!target) return;
void domEditActions
?.buildDomSelectionFromTarget(target)
.then((selection) => selection && domEditActions.applyDomSelection(selection));
};
return (
<TimelineTrackRow
@@ -150,7 +159,7 @@ export function TimelineGroupRow({
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onOpenFxRack={openGroupFxRack}
onOpenFxRack={() => openGroupFxRack()}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
// timeline (see Timeline.tsx) rather than by overhanging alone — an
@@ -172,6 +181,7 @@ export function TimelineGroupRow({
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
onReveal={openGroupFxRack}
/>
)}
</div>
@@ -59,6 +59,34 @@ export function armGroupDrag(
};
}
/**
* Snapshot the two endpoints owned by a segment drag.
*
* The pointer itself is the anchor: a line can be grabbed anywhere along its
* span, and the segment must not jump so either endpoint takes the pointer's
* place when the gesture starts.
*/
export function armSegmentDrag(
lane: HfAutomationLane,
index: number,
anchor: { t: number; v: number },
): GroupDragSnapshot | null {
const a = lane.points[index];
const b = lane.points[index + 1];
if (!a || !b) return null;
return {
points: lane.points.map((p) => ({ ...p })),
indices: [index, index + 1],
anchor: { ...anchor },
selection: {
t0: a.t,
t1: b.t,
v0: Math.min(a.v, b.v),
v1: Math.max(a.v, b.v),
},
};
}
export interface GroupMoveResult {
points: HfAutomationLane["points"];
selection: AutomationSelectionBox;
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
export const MIN_POINT_GAP_SEC = 0.001;
/** Hit radius for grabbing a point, in px. */
export const GRAB_PX = 7;
/** Distance from the drawn envelope that offers a segment drag, in px. */
export const SEGMENT_GRAB_PX = 5;
/** Samples used to draw a segment the eye should see as curved. */
export const DRAW_SAMPLES = 64;
/**
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
return best;
}
/** Draw commands from one breakpoint to the next, sampled when it is curved. */
function segmentLineCommands(input: {
lane: HfAutomationLane;
range: AutomationRange;
index: number;
xOf(t: number): number;
yOf(v: number): number;
}): string[] {
const { lane, range, index, xOf, yOf } = input;
const a = lane.points[index];
const b = lane.points[index + 1];
if (!a || !b) return [];
// A via point bends the segment with no `curve` of its own, so the
// straight-line shortcut has to rule out both.
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
return [`L ${xOf(b.t)} ${yOf(b.v)}`];
}
return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
});
}
/**
* The svg path for one lane's envelope.
*
@@ -247,24 +272,38 @@ export function envelopePath(input: {
}
const pts = [`M ${PAD_X} ${yOf(first.v)}`, `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 || !b) continue;
// A via point bends the segment with no `curve` of its own, so the
// straight-line shortcut has to rule out both.
if (!a.curve && a.viaX === undefined && range.scale === "linear") {
pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
continue;
}
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))}`);
}
pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
}
pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
return pts.join(" ");
}
/**
* The visible path for one segment, without the lane's constant extensions.
*
* Used for the hover/drag affordance: only the segment under the pointer grows
* heavier, rather than making the entire envelope look selected. It samples by
* the same rule as `envelopePath`, so a curved or logarithmic segment's hover
* stroke sits exactly on the line the audio model draws.
*/
export function envelopeSegmentPath(input: {
lane: HfAutomationLane;
range: AutomationRange;
index: number;
xOf(t: number): number;
yOf(v: number): number;
}): string | null {
const { lane, range, index, xOf, yOf } = input;
const a = lane.points[index];
const b = lane.points[index + 1];
if (!a || !b) return null;
const pts = [
`M ${xOf(a.t)} ${yOf(a.v)}`,
...segmentLineCommands({ lane, range, index, xOf, yOf }),
];
return pts.join(" ");
}
export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
}
@@ -25,6 +25,7 @@ import {
type GroupDragSnapshot,
type ShiftAxis,
} from "./automationLaneDragMath";
import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
/** How far a press may travel and still count as a click rather than a drag. */
const CLICK_SLOP_PX = 3;
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
dragIndex: number | null;
/** Segment being bent, identified by the point that owns its curve. */
curveIndex: number | null;
/** Segment whose two endpoints are being translated together. */
segmentDragIndex: number | null;
/** Segment close enough to the pointer to offer that translation. */
segmentHoverIndex: number | null;
/** Value readout to show while a gesture is live. */
hint: string | null;
hitIndex(clientX: number, clientY: number): number | null;
segmentIndex(clientX: number, clientY: number): number | null;
/** Clear hover feedback when the pointer leaves the lane. */
onPointerLeave(): void;
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
/** Edge being stretched, for the cursor. Null when no stretch is live. */
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
[lane, pointAt],
);
/** What a press starts: moving a point, or — with Alt on the line — bending it. */
const segmentDrag = useAutomationSegmentDrag({
getBox,
lane,
range,
pointAt,
xOf,
yOf,
segmentIndex,
commitPoints,
duration,
snapTimes,
readOnly,
onHint: setHint,
});
/** What a press starts: point move, segment move, or Alt segment bend. */
const gestureAt = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): { curve: boolean; index: number } | null => {
(
e: ReactPointerEvent<SVGSVGElement>,
): { kind: "point" | "segment" | "curve"; index: number } | null => {
const index = hitIndex(e.clientX, e.clientY);
if (index !== null) return { curve: false, index };
if (!e.altKey) return null;
const segment = segmentIndex(e.clientX, e.clientY);
return segment === null ? null : { curve: true, index: segment };
if (index !== null) return { kind: "point", index };
// Alt is an explicit bend gesture and retains its span-wide hit target.
// The unmodified translation is offered only close to the drawn line.
const segment = e.altKey
? segmentIndex(e.clientX, e.clientY)
: segmentDrag.hitIndex(e.clientX, e.clientY);
if (segment === null) return null;
return { kind: e.altKey ? "curve" : "segment", index: segment };
},
[hitIndex, segmentIndex],
[hitIndex, segmentDrag, segmentIndex],
);
const onPointerDown = useCallback(
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
}
e.preventDefault();
capturePointer(e);
if (gesture.curve) {
segmentDrag.clearHover();
if (gesture.kind === "curve") {
setCurveIndex(gesture.index);
return;
}
if (gesture.kind === "segment") {
segmentDrag.arm(gesture.index, e.clientX, e.clientY);
return;
}
dragOrigin.current = originOf(lane.points[gesture.index]);
// Pressing one of a selected set drags the whole set. Pressing a point
// outside the selection is an ordinary single-point drag, selection or no.
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
// selecting a range read the previous selection, or none.
rangeSelection,
stretch,
segmentDrag,
],
);
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
[dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
);
/** Route a live point, bend, or segment gesture after global gestures stand down. */
const moveLiveGesture = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
const from = pressAt.current;
if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
pressTravelled.current = true;
}
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
else movePoint(e);
},
[bendSegment, curveIndex, movePoint, segmentDrag],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
if (stretch.edge !== null) {
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
rangeDrag.move(e);
return;
}
if (curveIndex === null && dragIndex === null) {
if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
stretch.updateHover(e);
segmentDrag.updateHover(e.clientX, e.clientY);
return;
}
e.stopPropagation();
const from = pressAt.current;
if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
pressTravelled.current = true;
}
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
else movePoint(e);
moveLiveGesture(e);
},
[rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
[rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
);
const endDrag = useCallback(
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
rangeDrag.finish();
return;
}
if (dragIndex === null && curveIndex === null) return;
if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
e.stopPropagation();
const index = dragIndex;
const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
setDragIndex(null);
setCurveIndex(null);
segmentDrag.finish();
dragOrigin.current = null;
groupDrag.current = null;
shiftAxis.current = null;
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
}
commitPoints(lane.points, true);
},
[rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
[rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
);
/**
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
return {
dragIndex,
curveIndex,
segmentDragIndex: segmentDrag.dragIndex,
segmentHoverIndex: segmentDrag.hoverIndex,
edgeDrag: stretch.edge,
edgeHover: stretch.hover,
cancelDrag,
hint,
hitIndex,
segmentIndex,
onPointerLeave: segmentDrag.clearHover,
onPointerDown,
onPointerMove,
endDrag,
@@ -0,0 +1,110 @@
/**
* The grab target and translation gesture for one automation segment.
*
* Kept separate from the lane's gesture router: proximity is measured against
* the sampled envelope, while movement reuses the same shape-preserving group
* math as a box selection.
*/
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
sampleAutomationLane,
type AutomationRange,
type HfAutomationLane,
} from "@hyperframes/core/audio-automation";
import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
interface UseAutomationSegmentDragInput {
getBox(): DOMRect | null;
lane: HfAutomationLane;
range: AutomationRange;
pointAt(clientX: number, clientY: number): { t: number; v: number };
xOf(t: number): number;
yOf(v: number): number;
segmentIndex(clientX: number, clientY: number): number | null;
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
duration: number;
snapTimes: readonly number[] | undefined;
readOnly: boolean | undefined;
onHint(hint: string | null): void;
}
export function useAutomationSegmentDrag({
getBox,
lane,
range,
pointAt,
xOf,
yOf,
segmentIndex,
commitPoints,
duration,
snapTimes,
readOnly,
onHint,
}: UseAutomationSegmentDragInput) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const snapshot = useRef<GroupDragSnapshot | null>(null);
/** Segment whose drawn line is close enough to grab, or null. */
const hitIndex = useCallback(
(clientX: number, clientY: number): number | null => {
const box = getBox();
if (!box) return null;
const index = segmentIndex(clientX, clientY);
if (index === null) return null;
const { t } = pointAt(clientX, clientY);
const value = sampleAutomationLane(lane, t, range.scale);
return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
},
[getBox, lane, pointAt, range.scale, segmentIndex, yOf],
);
const arm = useCallback(
(index: number, clientX: number, clientY: number): void => {
snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
setHoverIndex(null);
setDragIndex(index);
},
[lane, pointAt],
);
const move = useCallback(
(e: ReactPointerEvent<SVGSVGElement>): void => {
const group = snapshot.current;
if (!group) return;
const moved = computeGroupMove({
group,
raw: pointAt(e.clientX, e.clientY),
shiftKey: e.shiftKey,
altKey: e.altKey,
range,
duration,
snapTimes,
xOf,
yOf,
});
onHint(moved.hint);
commitPoints(moved.points, false);
},
[commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
);
const finish = useCallback((): void => {
snapshot.current = null;
setDragIndex(null);
}, []);
const updateHover = useCallback(
(clientX: number, clientY: number): void => {
setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
},
[hitIndex, readOnly],
);
const clearHover = useCallback((): void => setHoverIndex(null), []);
return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
}