@hyperframes/studio 0.7.107 → 0.7.108

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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateShape } from "./automationShapes";
3
+ import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
4
+ import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
5
+
6
+ const flat: HfAutomationLane = {
7
+ target: "volume",
8
+ points: [
9
+ { t: 0, v: 0.8 },
10
+ { t: 6, v: 0.8 },
11
+ ],
12
+ };
13
+
14
+ describe("generateShape", () => {
15
+ it("ramp-up fades in from the floor to the envelope's own value", () => {
16
+ const pts = generateShape({ shape: "ramp-up", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
17
+ expect(pts).toEqual([
18
+ { t: 1, v: VOLUME_RANGE.min },
19
+ { t: 3, v: 0.8 },
20
+ ]);
21
+ });
22
+
23
+ it("ramp-down fades out from the envelope's own value", () => {
24
+ const pts = generateShape({
25
+ shape: "ramp-down",
26
+ lane: flat,
27
+ range: VOLUME_RANGE,
28
+ t0: 1,
29
+ t1: 3,
30
+ });
31
+ expect(pts).toEqual([
32
+ { t: 1, v: 0.8 },
33
+ { t: 3, v: VOLUME_RANGE.min },
34
+ ]);
35
+ });
36
+
37
+ it("swell peaks at range max mid-selection, smoothed", () => {
38
+ const pts = generateShape({ shape: "swell", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
39
+ expect(pts).toHaveLength(3);
40
+ expect(pts[1]).toMatchObject({ t: 2, v: VOLUME_RANGE.max });
41
+ expect(pts[0]?.curve).toBeDefined(); // eased, not a triangle
42
+ });
43
+
44
+ it("dip ducks to a quarter of the edge value in unit space", () => {
45
+ const pts = generateShape({ shape: "dip", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
46
+ // volume is linear 0..1: unit(0.8) = 0.8, floor = 0.2
47
+ expect(pts[1]?.v).toBeCloseTo(0.2, 5);
48
+ });
49
+
50
+ it("computes in unit space on a log lane", () => {
51
+ const range = resolveAutomationRange("fx.n1.frequency", {
52
+ version: 1,
53
+ nodes: [{ type: "lowpass", id: "n1", params: {} }],
54
+ });
55
+ expect(range?.scale).toBe("log");
56
+ if (!range) return;
57
+ const lane: HfAutomationLane = {
58
+ target: "fx.n1.frequency",
59
+ points: [
60
+ { t: 0, v: 2000 },
61
+ { t: 6, v: 2000 },
62
+ ],
63
+ };
64
+ const pts = generateShape({ shape: "dip", lane, range, t0: 1, t1: 3 });
65
+ const floor = pts[1]?.v ?? 0;
66
+ // A quarter of the way up the LOG axis, not 500 Hz.
67
+ expect(floor).toBeGreaterThan(range.min);
68
+ expect(floor).toBeLessThan(2000 * 0.25);
69
+ });
70
+
71
+ it("uses the range default when the lane is empty", () => {
72
+ const empty: HfAutomationLane = { target: "volume", points: [] };
73
+ const pts = generateShape({
74
+ shape: "ramp-down",
75
+ lane: empty,
76
+ range: VOLUME_RANGE,
77
+ t0: 1,
78
+ t1: 3,
79
+ });
80
+ expect(pts[0]?.v).toBe(VOLUME_RANGE.default);
81
+ });
82
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The utility shapes a video author reaches for: fade in, fade out, swell,
3
+ * duck. One shape scaled to the selection — this is not a DAW, nobody needs a
4
+ * tempo-synced LFO. Edge values come from the envelope itself so a shape
5
+ * splices into whatever is already there; vertical maths runs in unit space so
6
+ * a log knob (frequency) behaves like the lane that draws it.
7
+ */
8
+ import {
9
+ sampleAutomationLane,
10
+ type AutomationRange,
11
+ type HfAutomationLane,
12
+ type HfAutomationPoint,
13
+ } from "@hyperframes/core/audio-automation";
14
+ import { fromUnit, toUnit } from "./automationLaneGeometry";
15
+
16
+ export type AutomationShapeId = "ramp-up" | "ramp-down" | "swell" | "dip";
17
+
18
+ export const AUTOMATION_SHAPES: ReadonlyArray<{ id: AutomationShapeId; label: string }> = [
19
+ { id: "ramp-up", label: "Ramp up" },
20
+ { id: "ramp-down", label: "Ramp down" },
21
+ { id: "swell", label: "Swell" },
22
+ { id: "dip", label: "Dip" },
23
+ ];
24
+
25
+ /** Ease used on the segments entering/leaving a swell or dip midpoint. */
26
+ const SMOOTH = 0.4;
27
+ /** A dip ducks to this fraction of the edge value, in unit space. */
28
+ const DIP_FLOOR = 0.25;
29
+
30
+ function edgeValue(lane: HfAutomationLane, range: AutomationRange, t: number): number {
31
+ if (lane.points.length === 0) return range.default ?? (range.min + range.max) / 2;
32
+ return sampleAutomationLane(lane, t, range.scale);
33
+ }
34
+
35
+ export function generateShape(input: {
36
+ shape: AutomationShapeId;
37
+ lane: HfAutomationLane;
38
+ range: AutomationRange;
39
+ t0: number;
40
+ t1: number;
41
+ }): HfAutomationPoint[] {
42
+ const { shape, lane, range, t0, t1 } = input;
43
+ const v0 = edgeValue(lane, range, t0);
44
+ const v1 = edgeValue(lane, range, t1);
45
+ const mid = (t0 + t1) / 2;
46
+ switch (shape) {
47
+ case "ramp-up":
48
+ return [
49
+ { t: t0, v: range.min },
50
+ { t: t1, v: v1 },
51
+ ];
52
+ case "ramp-down":
53
+ return [
54
+ { t: t0, v: v0 },
55
+ { t: t1, v: range.min },
56
+ ];
57
+ case "swell":
58
+ return [
59
+ { t: t0, v: v0, curve: SMOOTH },
60
+ { t: mid, v: range.max, curve: -SMOOTH },
61
+ { t: t1, v: v1 },
62
+ ];
63
+ case "dip":
64
+ return [
65
+ { t: t0, v: v0, curve: -SMOOTH },
66
+ { t: mid, v: fromUnit(range, toUnit(range, v0) * DIP_FLOOR), curve: SMOOTH },
67
+ { t: t1, v: v1 },
68
+ ];
69
+ }
70
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { simplifyPoints } from "./automationSimplify";
3
+ import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
4
+ import { toUnit } from "./automationLaneGeometry";
5
+ import type { HfAutomationPoint } from "@hyperframes/core/audio-automation";
6
+
7
+ describe("simplifyPoints", () => {
8
+ it("collapses collinear runs to their endpoints", () => {
9
+ const line: HfAutomationPoint[] = Array.from({ length: 50 }, (_, i) => ({
10
+ t: i * 0.1,
11
+ v: 1 - i * 0.01,
12
+ }));
13
+ const out = simplifyPoints(line, VOLUME_RANGE);
14
+ expect(out).toHaveLength(2);
15
+ expect(out[0]).toEqual(line[0]);
16
+ expect(out[out.length - 1]).toEqual(line[line.length - 1]);
17
+ });
18
+
19
+ it("keeps every survivor within epsilon of the original", () => {
20
+ const wave: HfAutomationPoint[] = Array.from({ length: 100 }, (_, i) => ({
21
+ t: i * 0.05,
22
+ v: 0.5 + 0.4 * Math.sin(i * 0.2),
23
+ }));
24
+ const out = simplifyPoints(wave, VOLUME_RANGE, 0.02);
25
+ expect(out.length).toBeLessThan(wave.length / 2);
26
+ // Every dropped point must sit within epsilon (unit space) of the
27
+ // simplified polyline — check by linear interpolation between survivors.
28
+ for (const p of wave) {
29
+ const rIdx = out.findIndex((q) => q.t >= p.t);
30
+ const b = out[rIdx] ?? out[out.length - 1];
31
+ const a = out[rIdx - 1] ?? b;
32
+ if (!a || !b) continue;
33
+ const span = b.t - a.t;
34
+ const f = span > 0 ? (p.t - a.t) / span : 0;
35
+ const approx =
36
+ toUnit(VOLUME_RANGE, a.v) + f * (toUnit(VOLUME_RANGE, b.v) - toUnit(VOLUME_RANGE, a.v));
37
+ expect(Math.abs(approx - toUnit(VOLUME_RANGE, p.v))).toBeLessThanOrEqual(0.021);
38
+ }
39
+ });
40
+
41
+ it("returns short inputs untouched", () => {
42
+ const two: HfAutomationPoint[] = [
43
+ { t: 0, v: 1 },
44
+ { t: 1, v: 0 },
45
+ ];
46
+ expect(simplifyPoints(two, VOLUME_RANGE)).toEqual(two);
47
+ });
48
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ramer–Douglas–Peucker over an envelope's points, deviation measured
3
+ * VERTICALLY in unit space. Vertical (not perpendicular) because an envelope
4
+ * is a function of time — what matters is how far the value strays, and it
5
+ * keeps the metric independent of the time axis' units. Exists for dense
6
+ * producers: carve output and heavy hand edits.
7
+ */
8
+ import type { AutomationRange, HfAutomationPoint } from "@hyperframes/core/audio-automation";
9
+ import { toUnit } from "./automationLaneGeometry";
10
+
11
+ export function simplifyPoints(
12
+ points: HfAutomationPoint[],
13
+ range: AutomationRange,
14
+ epsilon = 0.02,
15
+ ): HfAutomationPoint[] {
16
+ if (points.length <= 2) return points;
17
+ const keep = new Array<boolean>(points.length).fill(false);
18
+ const last = keep.length - 1;
19
+ keep[0] = true;
20
+ keep[last] = true;
21
+
22
+ const stack: Array<[number, number]> = [[0, last]];
23
+ while (stack.length > 0) {
24
+ const seg = stack.pop();
25
+ if (!seg) break;
26
+ const [a, b] = seg;
27
+ const pa = points[a];
28
+ const pb = points[b];
29
+ if (!pa || !pb || b - a < 2) continue;
30
+ const ua = toUnit(range, pa.v);
31
+ const ub = toUnit(range, pb.v);
32
+ const span = pb.t - pa.t;
33
+ let worst = -1;
34
+ let worstDev = epsilon;
35
+ for (let i = a + 1; i < b; i += 1) {
36
+ const p = points[i];
37
+ if (!p) continue;
38
+ const f = span > 0 ? (p.t - pa.t) / span : 0;
39
+ const dev = Math.abs(toUnit(range, p.v) - (ua + f * (ub - ua)));
40
+ if (dev > worstDev) {
41
+ worstDev = dev;
42
+ worst = i;
43
+ }
44
+ }
45
+ if (worst >= 0) {
46
+ keep[worst] = true;
47
+ stack.push([a, worst], [worst, b]);
48
+ }
49
+ }
50
+ return points.filter((_, i) => keep[i]);
51
+ }
@@ -0,0 +1,223 @@
1
+ import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
4
+ import {
5
+ resolveBlockedTimelineEditIntent,
6
+ type BlockedTimelineEditIntent,
7
+ } from "./timelineEditing";
8
+ import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
9
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
10
+ import { CLIP_HANDLE_W } from "./timelineLayout";
11
+ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
12
+
13
+ export interface ClipGestureDeps {
14
+ pps: number;
15
+ onResizeElement?: TimelineEditCallbacks["onResizeElement"];
16
+ onMoveElement?: TimelineEditCallbacks["onMoveElement"];
17
+ onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
18
+ onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
19
+ blockedClipRef: React.RefObject<BlockedClipState | null>;
20
+ shiftClickClipRef: React.RefObject<{
21
+ element: TimelineElement;
22
+ anchorX: number;
23
+ anchorY: number;
24
+ } | null>;
25
+ suppressClickRef: React.RefObject<boolean>;
26
+ scrollRef: React.RefObject<HTMLDivElement | null>;
27
+ setShowPopover(v: boolean): void;
28
+ setRangeSelection(v: null): void;
29
+ setResizingClip(v: ResizingClipState | null): void;
30
+ setDraggedClip(v: DraggedClipState | null): void;
31
+ setSelectedElementId(id: string | null): void;
32
+ onSelectElement?: (element: TimelineElement | null) => void;
33
+ }
34
+
35
+ /** Whether a resize-handle drag on `edge` is allowed to begin at all. */
36
+ function canStartResize(
37
+ edge: "start" | "end",
38
+ e: ReactPointerEvent,
39
+ capabilities: TimelineEditCapabilities,
40
+ onResizeElement: ClipGestureDeps["onResizeElement"],
41
+ ): boolean {
42
+ if (e.button !== 0 || e.shiftKey || !onResizeElement) return false;
43
+ if (edge === "start") return capabilities.canTrimStart;
44
+ return capabilities.canTrimEnd;
45
+ }
46
+
47
+ type PointerDownAction =
48
+ | { kind: "ignore" }
49
+ | { kind: "arm-shift-click" }
50
+ | { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
51
+ | { kind: "move"; rect: DOMRect };
52
+
53
+ /**
54
+ * A hit-tested intent is "blocked" when the pointer landed on a smaller
55
+ * target than the gesture it implies would need — e.g. a resize handle too
56
+ * narrow to grab reliably — AND that gesture is actually wired up. An intent
57
+ * with nowhere to go (no matching callback) is not blocking anything.
58
+ */
59
+ function isIntentBlocked(
60
+ intent: BlockedTimelineEditIntent | null,
61
+ onResizeElement: ClipGestureDeps["onResizeElement"],
62
+ onMoveElement: ClipGestureDeps["onMoveElement"],
63
+ ): intent is BlockedTimelineEditIntent {
64
+ if (!intent) return false;
65
+ return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
66
+ }
67
+
68
+ /**
69
+ * What a pointerdown on a clip bar means, before any state is touched. Kept
70
+ * pure (no refs, no store writes) so the branching that decides move vs.
71
+ * resize-handle vs. blocked-by-a-smaller-hit-target lives in one place,
72
+ * separate from the side effects `onPointerDown` below performs for it.
73
+ */
74
+ function resolvePointerDownAction(
75
+ e: ReactPointerEvent,
76
+ capabilities: TimelineEditCapabilities,
77
+ onResizeElement: ClipGestureDeps["onResizeElement"],
78
+ onMoveElement: ClipGestureDeps["onMoveElement"],
79
+ ): PointerDownAction {
80
+ if (e.button !== 0) return { kind: "ignore" };
81
+ if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
82
+ if (e.shiftKey) return { kind: "arm-shift-click" };
83
+
84
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
85
+ const intent = resolveBlockedTimelineEditIntent({
86
+ width: rect.width,
87
+ offsetX: e.clientX - rect.left,
88
+ handleWidth: CLIP_HANDLE_W,
89
+ capabilities,
90
+ });
91
+ if (isIntentBlocked(intent, onResizeElement, onMoveElement)) {
92
+ return { kind: "block", intent, rect };
93
+ }
94
+
95
+ if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
96
+ return { kind: "move", rect };
97
+ }
98
+
99
+ /**
100
+ * The three pointer/click gestures a clip bar answers to. Factored out of
101
+ * TimelineLanes' render loop — one call per rendered clip, closing over that
102
+ * clip's own `el`/`elementKey`/`previewElement`/`capabilities` — so the huge
103
+ * per-track map body reads as JSX instead of ~120 lines of gesture logic.
104
+ */
105
+ export function createClipGestureHandlers(
106
+ el: TimelineElement,
107
+ elementKey: string,
108
+ previewElement: TimelineElement,
109
+ capabilities: TimelineEditCapabilities,
110
+ deps: ClipGestureDeps,
111
+ ) {
112
+ const {
113
+ pps,
114
+ onResizeElement,
115
+ onMoveElement,
116
+ onRazorSplit,
117
+ onRazorSplitAll,
118
+ blockedClipRef,
119
+ shiftClickClipRef,
120
+ suppressClickRef,
121
+ scrollRef,
122
+ setShowPopover,
123
+ setRangeSelection,
124
+ setResizingClip,
125
+ setDraggedClip,
126
+ setSelectedElementId,
127
+ onSelectElement,
128
+ } = deps;
129
+
130
+ const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
131
+ if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
132
+ e.stopPropagation();
133
+ blockedClipRef.current = null;
134
+ setShowPopover(false);
135
+ setRangeSelection(null);
136
+ setResizingClip({
137
+ pointerId: e.pointerId,
138
+ element: el,
139
+ edge,
140
+ originClientX: e.clientX,
141
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
142
+ previewStart: el.start,
143
+ previewDuration: el.duration,
144
+ previewPlaybackStart: el.playbackStart,
145
+ started: false,
146
+ });
147
+ };
148
+
149
+ const onPointerDown = (e: ReactPointerEvent): void => {
150
+ const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
151
+ if (action.kind === "ignore") return;
152
+
153
+ if (action.kind === "arm-shift-click") {
154
+ shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
155
+ return;
156
+ }
157
+
158
+ if (action.kind === "block") {
159
+ blockedClipRef.current = {
160
+ pointerId: e.pointerId,
161
+ element: el,
162
+ intent: action.intent,
163
+ originClientX: e.clientX,
164
+ originClientY: e.clientY,
165
+ started: false,
166
+ };
167
+ return;
168
+ }
169
+
170
+ const { rect } = action;
171
+ blockedClipRef.current = null;
172
+ setShowPopover(false);
173
+ setRangeSelection(null);
174
+ setDraggedClip({
175
+ pointerId: e.pointerId,
176
+ element: el,
177
+ originClientX: e.clientX,
178
+ originClientY: e.clientY,
179
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
180
+ originScrollTop: scrollRef.current?.scrollTop ?? 0,
181
+ pointerClientX: e.clientX,
182
+ pointerClientY: e.clientY,
183
+ pointerOffsetX: e.clientX - rect.left,
184
+ pointerOffsetY: e.clientY - rect.top,
185
+ previewStart: el.start,
186
+ previewTrack: el.track,
187
+ desiredTrack: el.track,
188
+ insertRow: null,
189
+ snapTime: null,
190
+ snapType: null,
191
+ started: false,
192
+ });
193
+ };
194
+
195
+ const onClick = (e: ReactMouseEvent): void => {
196
+ e.stopPropagation();
197
+ if (suppressClickRef.current) return;
198
+ const { activeTool } = usePlayerStore.getState();
199
+ if (activeTool === "razor" && onRazorSplit) {
200
+ const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
201
+ const clickOffsetX = e.clientX - clipRect.left;
202
+ const splitTime = previewElement.start + clickOffsetX / pps;
203
+ const clampedTime = Math.max(
204
+ previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
205
+ Math.min(
206
+ previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
207
+ splitTime,
208
+ ),
209
+ );
210
+ if (e.shiftKey && onRazorSplitAll) {
211
+ onRazorSplitAll(clampedTime);
212
+ } else {
213
+ onRazorSplit(el, clampedTime);
214
+ }
215
+ return;
216
+ }
217
+ // Clip selection is idempotent; empty timeline space owns deselection.
218
+ setSelectedElementId(elementKey);
219
+ onSelectElement?.(el);
220
+ };
221
+
222
+ return { onResizeStart, onPointerDown, onClick };
223
+ }
@@ -9,6 +9,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us
9
9
  import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
10
10
  import type { TimelineRowGeometry } from "./timelineLayout";
11
11
  import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
12
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
13
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
12
14
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
13
15
  import type { TimelineClipRenderContext } from "./TimelineTypes";
14
16
 
@@ -98,3 +100,20 @@ export interface TimelineLaneBaseProps {
98
100
  onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
99
101
  beatAnalysis?: MusicBeatAnalysis | null;
100
102
  }
103
+
104
+ /**
105
+ * {@link TimelineLaneBaseProps} plus the handful of props only the lane
106
+ * renderer ({@link import("./TimelineLanes")}) itself needs — the drag-preview
107
+ * state and the edit callbacks TimelineCanvas does not otherwise touch.
108
+ */
109
+ export interface TimelineLanesProps extends TimelineLaneBaseProps {
110
+ /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
111
+ draggedElement: TimelineElement | null;
112
+ multiDragPreview: MultiDragPreviewInput | null;
113
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
114
+ onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
115
+ onResizeElement: TimelineEditCallbacks["onResizeElement"];
116
+ onMoveElement: TimelineEditCallbacks["onMoveElement"];
117
+ onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
118
+ onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"];
119
+ }
@@ -1,3 +1,4 @@
1
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
1
2
  import type { ZoomMode } from "../store/playerStore";
2
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
3
4
 
@@ -86,6 +87,8 @@ export const TRACKS_LEFT_PAD = 48;
86
87
  export interface TimelineTrackHeightClip {
87
88
  clipId: string;
88
89
  laneCount: number;
90
+ /** Audio automation lanes shown when expanded, reserved at their own height. */
91
+ automationLaneCount?: number;
89
92
  }
90
93
 
91
94
  type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
@@ -101,12 +104,15 @@ export function trackHeights(
101
104
  ): number[] {
102
105
  return tracks.map((clips) => {
103
106
  let laneCount = 0;
104
- if (expandedClipIds) {
105
- for (const clip of clips) {
106
- if (expandedClipIds.has(clip.clipId)) laneCount = Math.max(laneCount, clip.laneCount);
107
- }
107
+ let automationLanes = 0;
108
+ for (const clip of clips) {
109
+ if (!expandedClipIds?.has(clip.clipId)) continue;
110
+ laneCount = Math.max(laneCount, clip.laneCount);
111
+ automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
108
112
  }
109
- return TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H;
113
+ return (
114
+ TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
115
+ );
110
116
  });
111
117
  }
112
118
 
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clampEdge } from "./useAutomationEdgeStretch";
3
+
4
+ describe("clampEdge", () => {
5
+ it("keeps the dragged edge inside the clip", () => {
6
+ expect(clampEdge("t0", -5, { t0: 1, t1: 3 }, 4)).toBe(0);
7
+ expect(clampEdge("t1", 99, { t0: 1, t1: 3 }, 4)).toBe(4);
8
+ });
9
+
10
+ it("keeps the dragged edge clear of its partner", () => {
11
+ expect(clampEdge("t0", 3.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(2.98, 5);
12
+ expect(clampEdge("t1", 0.5, { t0: 1, t1: 3 }, 4)).toBeCloseTo(1.02, 5);
13
+ });
14
+
15
+ it("never yields a negative time, even when the partner bound is itself below zero", () => {
16
+ // A selection thinner than the minimum width has no legal t0 at all. Bounding
17
+ // against the partner AFTER the 0-floor returned that illegal value, and
18
+ // core's cleanPoint collapses a negative time onto a duplicate t=0 on the
19
+ // next serialize round-trip — silent envelope corruption, not a visible bug.
20
+ expect(clampEdge("t0", -1, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
21
+ expect(clampEdge("t0", 0.008, { t0: 0.004, t1: 0.01 }, 4)).toBe(0);
22
+ });
23
+
24
+ it("never yields a time past the clip when the partner bound is past it", () => {
25
+ expect(clampEdge("t1", 99, { t0: 3.995, t1: 4 }, 4)).toBe(4);
26
+ });
27
+ });