Skip to content

Commit 60a3d69

Browse files
vanceingallsclaude
andcommitted
feat(studio): select automation points with a box, and stop them crossing
Replaces the time-range selection with a rectangle. A lane selection is a set of breakpoints, not a span, so it now has value bounds as well as time bounds and a point is caught only if it falls inside both — which is what lets you take the peaks of an envelope and leave the dips between them. Delete, the group drag and the rings drawn on caught points all read the one rule, so what looks selected is exactly what those act on. Copy, paste, shape insert and simplify still work on the box's time span, because they act on the envelope over a stretch of time. Dragging is bounded by its neighbours in both the single and group cases. A point cannot cross another, and cannot land exactly on one either: the lane collapses points that share a `t`, keeping the later one, so arriving on top of a neighbour deleted it. It stops a millisecond short, which is under a pixel at any zoom the lane offers and keeps both points. Only stationary neighbours constrain a group, per member rather than per end, since a box can select a non-contiguous set. Edge-stretch is removed rather than fixed. Dragging a selection's edges to retime the points inside it was the feature this branch opened for, and it is not wanted: the hook, retimeRange, the edge handles, the col-resize cursor and the pointercancel revert path all go, along with the ~360 lines of tests that pinned them. Also: gesture-scoped coalescing keys, so one drag is one undo entry rather than a fragmented chain of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8e4f51c commit 60a3d69

24 files changed

Lines changed: 2036 additions & 1031 deletions

packages/studio/src/player/components/TimelineAutomationLane.test.tsx

Lines changed: 650 additions & 360 deletions
Large diffs are not rendered by default.

packages/studio/src/player/components/TimelineAutomationLane.tsx

Lines changed: 147 additions & 86 deletions
Large diffs are not rendered by default.

packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ function mountSlot(binding: Partial<AutomationLaneBinding>) {
5656
describe("TimelineAutomationLaneSlot stale-selection guard", () => {
5757
it("clears the selection when its lane's target no longer exists", () => {
5858
const { onRangeClear } = mountSlot({
59-
selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2 },
59+
selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2, v0: 0, v1: 1 },
6060
});
6161
expect(onRangeClear).toHaveBeenCalledTimes(1);
6262
});
6363

6464
it("leaves an in-scope selection alone", () => {
6565
const { onRangeClear } = mountSlot({
66-
selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2 },
66+
selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2, v0: 0, v1: 1 },
6767
});
6868
expect(onRangeClear).not.toHaveBeenCalled();
6969
});

packages/studio/src/player/components/TimelineLanes.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,26 @@ export function TimelineLanes({
173173
// on the canvas. Keyed by display row, not by `trackNum`, which is a
174174
// fractional sort key and would mint ids like `...-0.16666666666666666`.
175175
const lanesId = `${lanesIdPrefix}-track-${row}`;
176+
// The header's remove buttons write through the same binding the lanes
177+
// themselves edit through, so a deletion persists exactly like dragging
178+
// a point does — and the binding reports read-only for an unselected
179+
// clip, which is what leaves the buttons off rather than offering one
180+
// that cannot act.
181+
const headerLanes =
182+
keyframeClip && keyframeClipKey
183+
? automationLanes.bind(
184+
keyframeClip,
185+
selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey),
186+
)
187+
: null;
188+
const removeAutomationLane =
189+
headerLanes && !headerLanes.readOnly
190+
? (target: string) =>
191+
headerLanes.onCommit({
192+
version: 1,
193+
lanes: headerLanes.lanes.filter((lane) => lane.target !== target),
194+
})
195+
: undefined;
176196
return (
177197
<TimelineTrackRow
178198
key={rowKey}
@@ -213,6 +233,7 @@ export function TimelineLanes({
213233
}}
214234
onToggleTrackHidden={onToggleTrackHidden}
215235
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
236+
onRemoveAutomationLane={removeAutomationLane}
216237
onSeek={onSeek}
217238
/>
218239
<div

packages/studio/src/player/components/TimelineTrackHeader.test.tsx

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader";
99
import { defaultTimelineTheme } from "./timelineTheme";
1010
import type { TimelineElement } from "../store/playerStore";
1111
import type { TimelineEditCallbacks } from "./timelineCallbacks";
12-
import { LABEL_COL_W } from "./timelineLayout";
12+
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
13+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
1314

1415
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1516

@@ -68,6 +69,7 @@ interface RenderHeaderOptions {
6869
onSeek?: (time: number) => void;
6970
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
7071
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
72+
onRemoveAutomationLane?: (target: string) => void;
7173
}
7274

7375
function renderHeader(options: RenderHeaderOptions = {}): {
@@ -100,6 +102,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
100102
onToggleClipExpanded={vi.fn()}
101103
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
102104
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
105+
onRemoveAutomationLane={next.onRemoveAutomationLane}
103106
onSeek={next.onSeek}
104107
/>,
105108
);
@@ -413,4 +416,115 @@ describe("TimelineTrackHeader", () => {
413416
assertAligned([POSITION, OPACITY]);
414417
act(() => view.root.unmount());
415418
});
419+
420+
/**
421+
* Automation lanes are named in the label column, on the same tree as the
422+
* keyframe rows — not painted inside the lane, where the name sat on top of
423+
* the envelope it belonged to and scrolled away from its own row.
424+
*/
425+
describe("audio automation rows", () => {
426+
const BED: TimelineElement = {
427+
id: "bed",
428+
label: "Music Bed",
429+
tag: "audio",
430+
start: 0,
431+
duration: 10,
432+
track: 0,
433+
fxChain: JSON.stringify({
434+
version: 1,
435+
nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }],
436+
}),
437+
automation: JSON.stringify({
438+
version: 1,
439+
lanes: [
440+
{
441+
target: "volume",
442+
points: [
443+
{ t: 0, v: 1 },
444+
{ t: 5, v: 0.4 },
445+
],
446+
},
447+
{
448+
target: "fx.n1.gain",
449+
points: [
450+
{ t: 0, v: 0 },
451+
{ t: 5, v: -6 },
452+
],
453+
},
454+
],
455+
}),
456+
} as TimelineElement;
457+
458+
it("names every envelope in the label column", () => {
459+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
460+
const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
461+
expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([
462+
"fx.n1.gain",
463+
"volume",
464+
]);
465+
// A band is named by its frequency: with several of them, "Peaking EQ" says
466+
// nothing about which is which. Bands sit above the level lanes.
467+
// Two lines per row: what the effect is, then which knob the envelope
468+
// drives. One line truncated mid-word in a column this narrow.
469+
expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([
470+
"Peaking EQ 1.6 kHz",
471+
"Volume",
472+
]);
473+
expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual(
474+
[
475+
"Gain",
476+
// Volume has no effect behind it, so it has no second line at all.
477+
undefined,
478+
],
479+
);
480+
act(() => root.unmount());
481+
});
482+
483+
it("hides them when the track is collapsed", () => {
484+
const { host, root } = renderHeader({
485+
keyframeClip: BED,
486+
animations: [],
487+
expanded: false,
488+
});
489+
expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0);
490+
act(() => root.unmount());
491+
});
492+
493+
it("removes just that envelope from the label column", () => {
494+
// The panel's automate toggle can only reach a parameter it still shows; a
495+
// carve's own lanes are not in it at all, so without this an envelope could
496+
// be created and never deleted.
497+
const onRemoveAutomationLane = vi.fn();
498+
const { host, root } = renderHeader({
499+
keyframeClip: BED,
500+
animations: [],
501+
onRemoveAutomationLane,
502+
});
503+
const button = host.querySelector<HTMLButtonElement>(
504+
'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]',
505+
);
506+
expect(button).not.toBeNull();
507+
act(() => button?.click());
508+
expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
509+
act(() => root.unmount());
510+
});
511+
512+
it("offers no remove button when the lanes are read-only", () => {
513+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
514+
expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0);
515+
act(() => root.unmount());
516+
});
517+
518+
it("stacks each envelope's row where its lane is drawn", () => {
519+
// Same rhythm the canvas uses: automation begins below the keyframe lanes
520+
// and steps by its own taller row height.
521+
const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] });
522+
const tops = Array.from(
523+
host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"),
524+
).map((r) => r.style.top);
525+
const base = getTimelineLaneTop(1);
526+
expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]);
527+
act(() => root.unmount());
528+
});
529+
});
416530
});

packages/studio/src/player/components/TimelineTrackHeader.tsx

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import type { TimelineElement } from "../store/playerStore";
55
import type { TimelineEditCallbacks } from "./timelineCallbacks";
66
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
77
import { automationLaneCountOf } from "./useTimelineTrackLayout";
8+
import {
9+
automationLaneLabel,
10+
automationLaneLabelParts,
11+
elementAutomationLanes,
12+
elementFxChain,
13+
} from "./automationLaneData";
14+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
815
import { clipTimingStart } from "../../hooks/gsapShared";
916
import { LayerDisclosureRow } from "./LayerDisclosureRow";
1017
import { TrackClipCount } from "./TrackClipCount";
@@ -46,6 +53,9 @@ interface TimelineTrackHeaderProps {
4653
onToggleClipExpanded: () => void;
4754
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
4855
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
56+
/** Drop one envelope. Absent while the lanes are read-only, which is what
57+
* hides the control rather than offering a button that cannot act. */
58+
onRemoveAutomationLane?: (target: string) => void;
4959
onSeek?: (time: number) => void;
5060
}
5161

@@ -281,6 +291,102 @@ function PropertyGroupHeaderRow({
281291
);
282292
}
283293

294+
/**
295+
* One envelope's row in the label column.
296+
*
297+
* Named here rather than inside the lane, on the same tree connector the
298+
* keyframe rows use: an automation lane is a child of its clip exactly as a
299+
* property group is, and drawing its name over the envelope put the label on top
300+
* of the curve it describes and scrolled it away from its own row.
301+
*/
302+
function AutomationLaneHeaderRow({
303+
target,
304+
label,
305+
name,
306+
param,
307+
top,
308+
isLastLane,
309+
gutterBackground,
310+
columnWidth,
311+
onRemove,
312+
}: {
313+
target: string;
314+
/** The whole thing on one line, for the tooltip and the remove button's name. */
315+
label: string;
316+
/** What the effect is — "Peaking EQ 1.6 kHz". */
317+
name: string;
318+
/** Which knob the envelope drives. Empty when there is no second line to draw. */
319+
param: string;
320+
top: number;
321+
isLastLane: boolean;
322+
gutterBackground: string;
323+
columnWidth: number;
324+
onRemove?: (target: string) => void;
325+
}) {
326+
return (
327+
<div
328+
data-automation-lane-label={target}
329+
data-timeline-lane-top={top}
330+
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
331+
style={{
332+
top,
333+
width: columnWidth,
334+
height: AUTOMATION_LANE_H,
335+
background: gutterBackground,
336+
}}
337+
>
338+
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
339+
tick at the name's own height. */}
340+
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
341+
<span
342+
className="absolute left-1.5 top-0 w-px bg-white/15"
343+
style={{ height: isLastLane ? "50%" : "100%" }}
344+
/>
345+
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
346+
</span>
347+
{/* Two lines: what the effect is, then which knob the envelope drives. On
348+
one line a band's own name was the first thing truncated in a column this
349+
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
350+
bands apart. */}
351+
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
352+
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
353+
{name}
354+
</span>
355+
{param ? (
356+
<span
357+
data-automation-lane-param=""
358+
className="truncate font-mono text-[9px] text-white/40"
359+
>
360+
{param}
361+
</span>
362+
) : null}
363+
</span>
364+
{/* Beside the name it labels, because that is the only place an envelope is
365+
named at all: a carve writes its own lanes, and the FX panel's automate
366+
toggle can only reach a parameter it still lists — so without this an
367+
envelope could be created and never removed. */}
368+
{onRemove && (
369+
<button
370+
type="button"
371+
aria-label={`Remove ${label} automation`}
372+
title={`Remove ${label} automation`}
373+
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
374+
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
375+
onPointerDown={(event) => event.stopPropagation()}
376+
onClick={(event) => {
377+
// A control in the label column owns its click; it does not also hit
378+
// the track row behind it and change the selection.
379+
event.stopPropagation();
380+
onRemove(target);
381+
}}
382+
>
383+
×
384+
</button>
385+
)}
386+
</div>
387+
);
388+
}
389+
284390
export function TimelineTrackHeader({
285391
trackNumber,
286392
trackDisplayNumber,
@@ -298,6 +404,7 @@ export function TimelineTrackHeader({
298404
onToggleClipExpanded,
299405
onToggleTrackHidden,
300406
onTogglePropertyGroupKeyframe,
407+
onRemoveAutomationLane,
301408
onSeek,
302409
}: TimelineTrackHeaderProps) {
303410
const clipPercentage = keyframeClip
@@ -315,6 +422,20 @@ export function TimelineTrackHeader({
315422
// left an audio clip's envelopes unreachable, since the track could not expand.
316423
const disclosable =
317424
lanes.length > 0 || (keyframeClip ? automationLaneCountOf(keyframeClip) : 0) > 0;
425+
// Each envelope's name, resolved against the chain the same way the lane
426+
// resolves its axis — a band is named by its frequency, not by its effect. The
427+
// lane list is already in drawing order, which is the order these rows have to
428+
// follow: a name beside the wrong envelope is worse than an awkward order.
429+
const automationRows = keyframeClip
430+
? elementAutomationLanes(keyframeClip).flatMap((lane) => {
431+
const chain = elementFxChain(keyframeClip);
432+
const parts = automationLaneLabelParts(lane.target, chain);
433+
const label = automationLaneLabel(lane.target, chain);
434+
return parts && label
435+
? [{ target: lane.target, label, name: parts.name, param: parts.param }]
436+
: [];
437+
})
438+
: [];
318439
const isKeyframeLayer = !!keyframeClip && disclosable;
319440

320441
return (
@@ -381,7 +502,7 @@ export function TimelineTrackHeader({
381502
key={lane.group}
382503
lane={lane}
383504
laneIndex={laneIndex}
384-
isLastLane={laneIndex === lanes.length - 1}
505+
isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
385506
expandedElement={keyframeClip}
386507
currentTime={currentTime}
387508
clipPercentage={clipPercentage}
@@ -391,6 +512,24 @@ export function TimelineTrackHeader({
391512
onSeek={onSeek}
392513
/>
393514
))}
515+
{/* Below the keyframe rows and stepping by its own height, which is how
516+
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
517+
two have to agree or a name labels the wrong curve. */}
518+
{isExpanded &&
519+
automationRows.map((row, index) => (
520+
<AutomationLaneHeaderRow
521+
key={row.target}
522+
target={row.target}
523+
label={row.label}
524+
name={row.name}
525+
param={row.param}
526+
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
527+
isLastLane={index === automationRows.length - 1}
528+
gutterBackground={theme.gutterBackground}
529+
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
530+
onRemove={onRemoveAutomationLane}
531+
/>
532+
))}
394533
</>
395534
)}
396535
</div>

0 commit comments

Comments
 (0)