@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.107",
3
+ "version": "0.7.108",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/player": "0.7.107",
51
- "@hyperframes/parsers": "0.7.107",
52
- "@hyperframes/studio-server": "0.7.107",
53
- "@hyperframes/sdk": "0.7.107",
54
- "@hyperframes/core": "0.7.107"
50
+ "@hyperframes/core": "0.7.108",
51
+ "@hyperframes/parsers": "0.7.108",
52
+ "@hyperframes/player": "0.7.108",
53
+ "@hyperframes/studio-server": "0.7.108",
54
+ "@hyperframes/sdk": "0.7.108"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.7.107"
71
+ "@hyperframes/producer": "0.7.108"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -124,6 +124,7 @@ export function StudioRightPanel({
124
124
  handleDomStyleCommit,
125
125
  handleDomAttributeCommit,
126
126
  handleDomAttributeLiveCommit,
127
+ handleDomAttributeQuietCommit,
127
128
  handleDomHtmlAttributeCommit,
128
129
  handleDomAttributesCommit,
129
130
  handleDomPathOffsetCommit,
@@ -326,6 +327,21 @@ export function StudioRightPanel({
326
327
  },
327
328
  [projectId, refreshFileTree, showToast],
328
329
  );
330
+
331
+ /**
332
+ * A dial being dragged writes to the preview and stops there.
333
+ *
334
+ * Every one of these panels previews on each pointermove and commits on
335
+ * release. Persisting the moves too put a fragment of the drag in the undo
336
+ * stack — and since those writes race, history could not coalesce them
337
+ * reliably, so undo took back a sliver of the gesture rather than the gesture.
338
+ * The release's own commit is what reaches the file and the undo stack.
339
+ */
340
+ const setAttributeWhileDragging = useCallback(
341
+ (attr: string, value: string | null) =>
342
+ handleDomAttributeLiveCommit(attr, value, undefined, { previewOnly: true }),
343
+ [handleDomAttributeLiveCommit],
344
+ );
329
345
  const handleHideAllSelected = () => {
330
346
  const { elements } = usePlayerStore.getState();
331
347
  const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
@@ -360,7 +376,8 @@ export function StudioRightPanel({
360
376
  onSetStyle={handleDomStyleCommit}
361
377
  onSetAttribute={handleDomAttributeCommit}
362
378
  onSetAttributes={handleDomAttributesCommit}
363
- onSetAttributeLive={handleDomAttributeLiveCommit}
379
+ onSetAttributeLive={setAttributeWhileDragging}
380
+ onSetAttributeQuiet={handleDomAttributeQuietCommit}
364
381
  onApplyColorGradingScope={handleApplyColorGradingScope}
365
382
  onSetHtmlAttribute={handleDomHtmlAttributeCommit}
366
383
  onRemoveBackground={handleRemoveBackground}
@@ -118,3 +118,49 @@ describe("TimelineToolbar — motion path endpoints", () => {
118
118
  act(() => root.unmount());
119
119
  });
120
120
  });
121
+
122
+ describe("TimelineToolbar — keyframes on audio tracks", () => {
123
+ const clip = (tag: string) => ({
124
+ id: "bgm",
125
+ key: "bgm",
126
+ tag,
127
+ start: 0,
128
+ duration: 10,
129
+ track: 1,
130
+ });
131
+
132
+ /** A session whose selection would otherwise offer the keyframe toggle. */
133
+ function sessionFor(tag: string) {
134
+ usePlayerStore.setState({ elements: [clip(tag)], selectedElementId: "bgm", currentTime: 1 });
135
+ const element = document.createElement(tag);
136
+ element.id = "bgm";
137
+ return {
138
+ domEditSelection: makeSelection("Element", element),
139
+ selectedGsapAnimations: [],
140
+ handleGsapAddAnimation: vi.fn(),
141
+ handleGsapConvertToKeyframes: vi.fn(),
142
+ handleGsapRemoveKeyframe: vi.fn(),
143
+ } satisfies NonNullable<React.ComponentProps<typeof TimelineToolbar>["domEditSession"]>;
144
+ }
145
+
146
+ it("offers no keyframe toggle for an audio clip", () => {
147
+ // An audio clip has no box on the canvas, so there is nothing to move or fade —
148
+ // and pressing this seeded a tween from the position properties, which put a
149
+ // position lane on a track that has no position. Audio is automated instead.
150
+ const { host, root } = renderToolbar(sessionFor("audio"));
151
+ const button = host.querySelector<HTMLButtonElement>(
152
+ 'button[aria-label="Add keyframe at playhead"]',
153
+ );
154
+ expect(button?.disabled).toBe(true);
155
+ act(() => root.unmount());
156
+ });
157
+
158
+ it("still offers it for a visual clip", () => {
159
+ const { host, root } = renderToolbar(sessionFor("div"));
160
+ const button = host.querySelector<HTMLButtonElement>(
161
+ 'button[aria-label="Add keyframe at playhead"]',
162
+ );
163
+ expect(button?.disabled).toBe(false);
164
+ act(() => root.unmount());
165
+ });
166
+ });
@@ -86,8 +86,22 @@ function resolveKeyframeToggleState(
86
86
  };
87
87
  }
88
88
 
89
+ /**
90
+ * Can this element be keyframed at all?
91
+ *
92
+ * An audio clip cannot. It has no box on the canvas, so there is nothing to move,
93
+ * scale or fade — and "add a keyframe" on one seeds a tween from the position
94
+ * properties, which produced a position lane on a track that has no position. Audio
95
+ * is automated instead: volume and effect parameters, on their own lanes.
96
+ */
97
+ function isKeyframeable(element: TimelineElement | undefined): boolean {
98
+ return element?.tag !== "audio";
99
+ }
100
+
89
101
  function useKeyframeToggle(session?: DomEditSessionSlice) {
90
102
  const currentTime = usePlayerStore((s) => s.currentTime);
103
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
104
+ const elements = usePlayerStore((s) => s.elements);
91
105
  const sessionRef = useRef(session);
92
106
  sessionRef.current = session;
93
107
 
@@ -95,6 +109,9 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
95
109
  sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
96
110
  );
97
111
 
112
+ const selected = elements.find((element) => (element.key ?? element.id) === selectedElementId);
113
+ if (!isKeyframeable(selected)) return { ...NO_KEYFRAME_TOGGLE, onToggle: undefined };
114
+
98
115
  const toggleState = resolveKeyframeToggleState(session, currentTime);
99
116
 
100
117
  return {
@@ -238,7 +238,11 @@ async function renderPanel(
238
238
  // renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
239
239
  // flag read); transforming the full section graph uncached can exceed the 5s
240
240
  // default under heavy parallel full-suite load, so give these a wider margin.
241
- const RENDER_TIMEOUT_MS = 20_000;
241
+ // 20s itself has now been observed timing out in CI's full-monorepo run (the
242
+ // same suite passes in well under 2s standalone) — widened again rather than
243
+ // re-tuned down to a number that will just need doing again next time CI adds
244
+ // load.
245
+ const RENDER_TIMEOUT_MS = 45_000;
242
246
 
243
247
  // Find the collapsed accordion row whose title matches and click it open.
244
248
  function openFlatGroup(host: HTMLElement, title: string) {
@@ -1,5 +1,5 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { memo, useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useMemo, useRef, useState } from "react";
3
3
  import { Move } from "../../icons/SystemIcons";
4
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
5
5
  import { useStudioShellContext } from "../../contexts/StudioContext";
@@ -29,7 +29,8 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
29
29
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./manualEditingAvailability";
30
30
  import { PropertyPanelFlat } from "./PropertyPanelFlat";
31
31
  import { createGsapLivePreview } from "./gsapLivePreview";
32
- import { usePlayerStore, liveTime } from "../../player";
32
+ import { usePlayerStore } from "../../player";
33
+ import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
33
34
  import { TimingSection } from "./propertyPanelTimingSection";
34
35
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
35
36
  import { GestureRecordPanelButton } from "./GestureRecordControl";
@@ -114,31 +115,14 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
114
115
  const { showToast } = useStudioShellContext();
115
116
  const [clipboardCopied, setClipboardCopied] = useState(false);
116
117
  const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
117
- const storeTime = usePlayerStore((s) => s.currentTime);
118
- const isPlaying = usePlayerStore((s) => s.isPlaying);
119
118
  const timelineElements = usePlayerStore((s) => s.elements);
120
119
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
121
120
  const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
122
121
  const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
123
- const liveTimeRef = useRef(storeTime);
124
- const [, forceRender] = useState(0);
125
- useEffect(() => {
126
- if (!isPlaying) return;
127
- let timerId: ReturnType<typeof setTimeout> | 0 = 0;
128
- const unsub = liveTime.subscribe((t) => {
129
- liveTimeRef.current = t;
130
- if (!timerId)
131
- timerId = setTimeout(() => {
132
- timerId = 0;
133
- forceRender((v) => v + 1);
134
- }, 33);
135
- });
136
- return () => {
137
- unsub();
138
- if (timerId) clearTimeout(timerId);
139
- };
140
- }, [isPlaying]);
141
- const currentTime = isPlaying ? liveTimeRef.current : storeTime;
122
+ // Live during playback, the store's when paused — see the hook. Shared with the
123
+ // audio FX panel, which follows the playhead for the same reason: a value the
124
+ // timeline drives has to be shown moving, not frozen at what the attribute says.
125
+ const currentTime = useLivePlayheadTime();
142
126
  const cacheElementKey = element?.id ?? element?.selector ?? "";
143
127
  const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
144
128
 
@@ -6,6 +6,7 @@ import { slugifyDesignInput } from "../../utils/designInputTracking";
6
6
  import { isTextEditableSelection } from "./domEditing";
7
7
  import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps";
8
8
  import { formatPxMetricValue } from "./propertyPanelHelpers";
9
+ import { audioFxSummary } from "./audioFxSummary";
9
10
  import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
10
11
  import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter";
11
12
  import { FlatGroupHeader } from "./propertyPanelFlatPrimitives";
@@ -13,6 +14,9 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
13
14
  import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
14
15
  import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
15
16
  import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
17
+ import { isCanaryEnabled } from "../../telemetry/canary";
18
+ import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
19
+ import { useVolumeAutomation } from "./useVolumeAutomation";
16
20
  import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
17
21
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
18
22
  import { createGsapLivePreview } from "./gsapLivePreview";
@@ -64,6 +68,7 @@ export function PropertyPanelFlat({
64
68
  onSetAttribute,
65
69
  onSetAttributes,
66
70
  onSetAttributeLive,
71
+ onSetAttributeQuiet,
67
72
  onApplyColorGradingScope,
68
73
  onSetHtmlAttribute,
69
74
  onRemoveBackground,
@@ -252,6 +257,8 @@ export function PropertyPanelFlat({
252
257
  const showMotionEffects = gsapEffectHandlers !== null;
253
258
  const showMotionGroup = showMotionTiming || showMotionEffects;
254
259
 
260
+ const volumeAutomation = useVolumeAutomation(element, onSetAttributeQuiet ?? onSetAttributeLive);
261
+
255
262
  const groups: FlatGroupDescriptor[] = [];
256
263
  if (isTextEditable) {
257
264
  groups.push({
@@ -420,6 +427,23 @@ export function PropertyPanelFlat({
420
427
  });
421
428
  }
422
429
  }
430
+ // Behind `audio-fx-rack`, at 0%. Gates the AUTHORING surface only: the runtime
431
+ // and render still honour a `data-fx-chain` already on an element, so a
432
+ // composition written through the skill does not go silently dry off-cohort.
433
+ if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
434
+ groups.push({
435
+ id: "audio-fx",
436
+ title: "Audio FX",
437
+ summary: audioFxSummary(element),
438
+ content: (
439
+ <AudioFxGroup
440
+ element={element}
441
+ onSetAttributeQuiet={onSetAttributeQuiet ?? onSetAttributeLive}
442
+ onSetAttributeLive={onSetAttributeLive}
443
+ />
444
+ ),
445
+ });
446
+ }
423
447
  if (sections.media) {
424
448
  groups.push({
425
449
  id: "media",
@@ -434,6 +458,7 @@ export function PropertyPanelFlat({
434
458
  onSetAttribute={onSetAttribute}
435
459
  onSetHtmlAttribute={onSetHtmlAttribute}
436
460
  onRemoveBackground={onRemoveBackground}
461
+ {...volumeAutomation}
437
462
  />
438
463
  ),
439
464
  });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { audioFxSummary } from "./audioFxSummary";
3
+ import type { DomEditSelection } from "./domEditingTypes";
4
+
5
+ const el = (dataAttributes: Record<string, string>): DomEditSelection =>
6
+ ({ dataAttributes }) as unknown as DomEditSelection;
7
+
8
+ const chain = (nodes: unknown[]) => JSON.stringify({ version: 1, nodes });
9
+
10
+ describe("audioFxSummary", () => {
11
+ it("counts a carve as one module, not as the filters behind it", () => {
12
+ // Six bands and a level stage reading "7 effects" is the misreading the
13
+ // grouping exists to prevent.
14
+ const summary = audioFxSummary(
15
+ el({
16
+ "fx-chain": chain([
17
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
18
+ { type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1600 } },
19
+ { type: "gain", id: "n3", fromCarve: true, params: { gain: -6 } },
20
+ ]),
21
+ "fx-carve": JSON.stringify({ source: "vo", strength: 0.25 }),
22
+ }),
23
+ );
24
+ expect(summary).toBe("carve");
25
+ });
26
+
27
+ it("counts hand-built effects alongside the module", () => {
28
+ expect(
29
+ audioFxSummary(
30
+ el({
31
+ "fx-chain": chain([
32
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400 } },
33
+ { type: "lowpass", id: "n2", params: { frequency: 8000 } },
34
+ { type: "delay", id: "n3", params: { time: 200 } },
35
+ ]),
36
+ }),
37
+ ),
38
+ ).toBe("2 effects + carve");
39
+ });
40
+
41
+ it("says how many when there is no carve", () => {
42
+ expect(audioFxSummary(el({ "fx-chain": chain([{ type: "lowpass", id: "n1" }]) }))).toBe(
43
+ "1 effect",
44
+ );
45
+ });
46
+
47
+ it("names a carve that is on but has not compiled to filters yet", () => {
48
+ // Switching it on with no voice chosen leaves the control in this section with
49
+ // nothing behind it; the summary should still say the section holds one.
50
+ expect(audioFxSummary(el({ "fx-carve": JSON.stringify({ source: "", strength: 0.25 }) }))).toBe(
51
+ "carve",
52
+ );
53
+ });
54
+
55
+ it("ignores bypassed effects, as it always did", () => {
56
+ expect(
57
+ audioFxSummary(
58
+ el({
59
+ "fx-chain": chain([
60
+ { type: "lowpass", id: "n1", enabled: false },
61
+ { type: "delay", id: "n2" },
62
+ ]),
63
+ }),
64
+ ),
65
+ ).toBe("1 effect");
66
+ });
67
+
68
+ it("says none for a track with neither", () => {
69
+ expect(audioFxSummary(el({}))).toBe("none");
70
+ });
71
+
72
+ it("says so when the chain cannot be read", () => {
73
+ expect(audioFxSummary(el({ "fx-chain": "{not json" }))).toBe("unreadable");
74
+ });
75
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What the collapsed Audio FX group says it holds.
3
+ *
4
+ * It has to describe the rack the author would see on opening it, which counts a
5
+ * carve as one module rather than as the filters it compiles to. Six peaking
6
+ * bands and a level stage reading "7 effects" invited exactly the misreading the
7
+ * grouping exists to prevent — that they are seven things to manage.
8
+ */
9
+
10
+ import { HF_AUDIO_FX_DATA_KEY, parseAudioFxChain } from "@hyperframes/core/audio-fx";
11
+ import type { DomEditSelection } from "./domEditingTypes";
12
+
13
+ export function audioFxSummary(element: DomEditSelection): string {
14
+ const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
15
+ const carveAttr = element.dataAttributes?.["fx-carve"];
16
+ let handBuilt = 0;
17
+ let carveNodes = 0;
18
+ if (raw) {
19
+ try {
20
+ for (const node of parseAudioFxChain(raw).nodes) {
21
+ if (node.enabled === false) continue;
22
+ if (node.fromCarve) carveNodes += 1;
23
+ else handBuilt += 1;
24
+ }
25
+ } catch {
26
+ return "unreadable";
27
+ }
28
+ }
29
+ const parts: string[] = [];
30
+ if (handBuilt > 0) parts.push(`${handBuilt} effect${handBuilt === 1 ? "" : "s"}`);
31
+ // One name for the module however many filters are behind it. Named when the
32
+ // carve is switched on at all, because the control is in this section whether or
33
+ // not it has compiled to anything yet.
34
+ if (carveNodes > 0 || carveAttr) parts.push("carve");
35
+ return parts.length > 0 ? parts.join(" + ") : "none";
36
+ }
@@ -0,0 +1,129 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi, beforeEach } from "vitest";
4
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
5
+
6
+ const trackStudioEvent = vi.fn();
7
+ vi.mock("../../utils/studioTelemetry", () => ({
8
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
9
+ }));
10
+
11
+ const { chainShape, nodeOrigin, trackChainObserved, trackParamCommitted, trackPresetApplied } =
12
+ await import("./audioFxTelemetry");
13
+
14
+ beforeEach(() => trackStudioEvent.mockReset());
15
+
16
+ const chainOf = (nodes: HfAudioFxChain["nodes"]): HfAudioFxChain => ({ version: 1, nodes });
17
+
18
+ describe("what the rack reports", () => {
19
+ it("namespaces every event so the feature can be found as one thing", () => {
20
+ trackPresetApplied("voice-clean", "voice", 5, "append", { trackKind: "voice" });
21
+ expect(trackStudioEvent).toHaveBeenCalledWith("audio_fx_preset_applied", expect.anything());
22
+ });
23
+
24
+ it("carries what the track is, so a preset's use can be read per material", () => {
25
+ trackPresetApplied("telephone", "character", 7, "append", { trackKind: "music" });
26
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
27
+ preset: "telephone",
28
+ family: "character",
29
+ track_kind: "music",
30
+ });
31
+ });
32
+
33
+ it("says 'unknown' rather than nothing when the track is unclassified", () => {
34
+ // Absent and "unclassified" are different facts; encoding them the same way
35
+ // is what makes a breakdown read as a gap that is not there.
36
+ trackPresetApplied("hall", "space", 1, "append", {});
37
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({ track_kind: "unknown" });
38
+ });
39
+
40
+ it("separates the two surfaces a parameter can be set from", () => {
41
+ // The whole one-knob design rests on which of these people use.
42
+ trackParamCommitted("compressor", "ratio", 4, "details", {});
43
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
44
+ effect: "compressor",
45
+ param: "ratio",
46
+ value: 4,
47
+ surface: "details",
48
+ });
49
+ });
50
+ });
51
+
52
+ describe("a chain reduced to counts", () => {
53
+ it("names where each node came from", () => {
54
+ expect(nodeOrigin({ type: "peaking", fromPreset: "voice-clean" })).toBe("preset");
55
+ expect(nodeOrigin({ type: "peaking", fromCarve: true })).toBe("carve");
56
+ expect(nodeOrigin({ type: "gain", fromLeveller: true })).toBe("leveller");
57
+ expect(nodeOrigin({ type: "highpass" })).toBe("hand");
58
+ });
59
+
60
+ it("counts by origin, which is what separates hand-built from generated", () => {
61
+ const shape = chainShape(
62
+ chainOf([
63
+ { type: "highpass", fromPreset: "voice-clean" },
64
+ { type: "peaking", fromPreset: "voice-clean" },
65
+ { type: "peaking", fromCarve: true },
66
+ { type: "lowpass" },
67
+ ]),
68
+ );
69
+ expect(shape).toMatchObject({
70
+ node_count: 4,
71
+ nodes_from_preset: 2,
72
+ nodes_from_carve: 1,
73
+ nodes_by_hand: 1,
74
+ preset_count: 1,
75
+ });
76
+ });
77
+
78
+ it("counts distinct presets, not preset nodes — stacking is the question", () => {
79
+ const shape = chainShape(
80
+ chainOf([
81
+ { type: "highpass", fromPreset: "voice-clean" },
82
+ { type: "lowpass", fromPreset: "telephone" },
83
+ { type: "peaking", fromPreset: "telephone" },
84
+ ]),
85
+ );
86
+ expect(shape.preset_count).toBe(2);
87
+ });
88
+
89
+ it("survives an empty or absent chain", () => {
90
+ expect(chainShape(null)).toMatchObject({ node_count: 0, preset_count: 0 });
91
+ });
92
+
93
+ it("ships no chain contents — only counts and flags", () => {
94
+ // The standing rule: nothing user-authored leaves the browser. A parameter
95
+ // value or an element id in here would be the author's own data.
96
+ const shape = chainShape(chainOf([{ type: "peaking", id: "n1", params: { frequency: 3000 } }]));
97
+ const serialized = JSON.stringify(shape);
98
+ expect(serialized).not.toContain("3000");
99
+ expect(serialized).not.toContain("n1");
100
+ });
101
+ });
102
+
103
+ describe("effects this session did not apply", () => {
104
+ it("calls a chain with no panel edits behind it authored outside", () => {
105
+ // This is the agent signal. An agent edits the composition and the panel
106
+ // simply finds the work done — none of its own events ever fire.
107
+ trackChainObserved(
108
+ chainOf([{ type: "highpass", fromPreset: "voice-clean" }]),
109
+ { firstSight: true, panelEdits: 0, hasCarve: false, hasAutomation: false },
110
+ { trackKind: "voice" },
111
+ );
112
+ expect(trackStudioEvent).toHaveBeenCalledWith(
113
+ "audio_fx_chain_observed",
114
+ expect.objectContaining({ authored_outside: true, first_sight: true }),
115
+ );
116
+ });
117
+
118
+ it("does not, when this session's own edits explain it", () => {
119
+ trackChainObserved(
120
+ chainOf([{ type: "highpass" }]),
121
+ { firstSight: false, panelEdits: 3, hasCarve: false, hasAutomation: false },
122
+ {},
123
+ );
124
+ expect(trackStudioEvent.mock.calls[0]?.[1]).toMatchObject({
125
+ authored_outside: false,
126
+ panel_edits: 3,
127
+ });
128
+ });
129
+ });