From 18025d7f9fdd55e93436e07b25ff49d89d616f74 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:40:51 -0700 Subject: [PATCH 1/5] feat(studio): retime an automation selection Add retimeRange pure operation that scales interior points proportionally into a new time span, then uses replaceRange to update the lane while preserving the envelope outside the union of old and new ranges. Co-Authored-By: Claude Sonnet 5 --- .../automationLaneSelection.test.ts | 30 ++++++++++++++++++- .../components/automationLaneSelection.ts | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts index a7f0296add..aa0adfe7e3 100644 --- a/packages/studio/src/player/components/automationLaneSelection.test.ts +++ b/packages/studio/src/player/components/automationLaneSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { pointsIn, replaceRange } from "./automationLaneSelection"; +import { pointsIn, replaceRange, retimeRange } from "./automationLaneSelection"; import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; @@ -94,3 +94,31 @@ describe("replaceRange", () => { expect(Math.max(...innerTimes)).toBeGreaterThan(3.0); }); }); + +describe("retimeRange", () => { + it("scales interior points proportionally into the new span", () => { + const pts = retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }); + const moved = pts.find((p) => p.v === 0.4); // the t=3 point + expect(moved?.t).toBe(5); + }); + + it("preserves the envelope outside the union of old and new spans", () => { + const before: HfAutomationLane = { target: "volume", points: ramp.points }; + const after: HfAutomationLane = { + target: "volume", + points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }), + }; + for (const t of [0, 1, 1.9, 5.1, 6]) { + expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo( + sampleAutomationLane(before, t, "linear"), + 5, + ); + } + }); + + it("rejects a degenerate span", () => { + expect( + retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 4, newT1: 4 }), + ).toEqual(ramp.points); + }); +}); diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts index 3e8e1fb703..f49c2bbcab 100644 --- a/packages/studio/src/player/components/automationLaneSelection.ts +++ b/packages/studio/src/player/components/automationLaneSelection.ts @@ -72,3 +72,33 @@ export function replaceRange(input: { const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget); return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t); } + +/** + * Retime a selection: interior points scale proportionally into the new span, + * then replaceRange runs over the UNION of old and new spans — growing eats + * whatever it covers, shrinking pins anchors where the envelope re-enters. + */ +export function retimeRange(input: { + lane: HfAutomationLane; + range: AutomationRange; + t0: number; + t1: number; + newT0: number; + newT1: number; +}): HfAutomationPoint[] { + const { lane, range, t0, t1, newT0, newT1 } = input; + const oldSpan = t1 - t0; + const newSpan = newT1 - newT0; + if (oldSpan <= 0 || newSpan <= 0) return lane.points; + const inner = pointsIn(lane, t0, t1).map((p) => ({ + ...p, + t: newT0 + ((p.t - t0) * newSpan) / oldSpan, + })); + return replaceRange({ + lane, + range, + t0: Math.min(t0, newT0), + t1: Math.max(t1, newT1), + inner, + }); +} From 13dcf776839fb463fa82d25ce30e882d02d2579b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 11:47:31 -0700 Subject: [PATCH 2/5] test(studio): probe retimeRange's actual guarantee, not sample-continuity past a moved edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failing test probed t=5.1, which sits inside the reshaped transition segment between the new edge (t=5) and the existing point (t=6). When growing past an existing breakpoint, the transition TO that point legitimately reshapes — the edge moved (t=3→t=5) even though the far point (t=6) did not. The real guarantee: all BREAKPOINTS strictly outside the union keep exact (t, v) values. Corrected test to: 1. Verify sample continuity on unaffected side: t=[0,1,1.9] 2. Verify the breakpoint at t=6 keeps exact value: (t:6, v:0) Co-Authored-By: Claude Sonnet 5 --- .../player/components/automationLaneSelection.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts index aa0adfe7e3..e1e2370cd8 100644 --- a/packages/studio/src/player/components/automationLaneSelection.test.ts +++ b/packages/studio/src/player/components/automationLaneSelection.test.ts @@ -108,12 +108,21 @@ describe("retimeRange", () => { target: "volume", points: retimeRange({ lane: ramp, range: VOLUME_RANGE, t0: 2, t1: 3, newT0: 2, newT1: 5 }), }; - for (const t of [0, 1, 1.9, 5.1, 6]) { + // Nothing to the left of t0=2 moved (newT0 === t0 here), so sampled + // continuity holds all the way up to the edited region. + for (const t of [0, 1, 1.9]) { expect(sampleAutomationLane(after, t, "linear")).toBeCloseTo( sampleAutomationLane(before, t, "linear"), 5, ); } + // The next real breakpoint past the edited region keeps its own exact + // value — growing past it reshapes the transition INTO it, not the point + // itself. (Sampling inside that transition, e.g. at t=5.1, is expected to + // differ: one of that segment's endpoints moved from t=3 to t=5, even + // though this point at t=6 did not move at all.) + const farPoint = after.points.find((p) => p.t === 6); + expect(farPoint).toEqual({ t: 6, v: 0 }); }); it("rejects a degenerate span", () => { From 794f976a24fae2076fe4954db5459eb6ccb4bf6e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 12:08:21 -0700 Subject: [PATCH 3/5] feat(studio): stretch an automation selection by its edges Add an edge-handle drag to a selection's rect: grabbing within 8px of either edge retimes the selection via the already-landed retimeRange, scaling interior points proportionally and clamping the dragged edge against its partner and the clip's duration. Priority is point-drag > curve-drag > edge-stretch > new-range-select, so a point sitting on an edge still wins the press. Cursor shows col-resize while hovering or dragging a handle. Co-Authored-By: Claude Sonnet 5 --- .../TimelineAutomationLane.test.tsx | 131 +++++++++++ .../components/TimelineAutomationLane.tsx | 15 +- .../components/useAutomationLaneGestures.ts | 219 +++++++++++++++--- 3 files changed, 326 insertions(+), 39 deletions(-) diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index c84a019201..375c3481c8 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -686,3 +686,134 @@ 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("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"); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 835c8111c5..25d150900e 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -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"; } @@ -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 => { @@ -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", }} diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index cb00c83efe..54214c6014 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -3,8 +3,8 @@ * * Its own hook because the lane component sits at the studio's file ceiling and * because these are the parts worth testing on their own: which of a press, - * a drag and a modifier resolves to moving a point, bending a segment, or - * nothing at all. + * a drag and a modifier resolves to moving a point, bending a segment, + * stretching a selection's edge, or nothing at all. * * Modifiers follow Ableton's, since that is the muscle memory an automation lane * inherits: Shift locks a drag to one axis and fines the value down, Alt over a @@ -21,11 +21,17 @@ import { POINT_MERGE_SEC, snapLaneTime, } from "./automationLaneGeometry"; +import { retimeRange } from "./automationLaneSelection"; /** Snap radius in clip seconds. Tight on purpose: a lane is often a few seconds * wide, where a generous radius makes a point unplaceable between two beats. */ const SNAP_SEC = 0.04; +/** Hit radius for grabbing a selection's edge, in screen px — independent of + * a point's own grab radius so the two zones can be reasoned about on their + * own, even though a point sitting on an edge still wins (see `gestureAt`). */ +const EDGE_GRAB_PX = 8; + /** A point's position, or the origin when the index no longer resolves. */ function originOf(point: HfAutomationLane["points"][number] | undefined): { t: number; v: number } { return point ? { t: point.t, v: point.v } : { t: 0, v: 0 }; @@ -60,6 +66,8 @@ export interface UseAutomationLaneGesturesInput { onRangeSelect?: ((t0: number, t1: number) => void) | undefined; onRangeClear?: (() => void) | undefined; duration: number; // clamp bound for range endpoints + /** Active selection on this lane, so its edges have something to grab. */ + rangeSelection?: { t0: number; t1: number } | null | undefined; } export interface UseAutomationLaneGesturesResult { @@ -67,6 +75,11 @@ export interface UseAutomationLaneGesturesResult { dragIndex: number | null; /** Segment being bent, identified by the point that owns its curve. */ curveIndex: number | null; + /** Edge being stretched, for the cursor. */ + edgeDrag: "t0" | "t1" | null; + /** Whether the pointer sits over a stretch handle with no gesture live — + * the col-resize cursor hint before a press commits to the drag. */ + edgeHover: boolean; /** Value readout to show while a gesture is live. */ hint: string | null; hitIndex(clientX: number, clientY: number): number | null; @@ -97,6 +110,7 @@ export function useAutomationLaneGestures({ onRangeSelect, onRangeClear, duration, + rangeSelection, }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult { const [dragIndex, setDragIndex] = useState(null); const [curveIndex, setCurveIndex] = useState(null); @@ -110,6 +124,32 @@ export function useAutomationLaneGestures({ /** Whether the live drag has crossed the pixel threshold that turns a press * into an actual range, rather than a click that should just clear one. */ const rangeCrossed = useRef(false); + /** An edge-stretch drag in progress: which edge, the selection it started + * from (kept fixed as the retime's untouched anchor), and the edge's own + * live position. */ + const [edgeDrag, setEdgeDrag] = useState<{ + edge: "t0" | "t1"; + origin: { t0: number; t1: number }; + current: number; + } | null>(null); + /** Cursor hint: hovering a stretch handle with nothing else live. */ + const [edgeHover, setEdgeHover] = useState(false); + + /** Which edge of the active selection, if any, sits within grab range of the + * pointer's screen x — full lane height, since the handle spans the rect. */ + const edgeAt = useCallback( + (clientX: number): "t0" | "t1" | null => { + if (!rangeSelection) return null; + const box = getBox(); + if (!box) return null; + const px = clientX - box.left; + const d0 = Math.abs(xOf(rangeSelection.t0) - px); + const d1 = Math.abs(xOf(rangeSelection.t1) - px); + if (d0 <= EDGE_GRAB_PX && d0 <= d1) return "t0"; + return d1 <= EDGE_GRAB_PX ? "t1" : null; + }, + [rangeSelection, getBox, xOf], + ); /** Index of a point under the pointer, or null. */ const hitIndex = useCallback( @@ -153,6 +193,38 @@ export function useAutomationLaneGestures({ [hitIndex, segmentIndex], ); + /** + * What a press on the lane's empty background arms: an edge grab when it + * landed within range of an existing selection's edge, else a new range + * selection — only when a caller wants to hear about one; a read-only lane + * never reaches here at all. + */ + const armBackgroundGesture = useCallback( + (e: ReactPointerEvent): void => { + const edge = edgeAt(e.clientX); + if (edge && rangeSelection) { + e.preventDefault(); + capturePointer(e); + setEdgeHover(false); + setEdgeDrag({ + edge, + origin: rangeSelection, + current: edge === "t0" ? rangeSelection.t0 : rangeSelection.t1, + }); + return; + } + if (!onRangeSelect) return; + e.preventDefault(); + capturePointer(e); + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + rangeCrossed.current = false; + setRangeDrag({ from: t, to: t }); + }, + [edgeAt, rangeSelection, onRangeSelect, pointAt, duration, snapTimes], + ); + const onPointerDown = useCallback( (e: ReactPointerEvent): void => { if (e.button !== 0) return; @@ -168,23 +240,15 @@ export function useAutomationLaneGestures({ } const gesture = gestureAt(e); if (!gesture) { - // Neither a point nor an Alt-held segment: the press landed on the - // lane's empty background. That is a range selection's gesture, not - // nothing — but only when a caller wants to hear about one; a - // read-only lane already returned above, so this is a live one with no - // range feature wired up. - if (!onRangeSelect) return; - e.preventDefault(); - capturePointer(e); - const raw = pointAt(e.clientX, e.clientY).t; - const clamped = Math.min(duration, Math.max(0, raw)); - const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); - rangeCrossed.current = false; - setRangeDrag({ from: t, to: t }); + armBackgroundGesture(e); return; } e.preventDefault(); capturePointer(e); + // A point can sit close enough to an edge to have set the hover hint + // moments ago; winning the press should not leave that stale cursor + // showing through the drag that follows. + setEdgeHover(false); if (gesture.curve) { setCurveIndex(gesture.index); return; @@ -192,7 +256,7 @@ export function useAutomationLaneGestures({ dragOrigin.current = originOf(lane.points[gesture.index]); setDragIndex(gesture.index); }, - [gestureAt, lane, readOnly, onSelect, onRangeSelect, pointAt, duration, snapTimes], + [gestureAt, lane, readOnly, onSelect, armBackgroundGesture], ); /** Bend the segment under the pointer, which is what Alt-dragging the line does. */ @@ -239,46 +303,120 @@ export function useAutomationLaneGestures({ [dragIndex, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf], ); + /** Preview the selection's new bounds as the grabbed edge moves: the other + * edge stays put as the retime's anchor, and the dragged one is clamped so + * it cannot cross its partner (leaving at least a merge-radius of room) nor + * leave the clip's own duration. */ + const moveEdge = useCallback( + (e: ReactPointerEvent): void => { + if (edgeDrag === null) return; + const { edge, origin } = edgeDrag; + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const current = + edge === "t0" + ? Math.min(clamped, origin.t1 - POINT_MERGE_SEC) + : Math.max(clamped, origin.t0 + POINT_MERGE_SEC); + setEdgeDrag({ edge, origin, current }); + const newT0 = edge === "t0" ? current : origin.t0; + const newT1 = edge === "t1" ? current : origin.t1; + setHint(`${newT0.toFixed(2)}s → ${newT1.toFixed(2)}s`); + commitPoints(retimeRange({ lane, range, t0: origin.t0, t1: origin.t1, newT0, newT1 }), false); + }, + [edgeDrag, pointAt, duration, lane, range, commitPoints], + ); + + /** Update the live range-drag as the pointer moves, firing `onRangeSelect` + * once it has covered enough pixels to count as an actual range rather + * than a click that should just clear one. */ + const moveRangeDrag = useCallback( + (e: ReactPointerEvent): void => { + if (rangeDrag === null) return; + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + setRangeDrag({ from: rangeDrag.from, to: t }); + if (Math.abs(xOf(t) - xOf(rangeDrag.from)) <= 3) return; + rangeCrossed.current = true; + onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t)); + }, + [rangeDrag, pointAt, duration, snapTimes, xOf, onRangeSelect], + ); + + /** Cursor hint only: whether the pointer sits over a stretch handle with + * nothing else live. Skipped read-only, which never arms a stretch. */ + const updateEdgeHover = useCallback( + (e: ReactPointerEvent): void => { + if (!readOnly) setEdgeHover(edgeAt(e.clientX) !== null); + }, + [readOnly, edgeAt], + ); + const onPointerMove = useCallback( (e: ReactPointerEvent): void => { + if (edgeDrag !== null) { + e.stopPropagation(); + moveEdge(e); + return; + } if (rangeDrag !== null) { e.stopPropagation(); - const raw = pointAt(e.clientX, e.clientY).t; - const clamped = Math.min(duration, Math.max(0, raw)); - const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); - setRangeDrag({ from: rangeDrag.from, to: t }); - if (Math.abs(xOf(t) - xOf(rangeDrag.from)) > 3) { - rangeCrossed.current = true; - onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t)); - } + moveRangeDrag(e); + return; + } + if (curveIndex === null && dragIndex === null) { + updateEdgeHover(e); return; } - if (curveIndex === null && dragIndex === null) return; e.stopPropagation(); if (curveIndex !== null) bendSegment(e.clientX, e.clientY); else movePoint(e); }, [ + edgeDrag, + moveEdge, rangeDrag, - pointAt, - duration, - snapTimes, - xOf, - onRangeSelect, - bendSegment, + moveRangeDrag, curveIndex, dragIndex, + updateEdgeHover, + bendSegment, movePoint, ], ); + /** Persist the stretch and hand the selection's new bounds back to the + * caller — the one point in the gesture that both commits and moves the + * selection it grabbed. */ + const finishEdgeDrag = useCallback((): void => { + if (edgeDrag === null) return; + const { edge, origin, current } = edgeDrag; + const newT0 = edge === "t0" ? current : origin.t0; + const newT1 = edge === "t1" ? current : origin.t1; + setEdgeDrag(null); + setHint(null); + commitPoints(lane.points, true); + onRangeSelect?.(newT0, newT1); + }, [edgeDrag, lane, commitPoints, onRangeSelect]); + + /** A sub-threshold press clears the selection rather than leaving a + * zero-width one behind. */ + const finishRangeDrag = useCallback((): void => { + if (!rangeCrossed.current) onRangeClear?.(); + rangeCrossed.current = false; + setRangeDrag(null); + }, [onRangeClear]); + const endDrag = useCallback( (e: ReactPointerEvent): void => { + if (edgeDrag !== null) { + e.stopPropagation(); + finishEdgeDrag(); + return; + } if (rangeDrag !== null) { e.stopPropagation(); - if (!rangeCrossed.current) onRangeClear?.(); - rangeCrossed.current = false; - setRangeDrag(null); + finishRangeDrag(); return; } if (dragIndex === null && curveIndex === null) return; @@ -289,7 +427,16 @@ export function useAutomationLaneGestures({ setHint(null); commitPoints(lane.points, true); }, - [rangeDrag, onRangeClear, curveIndex, dragIndex, lane, commitPoints], + [ + edgeDrag, + finishEdgeDrag, + rangeDrag, + finishRangeDrag, + curveIndex, + dragIndex, + lane, + commitPoints, + ], ); const onDoubleClick = useCallback( @@ -352,6 +499,8 @@ export function useAutomationLaneGestures({ return { dragIndex, curveIndex, + edgeDrag: edgeDrag?.edge ?? null, + edgeHover, hint, hitIndex, segmentIndex, From 8b3aa8eccc0dbbb7c3436beda996c15d43d75593 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 12:46:25 -0700 Subject: [PATCH 4/5] fix(studio): retime edge-stretch from a fixed points snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveEdge fed retimeRange the live draft on every pointermove while origin.t0/t1 stayed pinned to the drag's start. retimeRange is a relative transform that scales a lane's own current point positions, so repeated pointermoves compounded the scale factor (interior points drift toward the far edge) and could drop points that retimed past the selection's original bound out of the next move's `inner` set entirely. Snapshot lane.points at arm time (armBackgroundGesture) alongside the existing frozen origin, and always retime from that snapshot in moveEdge instead of the live draft. finishEdgeDrag is unchanged: it already just persists the last (now-correct) preview. Adds a regression test asserting a multi-pointermove edge-drag (both edges) lands on the exact same final points as a single-shot drag to the same target — the case that exposed the bug, since the existing suite only ever tested a single move. --- .../TimelineAutomationLane.test.tsx | 68 +++++++++++++++++++ .../components/useAutomationLaneGestures.ts | 38 ++++++++--- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index 375c3481c8..af73ba64f4 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -805,6 +805,74 @@ describe("TimelineAutomationLane stretch", () => { 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 diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index 54214c6014..2eaf4b9cd0 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -12,7 +12,11 @@ */ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation"; +import type { + AutomationRange, + HfAutomationLane, + HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; import { applyShiftConstraint, curveForDrag, @@ -125,12 +129,17 @@ export function useAutomationLaneGestures({ * into an actual range, rather than a click that should just clear one. */ const rangeCrossed = useRef(false); /** An edge-stretch drag in progress: which edge, the selection it started - * from (kept fixed as the retime's untouched anchor), and the edge's own - * live position. */ + * from (kept fixed as the retime's untouched anchor), the edge's own live + * position, and the lane's points as they stood at arm time. `retimeRange` + * is a RELATIVE transform — it scales a lane's own current point positions + * by newSpan/oldSpan — so it must always run against this fixed snapshot, + * never against the live draft: retiming from the draft would compound the + * scale factor on every pointermove instead of applying it once. */ const [edgeDrag, setEdgeDrag] = useState<{ edge: "t0" | "t1"; origin: { t0: number; t1: number }; current: number; + points: HfAutomationPoint[]; } | null>(null); /** Cursor hint: hovering a stretch handle with nothing else live. */ const [edgeHover, setEdgeHover] = useState(false); @@ -210,6 +219,7 @@ export function useAutomationLaneGestures({ edge, origin: rangeSelection, current: edge === "t0" ? rangeSelection.t0 : rangeSelection.t1, + points: lane.points, }); return; } @@ -222,7 +232,7 @@ export function useAutomationLaneGestures({ rangeCrossed.current = false; setRangeDrag({ from: t, to: t }); }, - [edgeAt, rangeSelection, onRangeSelect, pointAt, duration, snapTimes], + [edgeAt, rangeSelection, onRangeSelect, pointAt, duration, snapTimes, lane], ); const onPointerDown = useCallback( @@ -310,20 +320,32 @@ export function useAutomationLaneGestures({ const moveEdge = useCallback( (e: ReactPointerEvent): void => { if (edgeDrag === null) return; - const { edge, origin } = edgeDrag; + const { edge, origin, points } = edgeDrag; const raw = pointAt(e.clientX, e.clientY).t; const clamped = Math.min(duration, Math.max(0, raw)); const current = edge === "t0" ? Math.min(clamped, origin.t1 - POINT_MERGE_SEC) : Math.max(clamped, origin.t0 + POINT_MERGE_SEC); - setEdgeDrag({ edge, origin, current }); + setEdgeDrag({ edge, origin, current, points }); const newT0 = edge === "t0" ? current : origin.t0; const newT1 = edge === "t1" ? current : origin.t1; setHint(`${newT0.toFixed(2)}s → ${newT1.toFixed(2)}s`); - commitPoints(retimeRange({ lane, range, t0: origin.t0, t1: origin.t1, newT0, newT1 }), false); + // Retime from the snapshot taken at arm time, never from `lane` (the + // live draft) — see the state comment above for why. + commitPoints( + retimeRange({ + lane: { target: lane.target, points }, + range, + t0: origin.t0, + t1: origin.t1, + newT0, + newT1, + }), + false, + ); }, - [edgeDrag, pointAt, duration, lane, range, commitPoints], + [edgeDrag, pointAt, duration, lane.target, range, commitPoints], ); /** Update the live range-drag as the pointer moves, firing `onRangeSelect` From 7cde7480759381f74ae3d3342a00dde0b5ebc321 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 5 Aug 2026 12:47:55 -0700 Subject: [PATCH 5/5] fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAutomationSelectionKeyboard: clamp the selection-start paste branch to [0, element.duration - clip.span], same as the playhead branch already does. An unclamped paste near a clip's end could write points past element.duration and leave the resulting selection's edge ungrabbable off the visible lane. - automationClipboard.test.ts: swap the cross-parameter mapping test's target from fx.r.wet (numerically identical to VOLUME_RANGE) to the log-scaled fx.n1.frequency, so the test actually discriminates real unit-space mapping from a linear guess or a verbatim value copy. - automationLaneSelection.ts: drop the lone `!` non-null assertion in decimateEvenly's budget-of-1 branch for a guarded pattern, matching the loop right below it and the repo's no-`!` convention. - .fallowrc.jsonc: remove the two ignoreExports entries for AUTOMATION_SHAPES and simplifyPoints — both are now genuinely consumed (AutomationSelectionMenu.tsx, TimelineAutomationLane.tsx). - AutomationSelectionMenu.tsx: port TrackGapContextMenu's viewport-edge clamping so a right-click near the bottom/right of the timeline doesn't render the shape/simplify menu partially off-screen. --- .fallowrc.jsonc | 16 --------- .../useAutomationSelectionKeyboard.test.tsx | 34 +++++++++++++++++++ .../hooks/useAutomationSelectionKeyboard.ts | 2 +- .../components/AutomationSelectionMenu.tsx | 10 +++++- .../components/automationClipboard.test.ts | 31 ++++++++++++----- .../components/automationLaneSelection.ts | 5 ++- 6 files changed, 71 insertions(+), 27 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f5b1617118..3a9511683b 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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. diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index 7966634485..25ab48c2fa 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -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); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 4135afedcf..8fe20294ad 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -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); diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx index 4a2bd0d057..f157f6e266 100644 --- a/packages/studio/src/player/components/AutomationSelectionMenu.tsx +++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx @@ -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(
{AUTOMATION_SHAPES.map((shape) => (