Skip to content

Commit 157fbd5

Browse files
committed
fix(studio): clamp selection-start paste, sharpen clipboard test, cleanup
- 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.
1 parent c3e681c commit 157fbd5

5 files changed

Lines changed: 70 additions & 26 deletions

File tree

‎.fallowrc.jsonc‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,6 @@
176176
"withLane",
177177
],
178178
},
179-
// automationShapes is part of the audio-automation stack: its consumer is
180-
// the UI layer that uses shape generators one PR upstack, so a per-PR audit
181-
// diffing against the merge base sees these as unused. Consumed for real once
182-
// the stack merges; safe to drop this entry then.
183-
{
184-
"file": "packages/studio/src/player/components/automationShapes.ts",
185-
"exports": ["AUTOMATION_SHAPES"],
186-
},
187-
// automationSimplify is part of the audio-automation stack: its consumer is
188-
// the UI layer one PR upstack, so a per-PR audit diffing against the merge
189-
// base sees these as unused. Consumed for real once the stack merges; safe
190-
// to drop this entry then.
191-
{
192-
"file": "packages/studio/src/player/components/automationSimplify.ts",
193-
"exports": ["simplifyPoints"],
194-
},
195179
// propertyPanelAutomation is the shared reader for both panel sections; the
196180
// FX group that consumes these two lands one PR upstack, so a per-PR audit
197181
// against the merge base sees them as unused.

‎packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,40 @@ describe("useAutomationSelectionKeyboard", () => {
221221
});
222222
});
223223

224+
it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => {
225+
// The playhead branch already clamps to duration - span; the
226+
// selection-start branch didn't, so pasting a 2s clip at a selection
227+
// sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 —
228+
// past element.duration — and leave the selection itself out of bounds.
229+
clearAutomationClipboard();
230+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
231+
usePlayerStore
232+
.getState()
233+
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 });
234+
const { onCommit } = setup({});
235+
combo("c");
236+
expect(readClipboard(null)?.span).toBe(2);
237+
238+
// A 0.1s-wide selection right near the clip's 6s end.
239+
usePlayerStore
240+
.getState()
241+
.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 5.5, t1: 5.6 });
242+
combo("v");
243+
const written = onCommit.mock.calls.at(-1)?.[0];
244+
const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t);
245+
for (const t of times) {
246+
expect(t).toBeGreaterThanOrEqual(0);
247+
expect(t).toBeLessThanOrEqual(bgmElement.duration);
248+
}
249+
// Clamped to duration (6) - span (2) = 4, not the unclamped 5.5.
250+
expect(usePlayerStore.getState().automationSelection).toEqual({
251+
elementKey: "bgm",
252+
target: "volume",
253+
t0: 4,
254+
t1: 6,
255+
});
256+
});
257+
224258
it("refuses to paste when the dom-edit layer would write to a different clip", () => {
225259
// selectedElementId says "bgm" but the commit channel is still on the
226260
// previously selected clip — writing here would serialize bgm's automation

‎packages/studio/src/player/components/AutomationSelectionMenu.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,19 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
3030
const menuRef = useContextMenuDismiss(onClose);
3131
const row =
3232
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
33+
// Same edge-clamping precedent as TrackGapContextMenu: without it a
34+
// right-click near the bottom/right of the timeline renders this menu
35+
// partially off-screen.
36+
const menuWidth = 140;
37+
const menuHeight = AUTOMATION_SHAPES.length * 24 + 32;
38+
const overflowY = y + menuHeight - window.innerHeight;
39+
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
40+
const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
3341
return createPortal(
3442
<div
3543
ref={menuRef}
3644
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
37-
style={{ left: x, top: y }}
45+
style={{ left: adjustedX, top: adjustedY }}
3846
>
3947
{AUTOMATION_SHAPES.map((shape) => (
4048
<button

‎packages/studio/src/player/components/automationClipboard.test.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,34 @@ describe("automation clipboard", () => {
3939
});
4040

4141
it("maps values through unit space onto a different parameter", () => {
42-
const wet = resolveAutomationRange("fx.r.wet", {
42+
// fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
43+
// linear unit math and a literal copy of the source value would both
44+
// read as a passing test on a range that happens to be numerically
45+
// identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
46+
// genuinely log for the test to discriminate real unit-space mapping.
47+
const frequency = resolveAutomationRange("fx.n1.frequency", {
4348
version: 1,
44-
nodes: [{ type: "reverb", id: "r", params: {} }],
49+
nodes: [{ type: "lowpass", id: "n1", params: {} }],
4550
});
46-
expect(wet).toBeTruthy();
47-
if (!wet) return;
51+
expect(frequency).toBeTruthy();
52+
if (!frequency) return;
53+
expect(frequency.scale).toBe("log");
4854
copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
4955
const entry = readClipboard("project-a");
5056
if (!entry) return;
51-
const pts = pastePoints(entry, wet, 0);
52-
// volume 1 (unit 1) → wet max; volume 0.25 (unit 0.25) → a quarter up wet's axis
53-
expect(pts[0]?.v).toBeCloseTo(wet.max, 5);
54-
expect(pts[1]?.v).toBeCloseTo(wet.min + 0.25 * (wet.max - wet.min), 5);
57+
const pts = pastePoints(entry, frequency, 0);
58+
// volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
59+
// up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
60+
// NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
61+
// literal copy of 0.25.
62+
expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
63+
const expectedLog = Math.exp(
64+
Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
65+
);
66+
const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
67+
expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
68+
expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
69+
expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
5570
});
5671

5772
it("reads null when nothing was copied", () => {

‎packages/studio/src/player/components/automationLaneSelection.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ function anchor(
4242
function decimateEvenly<T>(items: readonly T[], budget: number): T[] {
4343
if (budget <= 0) return [];
4444
if (items.length <= budget) return [...items];
45-
if (budget === 1) return [items[0]!];
45+
if (budget === 1) {
46+
const item = items[0];
47+
return item ? [item] : [];
48+
}
4649
const out: T[] = [];
4750
const step = (items.length - 1) / (budget - 1);
4851
for (let i = 0; i < budget; i += 1) {

0 commit comments

Comments
 (0)