@hyperframes/studio 0.7.107 → 0.7.109

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-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -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,136 @@
1
+ /**
2
+ * Internal clipboard for automation ranges. Module-level, not the OS
3
+ * clipboard — points are not text, and useClipboard is already the DOM-element
4
+ * channel. Values cross parameters through unit space, so a volume duck
5
+ * pasted onto a log-scaled wet knob lands proportionally, not literally.
6
+ *
7
+ * Project-scoped by itself rather than by a caller, because the failure is
8
+ * destructive and silent: a range copied in project A pasted into B is remapped
9
+ * through A's captured `sourceRange` for an FX node B may not even have, and the
10
+ * keystroke is consumed so clip paste never runs. Every entry point carries the
11
+ * project it is speaking for and a mismatch empties the module, so no future
12
+ * caller can forget the guard — the same shape `keyframeSlice` uses to discard a
13
+ * request from a previous session.
14
+ */
15
+ import {
16
+ sampleAutomationLane,
17
+ type AutomationRange,
18
+ type HfAutomationLane,
19
+ type HfAutomationPoint,
20
+ } from "@hyperframes/core/audio-automation";
21
+ import { fromUnit, toUnit } from "./automationLaneGeometry";
22
+ import { pointsIn } from "./automationLaneSelection";
23
+
24
+ export interface AutomationClipboardEntry {
25
+ sourceRange: AutomationRange;
26
+ span: number;
27
+ points: HfAutomationPoint[];
28
+ }
29
+
30
+ /** The span one paste covered, in the same shape as the selection it left. */
31
+ export interface AutomationPasteMark {
32
+ elementKey: string;
33
+ target: string;
34
+ t0: number;
35
+ t1: number;
36
+ }
37
+
38
+ let ownerProjectId: string | null = null;
39
+ let entry: AutomationClipboardEntry | null = null;
40
+ let lastPaste: AutomationPasteMark | null = null;
41
+
42
+ /**
43
+ * Rebind the module to `projectId`, discarding anything captured under a
44
+ * different one. Called by both entry points, so the mark is scoped
45
+ * transitively: `isLastPasteSpan` can only ever see a mark left by a paste in
46
+ * the project that most recently read the clipboard.
47
+ *
48
+ * `null` (no timeline session yet) is a project id like any other — it can hold
49
+ * an entry, and the first real session id evicts it.
50
+ */
51
+ function useProject(projectId: string | null): void {
52
+ if (projectId === ownerProjectId) return;
53
+ ownerProjectId = projectId;
54
+ entry = null;
55
+ lastPaste = null;
56
+ }
57
+
58
+ /**
59
+ * Capture `[t0, t1]` rebased to zero. Returns false — leaving the clipboard as
60
+ * it was — when the lane has nothing to capture.
61
+ *
62
+ * `pointsIn` reports only explicit breakpoints, so a range drawn over a smooth
63
+ * stretch of envelope has none. Storing that as `points: []` would arm a
64
+ * clipboard whose paste is byte-identical to the Delete write, so every later
65
+ * Cmd+V would erase its destination instead of reproducing the copied shape.
66
+ * Over a lane that HAS an envelope the range is a real flat or sloped segment,
67
+ * captured by sampling both edges (what `replaceRange`'s own anchors do). Over
68
+ * an entirely empty lane there is no shape at all, so nothing is stored — a
69
+ * failed copy leaves an earlier clipboard alone rather than destroying it.
70
+ */
71
+ export function copyRange(
72
+ projectId: string | null,
73
+ lane: HfAutomationLane,
74
+ range: AutomationRange,
75
+ t0: number,
76
+ t1: number,
77
+ ): boolean {
78
+ const inner = pointsIn(lane, t0, t1).map((p) => ({ ...p, t: p.t - t0 }));
79
+ if (inner.length === 0 && lane.points.length === 0) return false;
80
+ useProject(projectId);
81
+ entry = {
82
+ sourceRange: range,
83
+ span: t1 - t0,
84
+ points:
85
+ inner.length > 0
86
+ ? inner
87
+ : [
88
+ { t: 0, v: sampleAutomationLane(lane, t0, range.scale) },
89
+ { t: t1 - t0, v: sampleAutomationLane(lane, t1, range.scale) },
90
+ ],
91
+ };
92
+ return true;
93
+ }
94
+
95
+ export function readClipboard(projectId: string | null): AutomationClipboardEntry | null {
96
+ useProject(projectId);
97
+ return entry;
98
+ }
99
+
100
+ export function pastePoints(
101
+ from: AutomationClipboardEntry,
102
+ target: AutomationRange,
103
+ atT: number,
104
+ ): HfAutomationPoint[] {
105
+ return from.points.map((p) => ({
106
+ ...p,
107
+ t: atT + p.t,
108
+ v: fromUnit(target, toUnit(from.sourceRange, p.v)),
109
+ }));
110
+ }
111
+
112
+ /**
113
+ * Remember the span a paste just covered and left selected, so the next Cmd+V
114
+ * can tell that selection apart from one the user drew and chain after it
115
+ * instead of overwriting it.
116
+ */
117
+ export function markLastPaste(mark: AutomationPasteMark): void {
118
+ lastPaste = { ...mark };
119
+ }
120
+
121
+ /** True when `mark` is exactly the span the last paste left selected. */
122
+ export function isLastPasteSpan(mark: AutomationPasteMark): boolean {
123
+ if (!lastPaste) return false;
124
+ return (
125
+ lastPaste.elementKey === mark.elementKey &&
126
+ lastPaste.target === mark.target &&
127
+ lastPaste.t0 === mark.t0 &&
128
+ lastPaste.t1 === mark.t1
129
+ );
130
+ }
131
+
132
+ export function clearAutomationClipboard(): void {
133
+ ownerProjectId = null;
134
+ entry = null;
135
+ lastPaste = null;
136
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createAutomationGestureKeys } from "./automationGestureKeys";
3
+
4
+ const keys = () => {
5
+ let n = 0;
6
+ return createAutomationGestureKeys(() => `g${++n}`);
7
+ };
8
+
9
+ describe("createAutomationGestureKeys", () => {
10
+ it("holds one key across every move of a drag", () => {
11
+ // Each move persists; history merges same-key entries inside 300ms. Separate
12
+ // keys, or a window that can expire mid-drag, is what made undo take back a
13
+ // fragment of the move instead of the move.
14
+ const g = keys();
15
+ expect([g.live().key, g.live().key, g.live().key]).toEqual(["g1", "g1", "g1"]);
16
+ });
17
+
18
+ it("never lets the window expire", () => {
19
+ expect(keys().live().ms).toBe(Number.POSITIVE_INFINITY);
20
+ expect(keys().commit().ms).toBe(Number.POSITIVE_INFINITY);
21
+ });
22
+
23
+ it("ends the gesture on the release, under the same key", () => {
24
+ const g = keys();
25
+ g.live();
26
+ expect(g.commit().key).toBe("g1");
27
+ });
28
+
29
+ it("starts a fresh key for the next drag", () => {
30
+ const g = keys();
31
+ g.live();
32
+ g.commit();
33
+ expect(g.live().key).toBe("g2");
34
+ });
35
+
36
+ it("gives a standalone commit its own key", () => {
37
+ // Deleting a range, pasting one, typing a value: one write, one step each.
38
+ const g = keys();
39
+ expect(g.commit().key).toBe("g1");
40
+ expect(g.commit().key).toBe("g2");
41
+ });
42
+
43
+ it("does not join a commit to a gesture that already ended", () => {
44
+ const g = keys();
45
+ g.live();
46
+ g.commit();
47
+ expect(g.commit().key).toBe("g2");
48
+ });
49
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Undo grouping for a lane gesture.
3
+ *
4
+ * Dragging a point persists on every move and once more on release. History
5
+ * coalesces entries that share a key and land within 300ms of each other, and
6
+ * these writes did neither: the moves and the release used different keys, and a
7
+ * drag slower than the window split into several entries. Undo then took back
8
+ * whatever fragment happened to be last, leaving the point near where the drag
9
+ * had dropped it — which reads as undo not working at all.
10
+ *
11
+ * So a gesture mints one key and holds it: every move and the release that ends
12
+ * it record under that key with no window to expire, and the next gesture gets a
13
+ * fresh one so two drags never collapse into a single step.
14
+ */
15
+
16
+ export interface CoalesceHint {
17
+ key: string;
18
+ /** No expiry: a gesture is one step however long the pointer is held. */
19
+ ms: number;
20
+ }
21
+
22
+ export interface AutomationGestureKeys {
23
+ /** A continuous write. Opens a gesture if one is not already open. */
24
+ live(): CoalesceHint;
25
+ /** The persisting write that ends a gesture — or a standalone edit. */
26
+ commit(): CoalesceHint;
27
+ }
28
+
29
+ export function createAutomationGestureKeys(
30
+ mint: () => string = () => `automation-gesture:${++counter}`,
31
+ ): AutomationGestureKeys {
32
+ let open: string | null = null;
33
+ const hint = (key: string): CoalesceHint => ({ key, ms: Number.POSITIVE_INFINITY });
34
+ return {
35
+ live: () => {
36
+ open ??= mint();
37
+ return hint(open);
38
+ },
39
+ commit: () => {
40
+ // A commit with no drag before it — a Delete, a paste, a typed value — is
41
+ // its own step, so it mints rather than joining anything.
42
+ const key = open ?? mint();
43
+ open = null;
44
+ return hint(key);
45
+ },
46
+ };
47
+ }
48
+
49
+ let counter = 0;
@@ -0,0 +1,270 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ automationLaneLabel,
5
+ automationLaneLabelParts,
6
+ elementAutomation,
7
+ groupAutomationLanes,
8
+ laneGroupKey,
9
+ elementFxChain,
10
+ } from "./automationLaneData";
11
+ import type { TimelineElement } from "../store/timelineElement";
12
+
13
+ const el = (over: Partial<TimelineElement> = {}): TimelineElement => ({
14
+ id: "bgm",
15
+ key: "bgm",
16
+ tag: "audio",
17
+ start: 0,
18
+ duration: 10,
19
+ track: 10,
20
+ ...over,
21
+ });
22
+
23
+ /** The chain as the lane code sees it: parsed, not the attribute text. */
24
+ const parseChain = (chain: unknown) => elementFxChain(el({ fxChain: JSON.stringify(chain) }))!;
25
+
26
+ const CHAIN = JSON.stringify({
27
+ version: 1,
28
+ nodes: [{ type: "lowpass", id: "n1", params: { frequency: 400, q: 0.9, poles: "2" } }],
29
+ });
30
+ const LANE = JSON.stringify({
31
+ version: 1,
32
+ lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
33
+ });
34
+
35
+ describe("automationLaneData", () => {
36
+ it("returns the same object for the same attributes", () => {
37
+ // The lane compares its drag draft against this identity; a fresh object per
38
+ // playhead tick would drop the drag.
39
+ const a = elementAutomation(el({ automation: LANE, fxChain: CHAIN }));
40
+ const b = elementAutomation(el({ automation: LANE, fxChain: CHAIN }));
41
+ expect(a).toBe(b);
42
+ expect(elementFxChain(el({ fxChain: CHAIN }))).toBe(elementFxChain(el({ fxChain: CHAIN })));
43
+ });
44
+
45
+ it("re-parses when the chain changes even though the automation text did not", () => {
46
+ const withChain = elementAutomation(el({ automation: LANE, fxChain: CHAIN }));
47
+ const withoutChain = elementAutomation(el({ automation: LANE }));
48
+ expect(withChain.lanes.map((l) => l.target)).toEqual(["fx.n1.frequency"]);
49
+ // No chain to resolve against, so the fx lane is dropped rather than drawn.
50
+ expect(withoutChain.lanes).toEqual([]);
51
+ });
52
+
53
+ it("keeps a hot entry alive when other elements push the cache past its limit", () => {
54
+ // Eviction used to clear the whole map, which changed every lane's identity
55
+ // at once and released any drag in progress.
56
+ const hot = el({ automation: LANE, fxChain: CHAIN });
57
+ const first = elementAutomation(hot);
58
+ for (let i = 0; i < 40; i += 1) {
59
+ elementAutomation(
60
+ el({
61
+ automation: JSON.stringify({
62
+ version: 1,
63
+ lanes: [{ target: "volume", points: [{ t: i, v: 0.5 }] }],
64
+ }),
65
+ }),
66
+ );
67
+ elementAutomation(hot);
68
+ }
69
+ expect(elementAutomation(hot)).toBe(first);
70
+ });
71
+
72
+ it("reads an unreadable attribute as nothing rather than throwing", () => {
73
+ expect(elementAutomation(el({ automation: "{nope" })).lanes).toEqual([]);
74
+ expect(elementFxChain(el({ fxChain: "{nope" }))).toBeNull();
75
+ });
76
+
77
+ describe("lane order", () => {
78
+ // A stack of EQ bands reads as a spectrum, so it has to be laid out like one:
79
+ // the top of the stack is the top of the audible range. Attribute order is
80
+ // whatever the carve happened to mint, which is the opposite — bands come out
81
+ // ascending.
82
+ const bandChain = JSON.stringify({
83
+ version: 1,
84
+ nodes: [
85
+ { type: "peaking", id: "n1", params: { frequency: 400, gain: -6, q: 1.4 } },
86
+ { type: "peaking", id: "n2", params: { frequency: 1600, gain: -9, q: 1.4 } },
87
+ { type: "peaking", id: "n3", params: { frequency: 1000, gain: -3, q: 1.4 } },
88
+ { type: "gain", id: "n4", params: { gain: -6 } },
89
+ ],
90
+ });
91
+ const bandLanes = JSON.stringify({
92
+ version: 1,
93
+ lanes: [
94
+ { target: "volume", points: [{ t: 0, v: 1 }] },
95
+ { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
96
+ { target: "fx.n2.gain", points: [{ t: 0, v: -9 }] },
97
+ { target: "fx.n3.gain", points: [{ t: 0, v: -3 }] },
98
+ { target: "fx.n4.gain", points: [{ t: 0, v: -6 }] },
99
+ ],
100
+ });
101
+
102
+ it("puts the highest frequency at the top", () => {
103
+ const lanes = elementAutomation(el({ automation: bandLanes, fxChain: bandChain })).lanes;
104
+ expect(lanes.map((l) => l.target)).toEqual([
105
+ "fx.n2.gain", // 1.6 kHz
106
+ "fx.n3.gain", // 1 kHz
107
+ "fx.n1.gain", // 400 Hz
108
+ // Neither of these is a band, so they sit under the spectrum in the order
109
+ // they were written.
110
+ "volume",
111
+ "fx.n4.gain",
112
+ ]);
113
+ });
114
+
115
+ it("keeps the same object identity, so a drag survives the sort", () => {
116
+ const a = elementAutomation(el({ automation: bandLanes, fxChain: bandChain }));
117
+ const b = elementAutomation(el({ automation: bandLanes, fxChain: bandChain }));
118
+ expect(a).toBe(b);
119
+ });
120
+ });
121
+
122
+ describe("automationLaneLabel", () => {
123
+ const chain = parseChain({
124
+ version: 1,
125
+ nodes: [
126
+ { type: "peaking", id: "n1", params: { frequency: 1600, gain: -9, q: 1.4 } },
127
+ { type: "peaking", id: "n2", params: { frequency: 400, gain: -6, q: 1.4 } },
128
+ { type: "gain", id: "n3", params: { gain: -6 } },
129
+ ],
130
+ });
131
+
132
+ it("names the effect and the band it sits at", () => {
133
+ // Three lanes all reading "Peaking EQ · Gain" say nothing about which band
134
+ // each one is; a bare frequency does not say whether it is a bell or a
135
+ // shelf. Both, then the parameter.
136
+ expect(automationLaneLabel("fx.n1.gain", chain)).toBe("Peaking EQ 1.6 kHz · Gain");
137
+ expect(automationLaneLabel("fx.n2.gain", chain)).toBe("Peaking EQ 400 Hz · Gain");
138
+ });
139
+
140
+ it("falls back to the effect's name when it has no frequency", () => {
141
+ expect(automationLaneLabel("fx.n3.gain", chain)).toBe("Gain · Gain");
142
+ expect(automationLaneLabel("volume", chain)).toBe("Volume");
143
+ });
144
+
145
+ it("has nothing to say about a target that does not resolve", () => {
146
+ expect(automationLaneLabel("fx.gone.gain", chain)).toBeNull();
147
+ expect(automationLaneLabelParts("fx.gone.gain", chain)).toBeNull();
148
+ });
149
+
150
+ it("splits the name from the parameter, which the column stacks", () => {
151
+ expect(automationLaneLabelParts("fx.n1.gain", chain)).toEqual({
152
+ name: "Peaking EQ 1.6 kHz",
153
+ param: "Gain",
154
+ });
155
+ expect(automationLaneLabelParts("fx.n3.gain", chain)).toEqual({
156
+ name: "Gain",
157
+ param: "Gain",
158
+ });
159
+ // Volume is one word with no effect behind it, so there is no second line.
160
+ expect(automationLaneLabelParts("volume", chain)).toEqual({ name: "Volume", param: "" });
161
+ });
162
+ });
163
+ });
164
+
165
+ describe("laneGroupKey", () => {
166
+ // Two clips, each with their OWN chain, both automating a 1 kHz peaking Q. Node
167
+ // ids are minted per chain, so the ids collide across clips while meaning
168
+ // different things — and match across clips while meaning the same thing.
169
+ const clipA = parseChain({
170
+ version: 1,
171
+ nodes: [
172
+ { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
173
+ { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
174
+ ],
175
+ });
176
+ const clipB = parseChain({
177
+ version: 1,
178
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } }],
179
+ });
180
+
181
+ it("groups the same property of the same effect across clips", () => {
182
+ // The whole point: one row for "Peaking EQ 1 kHz · Q", whichever clip it is on.
183
+ expect(laneGroupKey("fx.n2.q", clipA)).toBe(laneGroupKey("fx.n1.q", clipB));
184
+ });
185
+
186
+ it("does not group by lane target, which collides across chains", () => {
187
+ // `fx.n1.q` is a low-pass on one clip and a peaking EQ on the other. Grouping by
188
+ // target would put those two envelopes in one row.
189
+ expect(laneGroupKey("fx.n1.q", clipA)).not.toBe(laneGroupKey("fx.n1.q", clipB));
190
+ });
191
+
192
+ it("separates parameters, and the same parameter on different bands", () => {
193
+ expect(laneGroupKey("fx.n2.q", clipA)).not.toBe(laneGroupKey("fx.n2.gain", clipA));
194
+ const twoBands = parseChain({
195
+ version: 1,
196
+ nodes: [
197
+ { type: "peaking", id: "n1", params: { frequency: 400, gain: -6, q: 1.4 } },
198
+ { type: "peaking", id: "n2", params: { frequency: 1600, gain: -6, q: 1.4 } },
199
+ ],
200
+ });
201
+ expect(laneGroupKey("fx.n1.gain", twoBands)).not.toBe(laneGroupKey("fx.n2.gain", twoBands));
202
+ });
203
+
204
+ it("groups volume with volume, and nothing with an unresolvable target", () => {
205
+ expect(laneGroupKey("volume", clipA)).toBe(laneGroupKey("volume", clipB));
206
+ expect(laneGroupKey("fx.gone.gain", clipA)).toBeNull();
207
+ });
208
+ });
209
+
210
+ describe("groupAutomationLanes", () => {
211
+ const chainOf = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
212
+ const lanesOf = (...targets: string[]) =>
213
+ JSON.stringify({
214
+ version: 1,
215
+ lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
216
+ });
217
+
218
+ // Two narration slices sharing a row. Each mints its own chain, so the node ids
219
+ // collide across them while meaning different things.
220
+ const narration1 = el({
221
+ id: "narration-1",
222
+ key: "narration-1",
223
+ fxChain: chainOf([
224
+ { type: "lowpass", id: "n1", params: { frequency: 8000, q: 0.7, poles: "2" } },
225
+ { type: "peaking", id: "n2", params: { frequency: 1000, gain: -3, q: 1.4 } },
226
+ ]),
227
+ automation: lanesOf("fx.n2.q"),
228
+ });
229
+ const narration2 = el({
230
+ id: "narration-2",
231
+ key: "narration-2",
232
+ fxChain: chainOf([
233
+ { type: "peaking", id: "n1", params: { frequency: 1000, gain: -6, q: 1.4 } },
234
+ ]),
235
+ automation: lanesOf("fx.n1.q", "volume"),
236
+ });
237
+
238
+ it("puts the same property of the same effect in one row, one entry per clip", () => {
239
+ const groups = groupAutomationLanes([narration1, narration2]);
240
+ expect(groups.map((g) => g.key)).toEqual(["Peaking EQ 1 kHz · Q", "Volume"]);
241
+ expect(groups[0]?.entries.map((e) => e.element.id)).toEqual(["narration-1", "narration-2"]);
242
+ expect(groups[0]).toMatchObject({ name: "Peaking EQ 1 kHz", param: "Q" });
243
+ });
244
+
245
+ it("leaves a clip out of a row it does not automate", () => {
246
+ // The empty stretch is the point: narration-1 has no volume envelope, so it
247
+ // contributes nothing to that row rather than a flat line claiming one exists.
248
+ const groups = groupAutomationLanes([narration1, narration2]);
249
+ expect(groups[1]?.entries.map((e) => e.element.id)).toEqual(["narration-2"]);
250
+ });
251
+
252
+ it("does not merge lanes whose targets collide across chains", () => {
253
+ // `fx.n1.*` is a low-pass on one clip and a peaking EQ on the other.
254
+ const lowpass = el({ ...narration1, automation: lanesOf("fx.n1.q") });
255
+ const peaking = el({ ...narration2, automation: lanesOf("fx.n1.q") });
256
+ const groups = groupAutomationLanes([lowpass, peaking]);
257
+ expect(groups.map((g) => g.key)).toEqual(["Low-pass 8 kHz · Q", "Peaking EQ 1 kHz · Q"]);
258
+ expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
259
+ });
260
+
261
+ it("ignores clips that are not audio, the way the reserved height does", () => {
262
+ const video = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
263
+ expect(groupAutomationLanes([video])).toEqual([]);
264
+ });
265
+
266
+ it("skips a target that does not resolve against its clip's chain", () => {
267
+ const stale = el({ ...narration2, automation: lanesOf("fx.gone.q", "volume") });
268
+ expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
269
+ });
270
+ });