@hyperframes/studio 0.8.12 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ import type { UseAutomationLanesResult } from "./useAutomationLanes";
17
17
  import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
18
18
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
19
19
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
20
+ import { usePlayerStore } from "../store/playerStore";
20
21
 
21
22
  /** Accent rail on a group-owned lane — the same green the member rail uses, so
22
23
  * "this belongs to the group" reads the same in both places (groups doc §5). */
@@ -92,19 +93,27 @@ export function TimelineGroupRow({
92
93
  const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
93
94
  useTimelineEditContextOptional();
94
95
  const domEditActions = useDomEditActionsContextOptional();
96
+ const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
95
97
  const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
96
98
  const value = next.nodes.length ? serializeAudioFxChain(next) : null;
97
99
  if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
98
100
  else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
99
101
  };
100
- const openGroupFxRack = () => {
101
- const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
102
- group.id,
102
+ const openGroupFxRack = (automationTarget?: string) => {
103
+ // Use the guarded timeline-selection path even though the bus is synthetic:
104
+ // it invalidates an older clip selection that may still be resolving. The
105
+ // old direct build/apply path let that late clip reclaim the rack.
106
+ const selection = domEditActions?.handleTimelineElementSelect(groupElement);
107
+ if (!selection || !automationTarget) return;
108
+ // Bus selection clears the clip selection, which intentionally retires any
109
+ // old reveal request. Publish this one afterwards so the newly mounted bus
110
+ // rack can consume it rather than losing it during that clear.
111
+ void selection.then(() =>
112
+ revealAudioFx({
113
+ elementKey: group.id,
114
+ automationTarget,
115
+ }),
103
116
  );
104
- if (!target) return;
105
- void domEditActions
106
- ?.buildDomSelectionFromTarget(target)
107
- .then((selection) => selection && domEditActions.applyDomSelection(selection));
108
117
  };
109
118
  return (
110
119
  <TimelineTrackRow
@@ -150,7 +159,7 @@ export function TimelineGroupRow({
150
159
  onFxChainChange={(next) => writeGroupFxChain(next, false)}
151
160
  onFxChainPreview={(next) => writeGroupFxChain(next, true)}
152
161
  auditionSpans={memberElements}
153
- onOpenFxRack={openGroupFxRack}
162
+ onOpenFxRack={() => openGroupFxRack()}
154
163
  // Same width as every other row's header. The group row needs a real
155
164
  // label column, but it gets one by turning `labelMode` on for the whole
156
165
  // timeline (see Timeline.tsx) rather than by overhanging alone — an
@@ -172,6 +181,7 @@ export function TimelineGroupRow({
172
181
  columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
173
182
  gutterBackground={theme.gutterBackground}
174
183
  accentColor={GROUP_LANE_ACCENT}
184
+ onReveal={openGroupFxRack}
175
185
  />
176
186
  )}
177
187
  </div>
@@ -59,6 +59,34 @@ export function armGroupDrag(
59
59
  };
60
60
  }
61
61
 
62
+ /**
63
+ * Snapshot the two endpoints owned by a segment drag.
64
+ *
65
+ * The pointer itself is the anchor: a line can be grabbed anywhere along its
66
+ * span, and the segment must not jump so either endpoint takes the pointer's
67
+ * place when the gesture starts.
68
+ */
69
+ export function armSegmentDrag(
70
+ lane: HfAutomationLane,
71
+ index: number,
72
+ anchor: { t: number; v: number },
73
+ ): GroupDragSnapshot | null {
74
+ const a = lane.points[index];
75
+ const b = lane.points[index + 1];
76
+ if (!a || !b) return null;
77
+ return {
78
+ points: lane.points.map((p) => ({ ...p })),
79
+ indices: [index, index + 1],
80
+ anchor: { ...anchor },
81
+ selection: {
82
+ t0: a.t,
83
+ t1: b.t,
84
+ v0: Math.min(a.v, b.v),
85
+ v1: Math.max(a.v, b.v),
86
+ },
87
+ };
88
+ }
89
+
62
90
  export interface GroupMoveResult {
63
91
  points: HfAutomationLane["points"];
64
92
  selection: AutomationSelectionBox;
@@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02;
35
35
  export const MIN_POINT_GAP_SEC = 0.001;
36
36
  /** Hit radius for grabbing a point, in px. */
37
37
  export const GRAB_PX = 7;
38
+ /** Distance from the drawn envelope that offers a segment drag, in px. */
39
+ export const SEGMENT_GRAB_PX = 5;
38
40
  /** Samples used to draw a segment the eye should see as curved. */
39
41
  export const DRAW_SAMPLES = 64;
40
42
  /**
@@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec
222
224
  return best;
223
225
  }
224
226
 
227
+ /** Draw commands from one breakpoint to the next, sampled when it is curved. */
228
+ function segmentLineCommands(input: {
229
+ lane: HfAutomationLane;
230
+ range: AutomationRange;
231
+ index: number;
232
+ xOf(t: number): number;
233
+ yOf(v: number): number;
234
+ }): string[] {
235
+ const { lane, range, index, xOf, yOf } = input;
236
+ const a = lane.points[index];
237
+ const b = lane.points[index + 1];
238
+ if (!a || !b) return [];
239
+ // A via point bends the segment with no `curve` of its own, so the
240
+ // straight-line shortcut has to rule out both.
241
+ if (!a.curve && a.viaX === undefined && range.scale === "linear") {
242
+ return [`L ${xOf(b.t)} ${yOf(b.v)}`];
243
+ }
244
+ return Array.from({ length: DRAW_SAMPLES }, (_, sample) => {
245
+ const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES;
246
+ return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`;
247
+ });
248
+ }
249
+
225
250
  /**
226
251
  * The svg path for one lane's envelope.
227
252
  *
@@ -247,24 +272,38 @@ export function envelopePath(input: {
247
272
  }
248
273
  const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`];
249
274
  for (let i = 0; i + 1 < lane.points.length; i += 1) {
250
- const a = lane.points[i];
251
- const b = lane.points[i + 1];
252
- if (!a || !b) continue;
253
- // A via point bends the segment with no `curve` of its own, so the
254
- // straight-line shortcut has to rule out both.
255
- if (!a.curve && a.viaX === undefined && range.scale === "linear") {
256
- pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`);
257
- continue;
258
- }
259
- for (let k = 1; k <= DRAW_SAMPLES; k += 1) {
260
- const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES;
261
- pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`);
262
- }
275
+ pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf }));
263
276
  }
264
277
  pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`);
265
278
  return pts.join(" ");
266
279
  }
267
280
 
281
+ /**
282
+ * The visible path for one segment, without the lane's constant extensions.
283
+ *
284
+ * Used for the hover/drag affordance: only the segment under the pointer grows
285
+ * heavier, rather than making the entire envelope look selected. It samples by
286
+ * the same rule as `envelopePath`, so a curved or logarithmic segment's hover
287
+ * stroke sits exactly on the line the audio model draws.
288
+ */
289
+ export function envelopeSegmentPath(input: {
290
+ lane: HfAutomationLane;
291
+ range: AutomationRange;
292
+ index: number;
293
+ xOf(t: number): number;
294
+ yOf(v: number): number;
295
+ }): string | null {
296
+ const { lane, range, index, xOf, yOf } = input;
297
+ const a = lane.points[index];
298
+ const b = lane.points[index + 1];
299
+ if (!a || !b) return null;
300
+ const pts = [
301
+ `M ${xOf(a.t)} ${yOf(a.v)}`,
302
+ ...segmentLineCommands({ lane, range, index, xOf, yOf }),
303
+ ];
304
+ return pts.join(" ");
305
+ }
306
+
268
307
  export function laneFor(automation: HfAutomation, target: string): HfAutomationLane {
269
308
  return automation.lanes.find((l) => l.target === target) ?? { target, points: [] };
270
309
  }
@@ -25,6 +25,7 @@ import {
25
25
  type GroupDragSnapshot,
26
26
  type ShiftAxis,
27
27
  } from "./automationLaneDragMath";
28
+ import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
28
29
 
29
30
  /** How far a press may travel and still count as a click rather than a drag. */
30
31
  const CLICK_SLOP_PX = 3;
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
63
64
  dragIndex: number | null;
64
65
  /** Segment being bent, identified by the point that owns its curve. */
65
66
  curveIndex: number | null;
67
+ /** Segment whose two endpoints are being translated together. */
68
+ segmentDragIndex: number | null;
69
+ /** Segment close enough to the pointer to offer that translation. */
70
+ segmentHoverIndex: number | null;
66
71
  /** Value readout to show while a gesture is live. */
67
72
  hint: string | null;
68
73
  hitIndex(clientX: number, clientY: number): number | null;
69
74
  segmentIndex(clientX: number, clientY: number): number | null;
75
+ /** Clear hover feedback when the pointer leaves the lane. */
76
+ onPointerLeave(): void;
70
77
  onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
71
78
  onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
72
79
  /** Edge being stretched, for the cursor. Null when no stretch is live. */
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
227
234
  [lane, pointAt],
228
235
  );
229
236
 
230
- /** What a press starts: moving a point, or — with Alt on the line — bending it. */
237
+ const segmentDrag = useAutomationSegmentDrag({
238
+ getBox,
239
+ lane,
240
+ range,
241
+ pointAt,
242
+ xOf,
243
+ yOf,
244
+ segmentIndex,
245
+ commitPoints,
246
+ duration,
247
+ snapTimes,
248
+ readOnly,
249
+ onHint: setHint,
250
+ });
251
+
252
+ /** What a press starts: point move, segment move, or Alt segment bend. */
231
253
  const gestureAt = useCallback(
232
- (e: ReactPointerEvent<SVGSVGElement>): { curve: boolean; index: number } | null => {
254
+ (
255
+ e: ReactPointerEvent<SVGSVGElement>,
256
+ ): { kind: "point" | "segment" | "curve"; index: number } | null => {
233
257
  const index = hitIndex(e.clientX, e.clientY);
234
- if (index !== null) return { curve: false, index };
235
- if (!e.altKey) return null;
236
- const segment = segmentIndex(e.clientX, e.clientY);
237
- return segment === null ? null : { curve: true, index: segment };
258
+ if (index !== null) return { kind: "point", index };
259
+ // Alt is an explicit bend gesture and retains its span-wide hit target.
260
+ // The unmodified translation is offered only close to the drawn line.
261
+ const segment = e.altKey
262
+ ? segmentIndex(e.clientX, e.clientY)
263
+ : segmentDrag.hitIndex(e.clientX, e.clientY);
264
+ if (segment === null) return null;
265
+ return { kind: e.altKey ? "curve" : "segment", index: segment };
238
266
  },
239
- [hitIndex, segmentIndex],
267
+ [hitIndex, segmentDrag, segmentIndex],
240
268
  );
241
269
 
242
270
  const onPointerDown = useCallback(
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
274
302
  }
275
303
  e.preventDefault();
276
304
  capturePointer(e);
277
- if (gesture.curve) {
305
+ segmentDrag.clearHover();
306
+ if (gesture.kind === "curve") {
278
307
  setCurveIndex(gesture.index);
279
308
  return;
280
309
  }
310
+ if (gesture.kind === "segment") {
311
+ segmentDrag.arm(gesture.index, e.clientX, e.clientY);
312
+ return;
313
+ }
281
314
  dragOrigin.current = originOf(lane.points[gesture.index]);
282
315
  // Pressing one of a selected set drags the whole set. Pressing a point
283
316
  // outside the selection is an ordinary single-point drag, selection or no.
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
299
332
  // selecting a range read the previous selection, or none.
300
333
  rangeSelection,
301
334
  stretch,
335
+ segmentDrag,
302
336
  ],
303
337
  );
304
338
 
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
394
428
  [dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
395
429
  );
396
430
 
431
+ /** Route a live point, bend, or segment gesture after global gestures stand down. */
432
+ const moveLiveGesture = useCallback(
433
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
434
+ const from = pressAt.current;
435
+ if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
436
+ pressTravelled.current = true;
437
+ }
438
+ if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
439
+ else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
440
+ else movePoint(e);
441
+ },
442
+ [bendSegment, curveIndex, movePoint, segmentDrag],
443
+ );
444
+
397
445
  const onPointerMove = useCallback(
398
446
  (e: ReactPointerEvent<SVGSVGElement>): void => {
399
447
  if (stretch.edge !== null) {
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
409
457
  rangeDrag.move(e);
410
458
  return;
411
459
  }
412
- if (curveIndex === null && dragIndex === null) {
460
+ if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
413
461
  stretch.updateHover(e);
462
+ segmentDrag.updateHover(e.clientX, e.clientY);
414
463
  return;
415
464
  }
416
465
  e.stopPropagation();
417
- const from = pressAt.current;
418
- if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
419
- pressTravelled.current = true;
420
- }
421
- if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
422
- else movePoint(e);
466
+ moveLiveGesture(e);
423
467
  },
424
- [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch],
468
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
425
469
  );
426
470
 
427
471
  const endDrag = useCallback(
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
436
480
  rangeDrag.finish();
437
481
  return;
438
482
  }
439
- if (dragIndex === null && curveIndex === null) return;
483
+ if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
440
484
  e.stopPropagation();
441
485
  const index = dragIndex;
442
486
  const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
443
487
  setDragIndex(null);
444
488
  setCurveIndex(null);
489
+ segmentDrag.finish();
445
490
  dragOrigin.current = null;
446
491
  groupDrag.current = null;
447
492
  shiftAxis.current = null;
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
459
504
  }
460
505
  commitPoints(lane.points, true);
461
506
  },
462
- [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
507
+ [rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
463
508
  );
464
509
 
465
510
  /**
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
519
564
  return {
520
565
  dragIndex,
521
566
  curveIndex,
567
+ segmentDragIndex: segmentDrag.dragIndex,
568
+ segmentHoverIndex: segmentDrag.hoverIndex,
522
569
  edgeDrag: stretch.edge,
523
570
  edgeHover: stretch.hover,
524
571
  cancelDrag,
525
572
  hint,
526
573
  hitIndex,
527
574
  segmentIndex,
575
+ onPointerLeave: segmentDrag.clearHover,
528
576
  onPointerDown,
529
577
  onPointerMove,
530
578
  endDrag,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The grab target and translation gesture for one automation segment.
3
+ *
4
+ * Kept separate from the lane's gesture router: proximity is measured against
5
+ * the sampled envelope, while movement reuses the same shape-preserving group
6
+ * math as a box selection.
7
+ */
8
+
9
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
10
+ import {
11
+ sampleAutomationLane,
12
+ type AutomationRange,
13
+ type HfAutomationLane,
14
+ } from "@hyperframes/core/audio-automation";
15
+ import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
16
+ import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
17
+
18
+ interface UseAutomationSegmentDragInput {
19
+ getBox(): DOMRect | null;
20
+ lane: HfAutomationLane;
21
+ range: AutomationRange;
22
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
23
+ xOf(t: number): number;
24
+ yOf(v: number): number;
25
+ segmentIndex(clientX: number, clientY: number): number | null;
26
+ commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
27
+ duration: number;
28
+ snapTimes: readonly number[] | undefined;
29
+ readOnly: boolean | undefined;
30
+ onHint(hint: string | null): void;
31
+ }
32
+
33
+ export function useAutomationSegmentDrag({
34
+ getBox,
35
+ lane,
36
+ range,
37
+ pointAt,
38
+ xOf,
39
+ yOf,
40
+ segmentIndex,
41
+ commitPoints,
42
+ duration,
43
+ snapTimes,
44
+ readOnly,
45
+ onHint,
46
+ }: UseAutomationSegmentDragInput) {
47
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
48
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null);
49
+ const snapshot = useRef<GroupDragSnapshot | null>(null);
50
+
51
+ /** Segment whose drawn line is close enough to grab, or null. */
52
+ const hitIndex = useCallback(
53
+ (clientX: number, clientY: number): number | null => {
54
+ const box = getBox();
55
+ if (!box) return null;
56
+ const index = segmentIndex(clientX, clientY);
57
+ if (index === null) return null;
58
+ const { t } = pointAt(clientX, clientY);
59
+ const value = sampleAutomationLane(lane, t, range.scale);
60
+ return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
61
+ },
62
+ [getBox, lane, pointAt, range.scale, segmentIndex, yOf],
63
+ );
64
+
65
+ const arm = useCallback(
66
+ (index: number, clientX: number, clientY: number): void => {
67
+ snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
68
+ setHoverIndex(null);
69
+ setDragIndex(index);
70
+ },
71
+ [lane, pointAt],
72
+ );
73
+
74
+ const move = useCallback(
75
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
76
+ const group = snapshot.current;
77
+ if (!group) return;
78
+ const moved = computeGroupMove({
79
+ group,
80
+ raw: pointAt(e.clientX, e.clientY),
81
+ shiftKey: e.shiftKey,
82
+ altKey: e.altKey,
83
+ range,
84
+ duration,
85
+ snapTimes,
86
+ xOf,
87
+ yOf,
88
+ });
89
+ onHint(moved.hint);
90
+ commitPoints(moved.points, false);
91
+ },
92
+ [commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
93
+ );
94
+
95
+ const finish = useCallback((): void => {
96
+ snapshot.current = null;
97
+ setDragIndex(null);
98
+ }, []);
99
+
100
+ const updateHover = useCallback(
101
+ (clientX: number, clientY: number): void => {
102
+ setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
103
+ },
104
+ [hitIndex, readOnly],
105
+ );
106
+
107
+ const clearHover = useCallback((): void => setHoverIndex(null), []);
108
+
109
+ return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
110
+ }