@hyperframes/studio 0.7.106 → 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 (175) hide show
  1. package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BbYg_isc.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/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
  7. package/dist/chunk-AZYHQC6V.js.map +1 -0
  8. package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
  9. package/dist/index.d.ts +86 -3
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +24325 -17831
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/components/EditorShell.tsx +0 -2
  15. package/src/components/StudioErrorBoundary.test.tsx +97 -0
  16. package/src/components/StudioErrorBoundary.tsx +7 -0
  17. package/src/components/StudioOverlays.tsx +15 -13
  18. package/src/components/StudioRightPanel.tsx +18 -1
  19. package/src/components/TimelineToolbar.test.tsx +46 -0
  20. package/src/components/TimelineToolbar.tsx +17 -0
  21. package/src/components/editor/DomEditOverlay.tsx +36 -18
  22. package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
  23. package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
  24. package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
  25. package/src/components/editor/InlineTextToolbar.tsx +281 -0
  26. package/src/components/editor/OffCanvasIndicators.tsx +38 -0
  27. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  28. package/src/components/editor/PropertyPanel.tsx +7 -23
  29. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  30. package/src/components/editor/audioFxSummary.test.ts +75 -0
  31. package/src/components/editor/audioFxSummary.ts +36 -0
  32. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  33. package/src/components/editor/audioFxTelemetry.ts +230 -0
  34. package/src/components/editor/domEditInlineText.test.ts +86 -0
  35. package/src/components/editor/domEditInlineText.ts +99 -0
  36. package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
  37. package/src/components/editor/domEditing.ts +1 -0
  38. package/src/components/editor/domEditingLayers.ts +7 -6
  39. package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
  40. package/src/components/editor/inlineTextStyleRange.ts +593 -0
  41. package/src/components/editor/inlineTextStyleRead.ts +47 -0
  42. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  43. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  44. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  45. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  46. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  47. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  48. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  49. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  50. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  51. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  52. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  53. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  54. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  55. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  57. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  58. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  59. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  60. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  61. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  62. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  63. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  64. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  65. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  66. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  67. package/src/components/editor/propertyPanelTypes.ts +5 -0
  68. package/src/components/editor/useDomEditNudge.ts +2 -2
  69. package/src/components/editor/useFxAudition.ts +95 -0
  70. package/src/components/editor/useFxCarve.ts +0 -0
  71. package/src/components/editor/useFxChainObserved.ts +83 -0
  72. package/src/components/editor/useFxLevelling.ts +235 -0
  73. package/src/components/editor/useInlineTextEditing.tsx +119 -0
  74. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  75. package/src/components/editor/useVolumeAutomation.ts +54 -0
  76. package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
  77. package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
  78. package/src/components/feedback/feedbackTrigger.test.ts +168 -0
  79. package/src/components/feedback/feedbackTrigger.ts +269 -0
  80. package/src/components/feedback/projectProvenance.test.ts +120 -0
  81. package/src/components/feedback/projectProvenance.ts +83 -0
  82. package/src/components/renders/useRenderQueue.ts +62 -4
  83. package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
  84. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
  85. package/src/contexts/DomEditContext.tsx +15 -0
  86. package/src/hooks/domEditPersistFailure.ts +0 -7
  87. package/src/hooks/useAppHotkeys.test.ts +198 -0
  88. package/src/hooks/useAppHotkeys.ts +31 -4
  89. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  90. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  91. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  92. package/src/hooks/useDomEditCommits.test.tsx +141 -21
  93. package/src/hooks/useDomEditCommits.ts +4 -0
  94. package/src/hooks/useDomEditSession.ts +4 -0
  95. package/src/hooks/useDomEditTextCommits.ts +112 -11
  96. package/src/hooks/useElementLifecycleOps.ts +3 -0
  97. package/src/hooks/useFileTree.ts +8 -3
  98. package/src/hooks/useInlineTextEdit.test.tsx +555 -0
  99. package/src/hooks/useInlineTextEdit.ts +320 -0
  100. package/src/hooks/useLivePlayheadTime.ts +49 -0
  101. package/src/hooks/usePreviewPersistence.ts +7 -0
  102. package/src/hooks/useTimelineEditing.ts +3 -0
  103. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  104. package/src/player/components/AutomationValueInput.tsx +51 -0
  105. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  106. package/src/player/components/Timeline.test.ts +84 -0
  107. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  108. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  109. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  110. package/src/player/components/TimelineLanes.test.tsx +24 -5
  111. package/src/player/components/TimelineLanes.tsx +142 -154
  112. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  113. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  114. package/src/player/components/TimelineTrackRow.tsx +8 -1
  115. package/src/player/components/automationClipboard.test.ts +94 -0
  116. package/src/player/components/automationClipboard.ts +136 -0
  117. package/src/player/components/automationGestureKeys.test.ts +49 -0
  118. package/src/player/components/automationGestureKeys.ts +49 -0
  119. package/src/player/components/automationLaneData.test.ts +270 -0
  120. package/src/player/components/automationLaneData.ts +258 -0
  121. package/src/player/components/automationLaneDragMath.ts +194 -0
  122. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  123. package/src/player/components/automationLaneGeometry.ts +287 -0
  124. package/src/player/components/automationLaneHeight.ts +15 -0
  125. package/src/player/components/automationLanePointer.ts +20 -0
  126. package/src/player/components/automationLaneSelection.test.ts +183 -0
  127. package/src/player/components/automationLaneSelection.ts +135 -0
  128. package/src/player/components/automationShapes.test.ts +82 -0
  129. package/src/player/components/automationShapes.ts +70 -0
  130. package/src/player/components/automationSimplify.test.ts +48 -0
  131. package/src/player/components/automationSimplify.ts +51 -0
  132. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  133. package/src/player/components/timelineLaneProps.ts +19 -0
  134. package/src/player/components/timelineLayout.ts +11 -5
  135. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  136. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  137. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  138. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  139. package/src/player/components/useAutomationLanes.ts +170 -0
  140. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  141. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  142. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  143. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  145. package/src/player/lib/automationStoreSync.test.ts +83 -0
  146. package/src/player/lib/automationStoreSync.ts +56 -0
  147. package/src/player/lib/playbackShortcuts.ts +5 -4
  148. package/src/player/lib/timelineDOM.test.ts +33 -0
  149. package/src/player/lib/timelineDOM.ts +12 -0
  150. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  151. package/src/player/store/automationSelectionSlice.ts +54 -0
  152. package/src/player/store/playerStore.test.ts +15 -0
  153. package/src/player/store/playerStore.ts +54 -65
  154. package/src/player/store/timelineElement.ts +78 -0
  155. package/src/styles/studio.css +89 -0
  156. package/src/telemetry/agentRuntime.test.ts +45 -0
  157. package/src/telemetry/agentRuntime.ts +62 -0
  158. package/src/telemetry/breadcrumbs.test.ts +68 -0
  159. package/src/telemetry/breadcrumbs.ts +70 -0
  160. package/src/telemetry/client.ts +5 -0
  161. package/src/telemetry/events.ts +84 -3
  162. package/src/telemetry/system.ts +8 -0
  163. package/src/utils/sourcePatcher.ts +5 -1
  164. package/src/utils/studioFileHistory.test.ts +49 -0
  165. package/src/utils/studioFileHistory.ts +17 -1
  166. package/src/utils/studioHelpers.ts +2 -5
  167. package/src/utils/studioTelemetry.ts +16 -10
  168. package/src/utils/timelineDiscovery.ts +3 -24
  169. package/src/utils/typingTarget.test.ts +54 -0
  170. package/src/utils/typingTarget.ts +43 -0
  171. package/dist/assets/index-DerI0ikN.js +0 -428
  172. package/dist/assets/index-tBPidglp.css +0 -1
  173. package/dist/chunk-OBAG3GWK.js.map +0 -1
  174. package/src/components/StudioFeedbackBar.tsx +0 -217
  175. /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Preview a hypothetical chain without committing to it, and put it back on
3
+ * the way out — the machinery behind hovering a preset or an add-menu item.
4
+ *
5
+ * Split out of `propertyPanelFxSection.tsx`, whose `audition` callback and its
6
+ * teardown effect this was.
7
+ */
8
+
9
+ import { useCallback, useEffect, useRef } from "react";
10
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
11
+
12
+ export function useFxAudition(
13
+ chain: HfAudioFxChain,
14
+ onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
15
+ onAuditionTransport: ((on: boolean) => void) | undefined,
16
+ ) {
17
+ /**
18
+ * The chain as it is really stored, captured when an audition starts.
19
+ *
20
+ * Auditioning writes through the preview channel, which does not persist and
21
+ * does not come back as a new `chain` prop — so reverting has to remember what
22
+ * was there rather than read it back. Null means nothing is being auditioned,
23
+ * which is also what makes a stray leave a no-op instead of a write.
24
+ */
25
+ const auditionBase = useRef<HfAudioFxChain | null>(null);
26
+
27
+ /**
28
+ * Play something without committing to it, and put it back on the way out.
29
+ *
30
+ * Hearing a preset before choosing it is the strongest affordance in this
31
+ * panel — see `plans/audio-fx-ux/README.md` §Decided. It costs nothing new:
32
+ * the preview channel a slider drag already uses rebuilds the running graph
33
+ * without touching the document.
34
+ */
35
+ const audition = useCallback(
36
+ (make: ((base: HfAudioFxChain) => HfAudioFxChain) | null) => {
37
+ if (!onChainPreview) return;
38
+ if (make) {
39
+ auditionBase.current ??= chain;
40
+ onChainPreview(make(auditionBase.current));
41
+ // After the chain is in the graph, not before: starting the transport
42
+ // first plays a moment of the un-auditioned mix.
43
+ onAuditionTransport?.(true);
44
+ } else if (auditionBase.current) {
45
+ // Stop before reverting, for the mirror of that reason — the last thing
46
+ // heard should be the preset, not a frame of the chain coming back.
47
+ onAuditionTransport?.(false);
48
+ onChainPreview(auditionBase.current);
49
+ auditionBase.current = null;
50
+ }
51
+ },
52
+ [chain, onChainPreview, onAuditionTransport],
53
+ );
54
+
55
+ /**
56
+ * Drop whatever is being auditioned WITHOUT reverting the preview, for a
57
+ * caller that is about to mutate the real chain anyway — reverting first
58
+ * would be a chain the document never sees, immediately overwritten.
59
+ */
60
+ const clearAudition = useCallback(() => {
61
+ auditionBase.current = null;
62
+ onAuditionTransport?.(false);
63
+ }, [onAuditionTransport]);
64
+
65
+ /**
66
+ * The preview handler as of the last render, held rather than closed over.
67
+ *
68
+ * The teardown below must run on teardown and at no other time, so its deps
69
+ * have to be empty — and `onChainPreview` is an inline arrow in the group,
70
+ * which re-renders on every playhead tick to move the automation readouts. A
71
+ * dep on it made React tear down and re-run the effect on every one of those
72
+ * ticks, so an audition reverted itself about 30 times a second while the
73
+ * pointer was still on the button: the preset was heard for a frame during
74
+ * playback, which is the exact case the whole affordance exists for.
75
+ */
76
+ const previewRef = useRef(onChainPreview);
77
+ previewRef.current = onChainPreview;
78
+
79
+ // Leaving by any route other than the pointer — the element deselected, the
80
+ // panel closed — would otherwise leave the audition playing over a chain the
81
+ // document does not have.
82
+ const transportRef = useRef(onAuditionTransport);
83
+ transportRef.current = onAuditionTransport;
84
+ useEffect(
85
+ () => () => {
86
+ if (auditionBase.current) {
87
+ transportRef.current?.(false);
88
+ previewRef.current?.(auditionBase.current);
89
+ }
90
+ },
91
+ [],
92
+ );
93
+
94
+ return { audition, clearAudition };
95
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Report the shape of a chain this panel did not write.
3
+ *
4
+ * Split out of `propertyPanelAudioFxGroup.tsx`, which owned this whole
5
+ * provenance mechanism before the file grew past a size where it was still
6
+ * one thing to read alongside the carve and the leveller.
7
+ */
8
+
9
+ import { useEffect, useRef } from "react";
10
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
11
+ import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
12
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
13
+ import { trackChainObserved } from "./audioFxTelemetry.js";
14
+ import type { DomEditSelection } from "./domEditingTypes";
15
+
16
+ /**
17
+ * This is the only way agent-applied effects become visible. An agent asked to
18
+ * fix a mix does not drive this panel — it edits the composition HTML, or runs
19
+ * `scripts/carve.mjs`, and the rack simply finds the work already done. Not
20
+ * one of the panel's own events fires for any of it.
21
+ *
22
+ * So: watch the chain's shape, and report it when it changes without a panel
23
+ * edit behind it. `panelEdits` is the discriminator — this session's own
24
+ * writes bump it, so a chain that moved while the counter stood still moved
25
+ * because something outside the studio moved it.
26
+ *
27
+ * Keyed on the shape rather than fired once per mount: a soft reload after an
28
+ * agent edits the file re-mounts this component, and a mount-only event would
29
+ * either miss the change or double-count every HMR. Comparing the fingerprint
30
+ * reports real changes and stays quiet through both.
31
+ *
32
+ * Returns a wrapped `onSetAttributeQuiet` that every other write in the panel
33
+ * must go through instead of the raw prop: the count is only meaningful if it
34
+ * is exhaustive, and a write added later that forgot to route through here
35
+ * would silently start reporting the author's own edits as having come from
36
+ * outside.
37
+ */
38
+ export function useFxChainObserved(
39
+ element: DomEditSelection,
40
+ chain: HfAudioFxChain,
41
+ carve: HfCarveSettings | null,
42
+ automation: HfAutomation,
43
+ onSetAttributeQuietRaw: (attr: string, value: string | null) => void | Promise<void>,
44
+ ): (attr: string, value: string | null) => void | Promise<void> {
45
+ const lastShape = useRef<string | null>(null);
46
+ const panelEdits = useRef(0);
47
+
48
+ const onSetAttributeQuiet = (attr: string, value: string | null): void | Promise<void> => {
49
+ panelEdits.current += 1;
50
+ return onSetAttributeQuietRaw(attr, value);
51
+ };
52
+
53
+ useEffect(() => {
54
+ const shape = JSON.stringify([
55
+ chain.nodes.map((n) => `${n.type}:${n.fromPreset ?? ""}:${n.fromCarve ? 1 : 0}`),
56
+ carve?.enabled ?? false,
57
+ automation.lanes.length,
58
+ ]);
59
+ if (lastShape.current === shape) return;
60
+ const firstSight = lastShape.current === null;
61
+ lastShape.current = shape;
62
+ // An empty chain on first sight is the ordinary case — nothing to report.
63
+ if (firstSight && chain.nodes.length === 0 && !carve) return;
64
+ trackChainObserved(
65
+ chain,
66
+ {
67
+ firstSight,
68
+ panelEdits: panelEdits.current,
69
+ hasCarve: Boolean(carve?.enabled),
70
+ hasAutomation: automation.lanes.length > 0,
71
+ },
72
+ {
73
+ trackKind: classifyAudioName(element.id, element.element?.getAttribute("src")) ?? undefined,
74
+ },
75
+ );
76
+ // Each observation is measured against the edits made SINCE the last one, so
77
+ // a session that edits, then receives an outside change, still reports that
78
+ // second change as unattributed.
79
+ panelEdits.current = 0;
80
+ });
81
+
82
+ return onSetAttributeQuiet;
83
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The levelling script and its playhead-transport audition: measure this
3
+ * track, write the "Even Out Levels" node and lane, and preview the result
4
+ * before committing to it.
5
+ *
6
+ * Split out of `propertyPanelAudioFxGroup.tsx`, which owned all of this before
7
+ * the file grew past a size where "levelling" was still one thing to read.
8
+ */
9
+
10
+ import { useRef, useState } from "react";
11
+ import {
12
+ HF_AUDIO_FX_ATTR,
13
+ serializeAudioFxChain,
14
+ type HfAudioFxChain,
15
+ } from "@hyperframes/core/audio-fx";
16
+ import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
17
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
18
+ import {
19
+ automationAttrValue,
20
+ HF_AUDIO_AUTOMATION_ATTR,
21
+ withLane,
22
+ withoutLane,
23
+ } from "./propertyPanelAutomation";
24
+ import { trackLeveller } from "./audioFxTelemetry.js";
25
+ import type { DomEditSelection } from "./domEditingTypes";
26
+ import { usePlayerStore } from "../../player";
27
+
28
+ /**
29
+ * Rate the track is decoded at. Analysis is self-consistent because it reads
30
+ * the decoded buffer's own rate, so this only has to be a sane audio rate.
31
+ */
32
+ const DECODE_SAMPLE_RATE = 48000;
33
+
34
+ /** A parsed attribute, kept only when it is a usable positive number. */
35
+ function positiveFinite(n: number): number | null {
36
+ return Number.isFinite(n) && n > 0 ? n : null;
37
+ }
38
+
39
+ export function useFxLevelling(
40
+ element: DomEditSelection,
41
+ chain: HfAudioFxChain,
42
+ automation: HfAutomation,
43
+ onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
44
+ onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>,
45
+ setAnalysing: (value: boolean) => void,
46
+ ) {
47
+ /**
48
+ * This track's audio, decoded once and kept.
49
+ *
50
+ * Levelling is measured from it, and hover-auditioning means measuring on every
51
+ * pass over the button — fetching and decoding a several-minute voiceover each
52
+ * time would make the audition slower than the thing it is previewing. Keyed by
53
+ * `src` so a track pointed at a different file re-decodes.
54
+ */
55
+ const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
56
+
57
+ const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
58
+ const el = element.element;
59
+ const src = el?.getAttribute("src");
60
+ const doc = el?.ownerDocument;
61
+ if (!src || !doc) return null;
62
+ const cached = decoded.current;
63
+ if (cached?.src === src) return cached;
64
+ const Ctor =
65
+ window.OfflineAudioContext ??
66
+ (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
67
+ .webkitOfflineAudioContext;
68
+ if (!Ctor) return null;
69
+ const res = await fetch(new URL(src, doc.baseURI).href);
70
+ const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
71
+ await res.arrayBuffer(),
72
+ );
73
+ const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
74
+ decoded.current = next;
75
+ return next;
76
+ };
77
+
78
+ /**
79
+ * The part of the decoded file this clip actually plays.
80
+ *
81
+ * A lane's `t` is seconds from the start of the CLIP, but the decode is the
82
+ * whole file from its first sample — so measuring a trimmed clip produced an
83
+ * envelope offset by the trim, and every correction landed early by exactly
84
+ * `media-start`. Slicing here is what puts the two clocks back on the same
85
+ * zero.
86
+ */
87
+ const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
88
+ const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
89
+ const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
90
+ const from = mediaStart
91
+ ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
92
+ : 0;
93
+ const to = duration
94
+ ? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
95
+ : audio.samples.length;
96
+ return from === 0 && to === audio.samples.length
97
+ ? audio.samples
98
+ : audio.samples.subarray(from, to);
99
+ };
100
+
101
+ const runLeveller = async (): Promise<void> => {
102
+ setAnalysing(true);
103
+ try {
104
+ const audio = await decodeTrack();
105
+ if (!audio) return;
106
+ const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
107
+ if (!result) return;
108
+ trackLeveller("run");
109
+ await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
110
+ // Merged by target, never written wholesale: the script describes its own
111
+ // lane only, and replacing the attribute would take the carve's lanes and
112
+ // the volume lane with it.
113
+ const lane = result.automation.lanes[0];
114
+ if (lane) {
115
+ void onSetAttributeQuiet(
116
+ HF_AUDIO_AUTOMATION_ATTR,
117
+ automationAttrValue(withLane(automation, lane)) || null,
118
+ );
119
+ }
120
+ } catch {
121
+ // A track whose audio cannot be fetched or decoded simply gets no
122
+ // levelling, the same way an unreadable carve source is skipped.
123
+ } finally {
124
+ setAnalysing(false);
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Where the playhead was when an audition started the transport, so leaving
130
+ * can put it back. Null means this audition did not start playback — the
131
+ * transport was already running and must be left alone.
132
+ */
133
+ const auditionReturn = useRef<number | null>(null);
134
+
135
+ /**
136
+ * Start playback for an audition, and stop it again on the way out.
137
+ *
138
+ * An audition writes the preset to the running graph, which is silent while
139
+ * the transport is paused — so a paused author hovering a preset heard
140
+ * nothing at all, and the whole affordance only worked mid-playback. Hovering
141
+ * now plays from the playhead, and leaving stops and rewinds to exactly where
142
+ * it started: browsing the shelf must not cost the author their place.
143
+ *
144
+ * Already playing, this does nothing in either direction. The author started
145
+ * that, and stopping their transport because they passed over a preset would
146
+ * be the panel taking a decision that was not offered to it.
147
+ */
148
+ const auditionTransport = (on: boolean): void => {
149
+ const store = usePlayerStore.getState();
150
+ if (on) {
151
+ if (store.isPlaying || auditionReturn.current !== null) return;
152
+ auditionReturn.current = store.currentTime;
153
+ store.requestPlayback(true);
154
+ return;
155
+ }
156
+ const returnTo = auditionReturn.current;
157
+ if (returnTo === null) return;
158
+ auditionReturn.current = null;
159
+ store.requestPlayback(false, returnTo);
160
+ };
161
+
162
+ const [auditioningLevel, setAuditioningLevel] = useState(false);
163
+ /**
164
+ * Bumped on every enter and leave, so a measurement can tell whether the
165
+ * pointer is still on the button when it finishes.
166
+ *
167
+ * Decoding a long voiceover takes seconds, and a hover that takes seconds is
168
+ * one the author has usually already left. Applying the result then would put
169
+ * levelling on a track nobody asked to level, through a channel that does not
170
+ * persist — so it would be audible, invisible in the document, and gone on the
171
+ * next reload. This counter is what makes a late result a no-op.
172
+ */
173
+ const auditionRun = useRef(0);
174
+
175
+ /** Both attributes back to the stored chain, because levelling is a node AND the lane that drives it. */
176
+ const stopLevelAudition = (): void => {
177
+ setAuditioningLevel(false);
178
+ void onSetAttributeLive(
179
+ HF_AUDIO_FX_ATTR,
180
+ chain.nodes.length ? serializeAudioFxChain(chain) : null,
181
+ );
182
+ void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
183
+ };
184
+
185
+ /** Measure this track and play the levelling without persisting it. */
186
+ const startLevelAudition = async (run: number): Promise<void> => {
187
+ setAuditioningLevel(true);
188
+ try {
189
+ const audio = await decodeTrack();
190
+ // Gone, or superseded by a later hover. Either way this result is stale.
191
+ if (!audio || run !== auditionRun.current) return;
192
+ const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
193
+ if (!result || run !== auditionRun.current) return;
194
+ void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
195
+ const lane = result.automation.lanes[0];
196
+ if (lane) {
197
+ void onSetAttributeLive(
198
+ HF_AUDIO_AUTOMATION_ATTR,
199
+ automationAttrValue(withLane(automation, lane)) || null,
200
+ );
201
+ }
202
+ } catch {
203
+ // Same as the real run: a track that cannot be decoded simply does not
204
+ // audition, rather than failing the panel.
205
+ } finally {
206
+ if (run === auditionRun.current) setAuditioningLevel(false);
207
+ }
208
+ };
209
+
210
+ /**
211
+ * `false` puts the stored chain and automation back; `true` measures and
212
+ * plays the result without persisting it.
213
+ */
214
+ const auditionLevel = async (on: boolean): Promise<void> => {
215
+ const run = ++auditionRun.current;
216
+ if (!on) return stopLevelAudition();
217
+ await startLevelAudition(run);
218
+ };
219
+
220
+ const removeLeveller = (): void => {
221
+ trackLeveller("removed");
222
+ const { chain: next, removedTarget } = removeLevelling(chain);
223
+ void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
224
+ // The lane goes with the node. An orphan keeps driving a parameter that is
225
+ // no longer in the graph.
226
+ if (removedTarget) {
227
+ void onSetAttributeQuiet(
228
+ HF_AUDIO_AUTOMATION_ATTR,
229
+ automationAttrValue(withoutLane(automation, removedTarget)) || null,
230
+ );
231
+ }
232
+ };
233
+
234
+ return { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller };
235
+ }
@@ -0,0 +1,119 @@
1
+ import { useRef, type ReactNode, type RefObject } from "react";
2
+ import { InlineTextToolbar } from "./InlineTextToolbar";
3
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
+ import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
5
+ import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
7
+ import {
8
+ canEditElementTextInline,
9
+ canEditTextInline,
10
+ isDoublePress,
11
+ type PressMark,
12
+ } from "./domEditInlineText";
13
+ import type { DomEditSelection } from "./domEditingTypes";
14
+
15
+ /**
16
+ * The canvas' side of editing text where it sits.
17
+ *
18
+ * Holds the session, decides which presses open one, and knows the two things
19
+ * the canvas has to do differently while one is open: stand aside so the caret
20
+ * underneath can be reached, and stop taking focus back.
21
+ *
22
+ * The actions context is read here rather than threaded through the overlay's
23
+ * props, the same way the agent surfaces in that overlay read it, and it is
24
+ * absent in standalone player mounts, which have no project to edit.
25
+ */
26
+ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection | null>): {
27
+ editing: boolean;
28
+ /** Open an edit when this press pairs with the last one. */
29
+ startFromPress: (event: { clientX: number; clientY: number }) => boolean;
30
+ /** Handle a key on the canvas. Returns true when it opened an edit. */
31
+ handleKeyDown: (event: {
32
+ key: string;
33
+ shiftKey: boolean;
34
+ ctrlKey: boolean;
35
+ metaKey: boolean;
36
+ altKey: boolean;
37
+ }) => boolean;
38
+ /**
39
+ * The styling controls for the current selection, for the caller to render.
40
+ *
41
+ * Handed back rather than mounted somewhere central because it belongs to the
42
+ * session this hook owns, and appears and disappears with it.
43
+ */
44
+ toolbar: ReactNode;
45
+ } {
46
+ const actions = useDomEditActionsContextOptional();
47
+ const inlineText = useInlineTextEdit({
48
+ onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
49
+ onPause: () => usePlayerStore.getState().setIsPlaying(false),
50
+ });
51
+ const lastPressRef = useRef<PressMark | null>(null);
52
+
53
+ /**
54
+ * The element under this press, hit-tested now rather than read from state.
55
+ *
56
+ * Every cached answer in the overlay is React state that has not caught up
57
+ * with the press happening now: the hover is documented as an async cache,
58
+ * and the selection from the first press has not re-rendered. Reading either
59
+ * meant the first double press on any element opened nothing, which is every
60
+ * double press that matters.
61
+ */
62
+ const elementUnderPress = (event: { clientX: number; clientY: number }) => {
63
+ const iframe = actions?.previewIframeRef?.current;
64
+ if (!iframe) return null;
65
+ // Studio suppresses pointer events inside the composition so the canvas
66
+ // overlay can own input, which means a plain elementFromPoint only ever
67
+ // finds wrappers. This helper lifts that for the length of the hit test,
68
+ // and is the same one the canvas uses to decide what was clicked.
69
+ return getPreviewTargetFromPointer(
70
+ iframe,
71
+ event.clientX,
72
+ event.clientY,
73
+ selectionRef.current?.compositionPath ?? null,
74
+ );
75
+ };
76
+
77
+ /** A point on Studio's canvas, in the scaled composition's coordinates. */
78
+ const compositionPoint = (event: { clientX: number; clientY: number }) => {
79
+ const iframe = actions?.previewIframeRef?.current;
80
+ const view = iframe?.contentWindow;
81
+ if (!iframe || !view?.innerWidth) return undefined;
82
+ const box = iframe.getBoundingClientRect();
83
+ const scale = box.width / view.innerWidth || 1;
84
+ return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
85
+ };
86
+
87
+ return {
88
+ editing: inlineText.session !== null,
89
+ toolbar: (
90
+ <InlineTextToolbar
91
+ session={inlineText.session}
92
+ iframe={actions?.previewIframeRef?.current ?? null}
93
+ />
94
+ ),
95
+ // Enter opens the selected element's text, the way every design tool does,
96
+ // and is the dependable way in: a double press has to survive the canvas'
97
+ // gesture machinery, while this is one key on a selection that has settled.
98
+ handleKeyDown: (event) => {
99
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
100
+ return false;
101
+ const target = selectionRef.current;
102
+ if (inlineText.session || !target || !canEditTextInline(target)) return false;
103
+ return inlineText.start(target.element);
104
+ },
105
+ startFromPress: (event) => {
106
+ if (inlineText.session) return false;
107
+ const element = elementUnderPress(event);
108
+ const press = { x: event.clientX, y: event.clientY, at: Date.now(), element };
109
+ const paired = isDoublePress(lastPressRef.current, press);
110
+ lastPressRef.current = press;
111
+
112
+ if (!paired) return false;
113
+ if (!element || !canEditElementTextInline(element)) return false;
114
+ // The caret opens where the press landed, which means mapping the point
115
+ // out of Studio's coordinates and into the composition's own.
116
+ return inlineText.start(element, compositionPoint(event));
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createRoot } from "react-dom/client";
5
+ import { useVolumeAutomation, type VolumeAutomationBinding } from "./useVolumeAutomation";
6
+ import type { DomEditSelection } from "./domEditingTypes";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ function bind(dataAttributes: Record<string, string>) {
11
+ const onSetAttributeQuiet = vi.fn();
12
+ const captured: { current: VolumeAutomationBinding | null } = { current: null };
13
+ function Probe() {
14
+ captured.current = useVolumeAutomation(
15
+ { dataAttributes } as unknown as DomEditSelection,
16
+ onSetAttributeQuiet,
17
+ );
18
+ return null;
19
+ }
20
+ const host = document.createElement("div");
21
+ document.body.append(host);
22
+ act(() => {
23
+ createRoot(host).render(<Probe />);
24
+ });
25
+ if (!captured.current) throw new Error("hook never ran");
26
+ return { binding: captured.current, onSetAttributeQuiet };
27
+ }
28
+
29
+ const volumeLane = (v: number) =>
30
+ JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
31
+
32
+ describe("useVolumeAutomation", () => {
33
+ it("reports an unautomated track", () => {
34
+ expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
35
+ });
36
+
37
+ it("reports a track with a volume lane", () => {
38
+ expect(bind({ volume: "0.55", automation: volumeLane(0.2) }).binding.volumeAutomated).toBe(
39
+ true,
40
+ );
41
+ });
42
+
43
+ it("does not count an FX lane as automating the volume", () => {
44
+ const automation = JSON.stringify({
45
+ version: 1,
46
+ lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
47
+ });
48
+ expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
49
+ });
50
+
51
+ it("seeds a new lane at the level the slider already shows", () => {
52
+ // Automating a track must not change how loud it is.
53
+ const { binding, onSetAttributeQuiet } = bind({ volume: "0.55" });
54
+ act(() => binding.onAutomateVolume());
55
+ expect(onSetAttributeQuiet).toHaveBeenCalledWith(
56
+ "data-automation",
57
+ JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
58
+ );
59
+ });
60
+
61
+ it("treats a missing data-volume as unity", () => {
62
+ const { binding, onSetAttributeQuiet } = bind({});
63
+ act(() => binding.onAutomateVolume());
64
+ expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
65
+ });
66
+
67
+ it("keeps FX lanes when adding the volume one", () => {
68
+ const automation = JSON.stringify({
69
+ version: 1,
70
+ lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
71
+ });
72
+ const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
73
+ act(() => binding.onAutomateVolume());
74
+ expect(
75
+ JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
76
+ (l: { target: string }) => l.target,
77
+ ),
78
+ ).toEqual(["fx.n1.frequency", "volume"]);
79
+ });
80
+
81
+ it("deletes only the volume lane", () => {
82
+ const automation = JSON.stringify({
83
+ version: 1,
84
+ lanes: [
85
+ { target: "volume", points: [{ t: 0, v: 0.2 }] },
86
+ { target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
87
+ ],
88
+ });
89
+ const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
90
+ act(() => binding.onRemoveVolumeAutomation());
91
+ expect(
92
+ JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
93
+ (l: { target: string }) => l.target,
94
+ ),
95
+ ).toEqual(["fx.n1.frequency"]);
96
+ });
97
+
98
+ it("clears the attribute when the volume lane was the only one", () => {
99
+ const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation: volumeLane(0.2) });
100
+ act(() => binding.onRemoveVolumeAutomation());
101
+ // Null, not "": the quiet path removes an attribute it is given null for.
102
+ expect(onSetAttributeQuiet).toHaveBeenCalledWith("data-automation", null);
103
+ });
104
+
105
+ it("reads an unreadable attribute as no automation", () => {
106
+ expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
107
+ });
108
+
109
+ it("seeds at unity when data-volume is present but empty", () => {
110
+ // Number("") is 0, so `?? "1"` alone seeded the lane at silence while the
111
+ // engine read the same empty attribute as unity.
112
+ const { binding, onSetAttributeQuiet } = bind({ volume: "" });
113
+ act(() => binding.onAutomateVolume());
114
+ expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
115
+ });
116
+
117
+ it("seeds at unity when data-volume is not a number", () => {
118
+ const { binding, onSetAttributeQuiet } = bind({ volume: "loud" });
119
+ act(() => binding.onAutomateVolume());
120
+ expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
121
+ });
122
+ });