Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -176,22 +176,6 @@
"withLane",
],
},
// automationShapes is part of the audio-automation stack: its consumer is
// the UI layer that uses shape generators one PR upstack, so a per-PR audit
// diffing against the merge base sees these as unused. Consumed for real once
// the stack merges; safe to drop this entry then.
{
"file": "packages/studio/src/player/components/automationShapes.ts",
"exports": ["AUTOMATION_SHAPES"],
},
// automationSimplify is part of the audio-automation stack: its consumer is
// the UI layer one PR upstack, so a per-PR audit diffing against the merge
// base sees these as unused. Consumed for real once the stack merges; safe
// to drop this entry then.
{
"file": "packages/studio/src/player/components/automationSimplify.ts",
"exports": ["simplifyPoints"],
},
// propertyPanelAutomation is the shared reader for both panel sections; the
// FX group that consumes these two lands one PR upstack, so a per-PR audit
// against the merge base sees them as unused.
Expand Down
34 changes: 34 additions & 0 deletions packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,40 @@ describe("useAutomationSelectionKeyboard", () => {
});
});

it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
// The playhead branch already clamps to duration - span; the
// selection-start branch didn't, so pasting a 2s clip at a selection
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
// past element.duration — and leave the selection itself out of bounds.
clearAutomationClipboard();
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
const { onCommit } = setup({});
combo("c");
expect(readClipboard()?.span).toBe(2);

// A 0.1s-wide selection right near the clip's 6s end.
usePlayerStore
.getState()
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 });
combo("v");
const written = onCommit.mock.calls.at(-1)?.[0];
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
for (const t of times) {
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(bgmElement.duration);
}
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
expect(usePlayerStore.getState().automationSelection).toEqual({
elementKey: "bgm",
target: "volume",
t0: 4,
t1: 6,
});
});

it("Cmd+V with clipboard content but no resolvable element falls through", () => {
clearAutomationClipboard();
copyRange({ target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ function handlePaste(

const atT =
sel && sel.elementKey === paste.elementKey
? sel.t0
? clamp(sel.t0, 0, paste.element.duration - clip.span)
: clamp(state.currentTime - paste.element.start, 0, paste.element.duration - clip.span);
const t1 = atT + clip.span;
const inner = pastePoints(clip, paste.range, atT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
const menuRef = useContextMenuDismiss(onClose);
const row =
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
// Same edge-clamping precedent as TrackGapContextMenu: without it a
// right-click near the bottom/right of the timeline renders this menu
// partially off-screen.
const menuWidth = 140;
const menuHeight = AUTOMATION_SHAPES.length * 24 + 32;
const overflowY = y + menuHeight - window.innerHeight;
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
return createPortal(
<div
ref={menuRef}
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
style={{ left: x, top: y }}
style={{ left: adjustedX, top: adjustedY }}
>
{AUTOMATION_SHAPES.map((shape) => (
<button
Expand Down
199 changes: 199 additions & 0 deletions packages/studio/src/player/components/TimelineAutomationLane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -686,3 +686,202 @@ describe("TimelineAutomationLane selection menu", () => {
expect(document.querySelector(".hf-automation-menu")).toBeNull();
});
});

describe("TimelineAutomationLane stretch", () => {
// Edges deliberately off any existing point: the lane's hit-priority rule
// (a point always wins) means a selection edge sitting exactly on a
// breakpoint would resolve to a point-drag, never a stretch — see the
// dedicated priority test below for that case instead.

/** Press, drag and release the right edge of a stretchable selection — the
* shape most of this block's tests share, differing only in where the
* drag ends up. */
function dragRightEdge(svg: Element, from: number, to: number): void {
fire(svg, "pointerdown", at(from, 0.5));
fire(svg, "pointermove", at(to, 0.5));
fire(svg, "pointerup", at(to, 0.5));
}

const stretchable: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.5 },
{ t: 2, v: 0.8 },
{ t: 4, v: 0 },
],
},
],
};

it("dragging the right edge retimes the interior and persists on release", () => {
const onRangeSelect = vi.fn();
const { svg, props } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 3.3); // off any point, dragged out to 3.3

expect(props.onCommit).toHaveBeenCalledTimes(1);
const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
const points = written.lanes[0]?.points ?? [];
// Interior points (t=1, t=2) scale by the new/old span ratio (2.8 / 2 = 1.4).
expect(points.some((p) => Math.abs(p.t - 1.2) < 0.01 && p.v === 0.5)).toBe(true);
expect(points.some((p) => Math.abs(p.t - 2.6) < 0.01 && p.v === 0.8)).toBe(true);

expect(onRangeSelect).toHaveBeenCalledTimes(1);
expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, expect.closeTo(3.3, 1));
});

it("previews the stretch on move without persisting, then commits once on release", () => {
const onPreview = vi.fn();
const onCommit = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onPreview,
onCommit,
});
fire(svg, "pointerdown", at(2.5, 0.5));
fire(svg, "pointermove", at(3, 0.5));
fire(svg, "pointermove", at(3.3, 0.5));
expect(onPreview).toHaveBeenCalledTimes(2);
expect(onCommit).not.toHaveBeenCalled();
fire(svg, "pointerup", at(3.3, 0.5));
expect(onCommit).toHaveBeenCalledTimes(1);
});

it("a point sitting on the selection's edge wins over the edge-stretch gesture", () => {
const sel: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1.5, v: 0.5 },
{ t: 2, v: 0.8 },
{ t: 4, v: 0 },
],
},
],
};
const onRangeSelect = vi.fn();
const { svg, props } = mount(sel, {
rangeSelection: { t0: 1, t1: 2 },
onRangeSelect,
});
fire(svg, "pointerdown", at(2, 0.8)); // exactly the point at t=2, which is also the right edge
fire(svg, "pointermove", at(3, 0.8));
fire(svg, "pointerup", at(3, 0.8));
// A point-drag moved just that point; the selection itself was untouched.
expect(onRangeSelect).not.toHaveBeenCalled();
const written = props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation;
const times = (written.lanes[0]?.points ?? []).map((p) => p.t);
expect(times).toContain(3);
});

it("clamps the dragged edge so it cannot cross its partner", () => {
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 0.3); // dragged past the left edge (t0=0.5)
const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
expect(t1).toBeGreaterThan(0.5);
});

it("clamps the dragged edge to the lane's own duration", () => {
const onRangeSelect = vi.fn();
const { svg } = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onRangeSelect,
});
dragRightEdge(svg, 2.5, 10); // far past the clip's own duration (4s)
const [, t1] = onRangeSelect.mock.calls.at(-1) as [number, number];
expect(t1).toBeLessThanOrEqual(4);
});

it("retimes identically whether the right edge arrives in one move or several", () => {
// moveEdge must always retime from the points snapshotted at arm time,
// never from the live draft — retimeRange is a RELATIVE transform (it
// scales the lane's OWN current point positions by newSpan/oldSpan), so
// feeding it the live draft on every pointermove compounds the scale
// factor instead of applying it once. A real drag fires dozens of moves;
// this asserts the FINAL preview is identical regardless of how many.
const onPreviewSingle = vi.fn();
const single = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(2.5, 0.5));
fire(single.svg, "pointermove", at(3.3, 0.5));
const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
?.lanes[0]?.points;
expect(singleShot).toBeDefined();

const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
rangeSelection: { t0: 0.5, t1: 2.5 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(2.5, 0.5));
// At least 3 separate pointermoves crossing the same span, not one jump.
fire(multi.svg, "pointermove", at(2.7, 0.5));
fire(multi.svg, "pointermove", at(2.9, 0.5));
fire(multi.svg, "pointermove", at(3.1, 0.5));
fire(multi.svg, "pointermove", at(3.3, 0.5));
const afterFourMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
?.lanes[0]?.points;
expect(afterFourMoves).toBeDefined();

// Both interior points (t=1, t=2) land exactly where a single-shot retime
// puts them — not compounded, and not dropped.
expect(afterFourMoves).toEqual(singleShot);
expect(afterFourMoves?.length).toBe(6);
expect(afterFourMoves?.some((p) => Math.abs(p.t - 1.2) < 0.001 && p.v === 0.5)).toBe(true);
expect(afterFourMoves?.some((p) => Math.abs(p.t - 2.6) < 0.001 && p.v === 0.8)).toBe(true);
});

it("retimes identically whether the left edge arrives in one move or several", () => {
const onPreviewSingle = vi.fn();
const single = mount(stretchable, {
rangeSelection: { t0: 1, t1: 3 },
onPreview: onPreviewSingle,
});
fire(single.svg, "pointerdown", at(1, 0.5));
fire(single.svg, "pointermove", at(0.2, 0.5));
const singleShot = (onPreviewSingle.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
?.lanes[0]?.points;
expect(singleShot).toBeDefined();

const onPreviewMulti = vi.fn();
const multi = mount(stretchable, {
rangeSelection: { t0: 1, t1: 3 },
onPreview: onPreviewMulti,
});
fire(multi.svg, "pointerdown", at(1, 0.5));
fire(multi.svg, "pointermove", at(0.7, 0.5));
fire(multi.svg, "pointermove", at(0.4, 0.5));
fire(multi.svg, "pointermove", at(0.2, 0.5));
const afterThreeMoves = (onPreviewMulti.mock.calls.at(-1)?.[0] as HfAutomation | undefined)
?.lanes[0]?.points;
expect(afterThreeMoves).toBeDefined();
expect(afterThreeMoves).toEqual(singleShot);
});

it("shows a resize cursor when hovering an edge with nothing else live", () => {
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
fire(svg, "pointermove", at(3, 0.5)); // near the right edge, nothing pressed
expect(svg.style.cursor).toBe("col-resize");
});

it("keeps the normal cursor away from the selection's edges", () => {
const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
fire(svg, "pointermove", at(2, 0.5)); // middle of the selection, not an edge
expect(svg.style.cursor).not.toBe("col-resize");
});
});
15 changes: 11 additions & 4 deletions packages/studio/src/player/components/TimelineAutomationLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ import { getTimelineLaneTop } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";

/** Pointer shape: a read-only lane can only be selected, a live one edited. */
function laneCursor(readOnly: boolean | undefined, dragging: boolean): string {
/** Pointer shape: a stretch handle wins over everything else it might also
* sit above, a read-only lane can only be selected, a live one edited. */
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
if (stretching) return "col-resize";
if (readOnly) return "pointer";
return dragging ? "grabbing" : "crosshair";
}
Expand Down Expand Up @@ -204,8 +206,9 @@ export function TimelineAutomationLane({
onRangeSelect,
onRangeClear,
duration,
rangeSelection,
});
const { dragIndex, curveIndex, hint, editing } = gestures;
const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures;

const removeAt = useCallback(
(index: number): void => {
Expand Down Expand Up @@ -285,7 +288,11 @@ export function TimelineAutomationLane({
top: 0,
width: widthPx + PAD_X * 2,
height: h,
cursor: laneCursor(readOnly, dragIndex !== null || curveIndex !== null),
cursor: laneCursor(
readOnly,
dragIndex !== null || curveIndex !== null,
edgeDrag !== null || edgeHover,
),
opacity: readOnly ? 0.55 : 1,
touchAction: "none",
}}
Expand Down
31 changes: 23 additions & 8 deletions packages/studio/src/player/components/automationClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,34 @@ describe("automation clipboard", () => {
});

it("maps values through unit space onto a different parameter", () => {
const wet = resolveAutomationRange("fx.r.wet", {
// fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
// linear unit math and a literal copy of the source value would both
// read as a passing test on a range that happens to be numerically
// identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
// genuinely log for the test to discriminate real unit-space mapping.
const frequency = resolveAutomationRange("fx.n1.frequency", {
version: 1,
nodes: [{ type: "reverb", id: "r", params: {} }],
nodes: [{ type: "lowpass", id: "n1", params: {} }],
});
expect(wet).toBeTruthy();
if (!wet) return;
expect(frequency).toBeTruthy();
if (!frequency) return;
expect(frequency.scale).toBe("log");
copyRange(duck, VOLUME_RANGE, 2, 4);
const entry = readClipboard();
if (!entry) return;
const pts = pastePoints(entry, wet, 0);
// volume 1 (unit 1) → wet max; volume 0.25 (unit 0.25) → a quarter up wet's axis
expect(pts[0]?.v).toBeCloseTo(wet.max, 5);
expect(pts[1]?.v).toBeCloseTo(wet.min + 0.25 * (wet.max - wet.min), 5);
const pts = pastePoints(entry, frequency, 0);
// volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
// up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
// NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
// literal copy of 0.25.
expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
const expectedLog = Math.exp(
Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
);
const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
});

it("reads null when nothing was copied", () => {
Expand Down
Loading