Skip to content

Commit a5edce0

Browse files
committed
feat(studio): bend a fade by dragging its own line
Picking a curve used to be a double-click on a 9px grip that cycled through three shapes. Nothing advertised the gesture, you could not see the options before committing to one, and getting back to where you started meant going all the way round. The line is now the control. A dot sits on the curve's midpoint and dragging it up or down bends the fade, live, with the curve following the pointer rather than approaching it: the pointer's height inside the clip IS the level the curve must reach halfway through, and `fadeCurveThroughMidpoint` returns the bend that does it. Drag back through the middle and the fade is straight again, exactly, because the two directions are inverse functions. Both media now draw one shape rather than two lookalikes. `envelopeCurveForFade` is the whole conversion: a fade is `p^(2^(-2·bend))` and an envelope segment is `x^(2^(2·curve))`, so an audio fade and a visual one differ by a sign and nothing else. That collapses the two samplers into one and is asserted point by point, since the two are stored in different places and reached through different code. The dot only appears once there is a fade to bend. On a clip with none there is no line to pull, and the corner grips are what start one.
1 parent 229ce84 commit a5edce0

7 files changed

Lines changed: 422 additions & 148 deletions

File tree

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

Lines changed: 131 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { useCallback, useRef, useState } from "react";
22
import type { PointerEvent as ReactPointerEvent } from "react";
33
import {
44
clampClipFades,
5+
fadeSampler,
56
fadeWedgePath,
67
MIN_FADE_SECONDS,
78
type ClipFades,
8-
type FadeCurve,
99
type FadeSampler,
1010
} from "./clipFades";
11+
import { bendFromPointer, bendHandlePosition } from "./clipFadeBendDrag";
12+
import { FADE_CURVE_LIMIT } from "@hyperframes/core/clip-fade";
1113

1214
/**
1315
* The fade grips on a clip's top corners, and the wedges they draw.
@@ -26,8 +28,9 @@ export interface TimelineClipFadesProps {
2628
duration: number;
2729
pixelsPerSecond: number;
2830
width: number;
29-
curve: FadeCurve;
30-
/** How the level rises across the fade — the medium decides. */
31+
/** How far the fade is bent; 0 is a straight ramp. */
32+
curve: number;
33+
/** How the level rises across the fade, read back from where it is kept. */
3134
sample: FadeSampler;
3235
/**
3336
* The clip's accent. The same colour, weight and opacity the automation lane
@@ -40,8 +43,8 @@ export interface TimelineClipFadesProps {
4043
showGrips: boolean;
4144
/** True when this is not the selected clip, which is what makes it editable. */
4245
readOnly: boolean;
43-
/** Double-clicking a grip steps the fade through its curve shapes. */
44-
onCycleCurve(): void;
46+
/** Dragging the fade line bends it. Live while dragging, once on release. */
47+
onBend(curve: number, persist: boolean): void;
4548
/** Live during the drag: preview only, never persisted. */
4649
onPreview(next: ClipFades): void;
4750
/** Once, on release. */
@@ -51,6 +54,16 @@ export interface TimelineClipFadesProps {
5154
/** Side of the grip square, in px. Matches the trim handle's visual weight. */
5255
const GRIP = 9;
5356

57+
/** Diameter of the bend handle. Smaller than a grip: it is a finer adjustment,
58+
* and it sits on the line rather than on a corner you throw the pointer at. */
59+
const BEND = 7;
60+
61+
/** What the bend reads as, for a title and for a screen reader. */
62+
function bendLabel(curve: number): string {
63+
if (Math.abs(curve) < 0.02) return "Straight";
64+
return curve < 0 ? "Starts slow, finishes fast" : "Starts fast, finishes slow";
65+
}
66+
5467
/**
5568
* Vertical units the wedge is drawn in. A clip's height is set by the row, and
5669
* sometimes by `bottom` rather than a number, so the overlay draws in its own
@@ -71,21 +84,27 @@ export function TimelineClipFades({
7184
readOnly,
7285
onPreview,
7386
onCommit,
74-
onCycleCurve,
87+
onBend,
7588
}: TimelineClipFadesProps) {
7689
// While dragging, the drawn fades come from here: the committed value only
7790
// catches up once the write lands, and the wedge has to track the pointer.
7891
const [draft, setDraft] = useState<ClipFades | null>(null);
92+
const [bendDraft, setBendDraft] = useState<number | null>(null);
7993
const dragRef = useRef<{ edge: "in" | "out"; originX: number; from: ClipFades } | null>(null);
94+
const bendRef = useRef<{ top: number; height: number } | null>(null);
95+
const hostRef = useRef<HTMLDivElement | null>(null);
8096
const shown = draft ?? fades;
97+
// The bend is previewed the same way the lengths are: the committed value
98+
// only catches up once the write lands, and the line has to stay under the
99+
// pointer until then.
100+
const shownCurve = bendDraft ?? curve;
101+
const shownSample = bendDraft === null ? sample : fadeSampler(bendDraft);
81102

82103
const onGripDown = useCallback(
83104
(edge: "in" | "out", event: ReactPointerEvent) => {
84105
if (event.button !== 0) return;
85106
// The grip sits on top of the trim handle and inside the clip body; both
86-
// would otherwise start their own gesture from this same press. NOT
87-
// preventDefault: that suppresses the compatibility click events, and the
88-
// double-click that cycles the curve is one of them.
107+
// would otherwise start their own gesture from this same press.
89108
event.stopPropagation();
90109
event.currentTarget.setPointerCapture(event.pointerId);
91110
dragRef.current = { edge, originX: event.clientX, from: fades };
@@ -126,15 +145,108 @@ export function TimelineClipFades({
126145
const from = dragRef.current?.from;
127146
dragRef.current = null;
128147
setDraft(null);
129-
// A press that moved nothing — the first half of a double-click, or a
130-
// mis-aimed click — must not write the same fade back to the file.
148+
// A press that moved nothing must not write the same fade back to the
149+
// file: a mis-aimed click is not an edit.
131150
if (next && from && (next.fadeIn !== from.fadeIn || next.fadeOut !== from.fadeOut)) {
132151
onCommit(next);
133152
}
134153
},
135154
[onCommit, resolveDrag],
136155
);
137156

157+
/**
158+
* The bend drag. It measures the clip's own pixel box on the way down rather
159+
* than taking a height prop, because a clip row is sometimes sized by
160+
* `bottom` and so has no number to pass down.
161+
*/
162+
const onBendDown = useCallback((event: ReactPointerEvent) => {
163+
if (event.button !== 0) return;
164+
event.stopPropagation();
165+
const host = hostRef.current;
166+
if (!host) return;
167+
const box = host.getBoundingClientRect();
168+
if (!(box.height > 0)) return;
169+
event.currentTarget.setPointerCapture(event.pointerId);
170+
bendRef.current = { top: box.top, height: box.height };
171+
}, []);
172+
173+
const resolveBend = useCallback((clientY: number): number | null => {
174+
const drag = bendRef.current;
175+
return drag ? bendFromPointer(clientY - drag.top, drag.height) : null;
176+
}, []);
177+
178+
const onBendMove = useCallback(
179+
(event: ReactPointerEvent) => {
180+
const next = resolveBend(event.clientY);
181+
if (next === null) return;
182+
setBendDraft(next);
183+
onBend(next, false);
184+
},
185+
[onBend, resolveBend],
186+
);
187+
188+
const onBendUp = useCallback(
189+
(event: ReactPointerEvent) => {
190+
const next = resolveBend(event.clientY);
191+
bendRef.current = null;
192+
setBendDraft(null);
193+
if (next !== null && next !== curve) onBend(next, true);
194+
},
195+
[curve, onBend, resolveBend],
196+
);
197+
198+
/**
199+
* The handle that bends a fade, sitting on the curve's own midpoint so it
200+
* stays under the pointer as the shape changes. Only present once there is a
201+
* fade to bend: with none there is no line to pull, and the corner grips are
202+
* what start one.
203+
*/
204+
const bendFor = (edge: "in" | "out") => {
205+
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
206+
if (seconds < MIN_FADE_SECONDS) return null;
207+
const at = bendHandlePosition({
208+
edge,
209+
seconds,
210+
pixelsPerSecond,
211+
width,
212+
height: hostRef.current?.getBoundingClientRect().height ?? 0,
213+
level: shownSample(0.5),
214+
});
215+
if (!at) return null;
216+
const label = bendLabel(shownCurve);
217+
return (
218+
<div
219+
key={`bend-${edge}`}
220+
role="slider"
221+
tabIndex={-1}
222+
aria-label={edge === "in" ? "Fade in curve" : "Fade out curve"}
223+
aria-valuemin={-FADE_CURVE_LIMIT}
224+
aria-valuemax={FADE_CURVE_LIMIT}
225+
aria-valuenow={shownCurve}
226+
aria-valuetext={label}
227+
data-clip-fade-bend={edge}
228+
onPointerDown={onBendDown}
229+
onPointerMove={onBendMove}
230+
onPointerUp={onBendUp}
231+
onPointerCancel={onBendUp}
232+
title={`Drag up or down to bend this fade. ${label}`}
233+
style={{
234+
position: "absolute",
235+
left: at.x - BEND / 2,
236+
top: at.y - BEND / 2,
237+
width: BEND,
238+
height: BEND,
239+
borderRadius: "50%",
240+
background: accent,
241+
boxShadow: "0 0 0 1.5px rgba(0,0,0,0.55)",
242+
cursor: "ns-resize",
243+
pointerEvents: "auto",
244+
zIndex: 6,
245+
}}
246+
/>
247+
);
248+
};
249+
138250
const gripFor = (edge: "in" | "out") => {
139251
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
140252
const span = Math.min(seconds * pixelsPerSecond, width);
@@ -156,11 +268,7 @@ export function TimelineClipFades({
156268
onPointerMove={onGripMove}
157269
onPointerUp={onGripUp}
158270
onPointerCancel={onGripUp}
159-
onDoubleClick={(event) => {
160-
event.stopPropagation();
161-
if (seconds > 0) onCycleCurve();
162-
}}
163-
title={`Fade ${edge}: drag to set its length, double-click to change its ${curve} curve`}
271+
title={`Fade ${edge}: drag to set its length. Drag the dot on the line to bend it.`}
164272
style={{
165273
position: "absolute",
166274
left: x - GRIP / 2,
@@ -171,14 +279,17 @@ export function TimelineClipFades({
171279
background: "rgba(255,255,255,0.9)",
172280
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
173281
cursor: "ew-resize",
282+
pointerEvents: "auto",
174283
zIndex: 6,
175284
}}
176285
/>
177286
);
178287
};
179288

180289
return (
181-
<>
290+
// One positioned box owns the overlay so the bend handle has a parent whose
291+
// height it can measure, and so both handles share the clip's coordinates.
292+
<div ref={hostRef} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
182293
<svg
183294
aria-hidden="true"
184295
width="100%"
@@ -193,7 +304,7 @@ export function TimelineClipFades({
193304
const { line, fill } = fadeWedgePath({
194305
edge,
195306
seconds,
196-
sample,
307+
sample: shownSample,
197308
pixelsPerSecond,
198309
width,
199310
height: VIEW_HEIGHT,
@@ -218,6 +329,7 @@ export function TimelineClipFades({
218329
})}
219330
</svg>
220331
{showGrips && !readOnly && (["in", "out"] as const).map(gripFor)}
221-
</>
332+
{showGrips && !readOnly && (["in", "out"] as const).map(bendFor)}
333+
</div>
222334
);
223335
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "vitest";
2+
import { fadeEase } from "@hyperframes/core/clip-fade";
3+
import { bendFromPointer, bendHandlePosition } from "./clipFadeBendDrag";
4+
import { fadeSampler } from "./clipFades";
5+
6+
const HEIGHT = 40;
7+
8+
describe("bendFromPointer", () => {
9+
it("is straight when the pointer sits on the line", () => {
10+
expect(bendFromPointer(HEIGHT / 2, HEIGHT)).toBeCloseTo(0, 6);
11+
});
12+
13+
it("puts the curve under the pointer, which is the whole gesture", () => {
14+
for (const offsetY of [6, 12, 20, 28, 34]) {
15+
const bend = bendFromPointer(offsetY, HEIGHT);
16+
const level = fadeEase(0.5, bend);
17+
expect((1 - level) * HEIGHT).toBeCloseTo(offsetY, 0);
18+
}
19+
});
20+
21+
it("bends up for a fade that gets loud early and down for one that waits", () => {
22+
// Smaller offsetY is higher on the screen, so the level halfway through is
23+
// greater: the fade has already done most of its work.
24+
expect(bendFromPointer(8, HEIGHT)).toBeGreaterThan(0);
25+
expect(bendFromPointer(32, HEIGHT)).toBeLessThan(0);
26+
});
27+
28+
it("stops following a pointer dragged past the range instead of inverting", () => {
29+
expect(bendFromPointer(-200, HEIGHT)).toBe(1);
30+
expect(bendFromPointer(400, HEIGHT)).toBe(-1);
31+
});
32+
33+
it("reports straight rather than dividing by a clip with no height", () => {
34+
expect(bendFromPointer(10, 0)).toBe(0);
35+
});
36+
});
37+
38+
describe("bendHandlePosition", () => {
39+
const base = { seconds: 2, pixelsPerSecond: 25, width: 200, height: HEIGHT };
40+
41+
it("sits halfway along a fade in, and halfway along a fade out", () => {
42+
expect(bendHandlePosition({ ...base, edge: "in", level: 0.5 })?.x).toBeCloseTo(25, 6);
43+
expect(bendHandlePosition({ ...base, edge: "out", level: 0.5 })?.x).toBeCloseTo(175, 6);
44+
});
45+
46+
it("rides the curve, so the handle stays under the pointer while bending", () => {
47+
for (const bend of [-1, -0.5, 0, 0.5, 1]) {
48+
const level = fadeSampler(bend)(0.5);
49+
const at = bendHandlePosition({ ...base, edge: "in", level });
50+
expect(at?.y).toBeCloseTo((1 - level) * HEIGHT, 6);
51+
}
52+
});
53+
54+
it("has nowhere to sit on a fade with no width or a clip with no height", () => {
55+
expect(bendHandlePosition({ ...base, edge: "in", level: 0.5, seconds: 0 })).toBeNull();
56+
expect(bendHandlePosition({ ...base, edge: "in", level: 0.5, height: 0 })).toBeNull();
57+
});
58+
59+
it("never runs past a fade clamped to the clip's own width", () => {
60+
const at = bendHandlePosition({ ...base, edge: "in", level: 0.5, seconds: 999 });
61+
expect(at?.x).toBeCloseTo(base.width / 2, 6);
62+
});
63+
});
64+
65+
describe("the drag round-trips", () => {
66+
it("lands the handle back where the pointer left it", () => {
67+
// Inside the band the bend limit can express: levels from 0.5^4 to 0.5^0.25,
68+
// which is roughly 6.4px to 37.5px down a 40px clip.
69+
for (const offsetY of [8, 15, 25, 35]) {
70+
const bend = bendFromPointer(offsetY, HEIGHT);
71+
const at = bendHandlePosition({
72+
edge: "in",
73+
seconds: 2,
74+
pixelsPerSecond: 25,
75+
width: 200,
76+
height: HEIGHT,
77+
level: fadeSampler(bend)(0.5),
78+
});
79+
expect(at?.y).toBeCloseTo(offsetY, 0);
80+
}
81+
});
82+
83+
it("parks the handle at the limit when the pointer goes further than a bend can", () => {
84+
const at = (offsetY: number) =>
85+
bendHandlePosition({
86+
edge: "in",
87+
seconds: 2,
88+
pixelsPerSecond: 25,
89+
width: 200,
90+
height: HEIGHT,
91+
level: fadeSampler(bendFromPointer(offsetY, HEIGHT))(0.5),
92+
})?.y;
93+
// Dragged off the top of the clip and well past it: both stop in the same
94+
// place rather than the curve flipping over.
95+
expect(at(0)).toBeCloseTo(at(-500)!, 6);
96+
expect(at(HEIGHT)).toBeCloseTo(at(500)!, 6);
97+
});
98+
});

0 commit comments

Comments
 (0)