@hyperframes/studio 0.7.107 → 0.7.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,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,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
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The volume lane's state and edits for the media section.
3
+ *
4
+ * Volume lives in a different panel section from the FX chain, but is automated
5
+ * the same way, so it reads and writes through the same helper the FX group uses
6
+ * rather than a second interpretation of the attribute.
7
+ */
8
+
9
+ import { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
10
+ import type { DomEditSelection } from "./domEditingTypes";
11
+ import {
12
+ automationAttrValue,
13
+ HF_AUDIO_AUTOMATION_ATTR,
14
+ readPanelAutomation,
15
+ withoutLane,
16
+ withSeededLane,
17
+ } from "./propertyPanelAutomation";
18
+
19
+ export interface VolumeAutomationBinding {
20
+ volumeAutomated: boolean;
21
+ onAutomateVolume: () => void;
22
+ onRemoveVolumeAutomation: () => void;
23
+ }
24
+
25
+ export function useVolumeAutomation(
26
+ element: DomEditSelection,
27
+ onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
28
+ ): VolumeAutomationBinding {
29
+ // The chain is not needed to resolve a volume lane — volume is always a valid
30
+ // target — so this deliberately does not parse it.
31
+ const automation = readPanelAutomation(
32
+ element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
33
+ undefined,
34
+ );
35
+ const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
36
+ // Quiet: clicking the toggle used to reload the preview and restart every
37
+ // playing track, while the same click on an effect parameter did not.
38
+ void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
39
+ };
40
+ // `??` alone would let an empty `data-volume` through as Number("") === 0, so
41
+ // automating the track would seed its lane at silence. The engine reads the same
42
+ // empty value as unity.
43
+ const raw = element.dataAttributes?.["volume"];
44
+ const parsed = raw ? Number(raw) : 1;
45
+ const current = Number.isFinite(parsed) ? parsed : 1;
46
+ return {
47
+ volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
48
+ // Seeded at the level the slider already shows, so automating the track does
49
+ // not change how loud it is.
50
+ onAutomateVolume: () =>
51
+ write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
52
+ onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
53
+ };
54
+ }
@@ -15,6 +15,7 @@ export interface DomEditActionsValue extends Pick<
15
15
  | "handleDomStyleCommit"
16
16
  | "handleDomAttributeCommit"
17
17
  | "handleDomAttributeLiveCommit"
18
+ | "handleDomAttributeQuietCommit"
18
19
  | "handleDomHtmlAttributeCommit"
19
20
  | "handleDomAttributesCommit"
20
21
  | "handleDomPathOffsetCommit"
@@ -116,6 +117,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue {
116
117
  return ctx;
117
118
  }
118
119
 
120
+ /** Optional counterpart to useDomEditActionsContextOptional — same reason: the
121
+ * player package's own components mount outside a provider in standalone and
122
+ * test trees, where "no dom-edit selection" is the correct answer. */
123
+ export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null {
124
+ return useContext(DomEditSelectionContext);
125
+ }
126
+
119
127
  /** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */
120
128
  export function useDomEditContext(): DomEditValue {
121
129
  return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() };
@@ -140,6 +148,7 @@ export function DomEditProvider({
140
148
  handleDomStyleCommit,
141
149
  handleDomAttributeCommit,
142
150
  handleDomAttributeLiveCommit,
151
+ handleDomAttributeQuietCommit,
143
152
  handleDomHtmlAttributeCommit,
144
153
  handleDomAttributesCommit,
145
154
  handleDomPathOffsetCommit,
@@ -229,6 +238,7 @@ export function DomEditProvider({
229
238
  handleDomStyleCommit,
230
239
  handleDomAttributeCommit,
231
240
  handleDomAttributeLiveCommit,
241
+ handleDomAttributeQuietCommit,
232
242
  handleDomHtmlAttributeCommit,
233
243
  handleDomAttributesCommit,
234
244
  handleDomPathOffsetCommit,
@@ -299,6 +309,7 @@ export function DomEditProvider({
299
309
  handleDomStyleCommit,
300
310
  handleDomAttributeCommit,
301
311
  handleDomAttributeLiveCommit,
312
+ handleDomAttributeQuietCommit,
302
313
  handleDomHtmlAttributeCommit,
303
314
  handleDomAttributesCommit,
304
315
  handleDomPathOffsetCommit,