@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,299 @@
1
+ /**
2
+ * The audio FX panel's bridge to the element/attribute world.
3
+ *
4
+ * Chain, carve and automation are all serialised onto the element the way colour
5
+ * grading carries its config, so persistence is an ordinary attribute write with
6
+ * no new server route. Split out of PropertyPanelFlat, which is at its size
7
+ * budget, and self-contained enough to test on its own.
8
+ */
9
+
10
+ import { useState } from "react";
11
+ import {
12
+ HF_AUDIO_FX_ATTR,
13
+ HF_AUDIO_FX_DATA_KEY,
14
+ parseAudioFxChain,
15
+ serializeAudioFxChain,
16
+ type HfAudioFxChain,
17
+ } from "@hyperframes/core/audio-fx";
18
+ import {
19
+ classifyAudioName,
20
+ HF_AUDIO_CARVE_ATTR,
21
+ normalizeCarveSettings,
22
+ type HfCarveSettings,
23
+ } from "@hyperframes/core/audio-carve";
24
+ import {
25
+ fxAutomationTarget,
26
+ parseAutomationTarget,
27
+ presetAutomationTarget,
28
+ sampleAutomationLane,
29
+ type HfAutomation,
30
+ } from "@hyperframes/core/audio-automation";
31
+ import {
32
+ automatedTargetsOf,
33
+ automationAttrValue,
34
+ HF_AUDIO_AUTOMATION_ATTR,
35
+ HF_AUDIO_AUTOMATION_DATA_KEY,
36
+ readPanelAutomation,
37
+ resolveAutomationRange,
38
+ withoutLane,
39
+ withSeededLane,
40
+ } from "./propertyPanelAutomation";
41
+ import type { DomEditSelection } from "./domEditingTypes";
42
+ import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
43
+ import { FxSection } from "./propertyPanelFxSection.js";
44
+ import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
45
+ import { useFxChainObserved } from "./useFxChainObserved.js";
46
+ import { useFxCarve } from "./useFxCarve.js";
47
+ import { useFxLevelling } from "./useFxLevelling.js";
48
+
49
+ /**
50
+ * Bridges the FX panel to the element/attribute world. Chain and carve are
51
+ * serialised onto the element the way colour grading carries its config, so
52
+ * persistence is an ordinary attribute write with no new server route.
53
+ */
54
+ export function AudioFxGroup({
55
+ element,
56
+ onSetAttributeQuiet: onSetAttributeQuietRaw,
57
+ onSetAttributeLive,
58
+ }: {
59
+ element: DomEditSelection;
60
+ /**
61
+ * Every write here is quiet: it persists to the source and skips the preview
62
+ * reload, because the runtime applies chain and automation edits to the
63
+ * running graph — a reload would only interrupt the audio to reach the same
64
+ * state, which is heard as the track chopping.
65
+ *
66
+ * It does re-read the selection afterwards, which this panel depends on: each
67
+ * edit is computed from the current attribute, so without the resync a second
68
+ * edit would work from a pre-edit value and appear to do nothing.
69
+ */
70
+ onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>;
71
+ /** Continuous, non-persisting write for a dial being dragged. */
72
+ onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
73
+ }) {
74
+ const chain = ((): HfAudioFxChain => {
75
+ const raw = element.dataAttributes?.[HF_AUDIO_FX_DATA_KEY];
76
+ if (!raw) return { version: 1, nodes: [] };
77
+ try {
78
+ return parseAudioFxChain(raw);
79
+ } catch {
80
+ // Show an unreadable chain as empty rather than breaking the panel; the
81
+ // attribute is left untouched until the user changes something.
82
+ return { version: 1, nodes: [] };
83
+ }
84
+ })();
85
+
86
+ const automation = readPanelAutomation(
87
+ element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
88
+ chain,
89
+ );
90
+ const automatedTargets = automatedTargetsOf(automation);
91
+
92
+ /**
93
+ * What every automated knob is worth at the playhead, so the rack shows the
94
+ * value the audio is actually using rather than the one the attribute stores.
95
+ *
96
+ * An automated parameter has two values: the number sitting in the chain, which
97
+ * is only a seed once a lane exists, and the number the envelope is on right now.
98
+ * The second is the true one, and a rack that shows the first reads as broken
99
+ * during playback — the carve is visibly working and the readouts do not move.
100
+ *
101
+ * Sampled while paused as well, because the same argument applies to a scrub:
102
+ * the playhead is somewhere, and the envelope has a value there.
103
+ *
104
+ * Sampled off the clip too, which is not obvious. A lane holds its first value
105
+ * backwards and its last value forwards, so before the clip starts it already
106
+ * knows what it will open on — while the stored number is a seed the lane
107
+ * replaced and nothing will ever play. Showing that seed put a value on screen
108
+ * that the automation never uses, and made the fader jump the moment the clip
109
+ * came under the playhead.
110
+ */
111
+ const playhead = useLivePlayheadTime();
112
+ const localTime = playhead - clipStart(element.dataAttributes?.["start"]);
113
+ const liveAutomationValues = ((): Map<string, number> => {
114
+ const values = new Map<string, number>();
115
+ for (const lane of automation.lanes) {
116
+ const range = resolveAutomationRange(lane.target, chain);
117
+ if (!range) continue;
118
+ values.set(lane.target, sampleAutomationLane(lane, localTime, range.scale));
119
+ }
120
+ return values;
121
+ })();
122
+
123
+ const carve = ((): HfCarveSettings | null => {
124
+ const raw = element.dataAttributes?.["fx-carve"];
125
+ if (!raw) return null;
126
+ try {
127
+ return normalizeCarveSettings(JSON.parse(raw));
128
+ } catch {
129
+ return null;
130
+ }
131
+ })();
132
+
133
+ /**
134
+ * Every persisting write this panel makes, counted — see `useFxChainObserved`,
135
+ * which reports a chain that changed without one of these behind it as work
136
+ * something outside the studio did.
137
+ */
138
+ const onSetAttributeQuiet = useFxChainObserved(
139
+ element,
140
+ chain,
141
+ carve,
142
+ automation,
143
+ onSetAttributeQuietRaw,
144
+ );
145
+
146
+ // Written through the live path on purpose. It persists to the source just
147
+ // like the refreshing one, but skips the preview reload — and a reload
148
+ // restarts every playing track, which is heard as the audio chopping. The
149
+ // runtime follows the attribute and swaps the graph in place instead.
150
+ const writeAutomation = (next: HfAutomation): void => {
151
+ void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
152
+ };
153
+
154
+ /**
155
+ * Start automating one effect parameter.
156
+ *
157
+ * The lane is seeded with a single point at the value the control currently
158
+ * holds, so switching to an envelope never changes the sound — it only moves
159
+ * where the value comes from. The author then adds points in the timeline.
160
+ */
161
+ const automateParam = (nodeId: string, paramKey: string): void => {
162
+ const target = fxAutomationTarget(nodeId, paramKey);
163
+ const node = chain.nodes.find((n) => n.id === nodeId);
164
+ const range = resolveAutomationRange(target, chain);
165
+ if (!node || !range) return;
166
+ const raw = node.params?.[paramKey];
167
+ writeAutomation(
168
+ withSeededLane(automation, target, typeof raw === "number" ? raw : range.default),
169
+ );
170
+ };
171
+
172
+ /** Stop automating it, handing the value back to the panel control. */
173
+ const removeParamAutomation = (nodeId: string, paramKey: string): void => {
174
+ writeAutomation(withoutLane(automation, fxAutomationTarget(nodeId, paramKey)));
175
+ };
176
+
177
+ /**
178
+ * Automate a whole preset's amount — the wet/dry blend around its run.
179
+ *
180
+ * Seeded where the preset already sits, so switching to a lane never changes
181
+ * the sound; the author then draws the ramp in the timeline. Same contract as
182
+ * automating one parameter, one level up.
183
+ */
184
+ const automatePreset = (presetId: string, amount: number): void => {
185
+ writeAutomation(withSeededLane(automation, presetAutomationTarget(presetId), amount));
186
+ };
187
+
188
+ const removePresetAutomation = (presetId: string): void => {
189
+ writeAutomation(withoutLane(automation, presetAutomationTarget(presetId)));
190
+ };
191
+
192
+ /** Presets a lane already drives, so the panel shows a readout not a slider. */
193
+ const automatedPresets = new Set(
194
+ automation.lanes
195
+ .map((lane) => parseAutomationTarget(lane.target))
196
+ .filter((t): t is { kind: "preset"; presetId: string } => t?.kind === "preset")
197
+ .map((t) => t.presetId),
198
+ );
199
+
200
+ /**
201
+ * Drop every lane belonging to these nodes, and optionally a whole-preset one.
202
+ *
203
+ * Takes a LIST rather than one id, because each call recomputes from the same
204
+ * render-time `automation` snapshot and writes the whole attribute: calling it
205
+ * per node in a loop meant every write but the last was discarded, and only
206
+ * the final node's lanes were actually removed. The rest survived as orphans,
207
+ * and with ids minted lowest-free the next effect added inherited one —
208
+ * arriving "Automated" with an envelope nobody drew, baked into the render.
209
+ */
210
+ const removeNodesAutomation = (nodeIds: readonly string[], presetId?: string): void => {
211
+ const prefixes = nodeIds.map((id) => `fx.${id}.`);
212
+ const kept = automation.lanes.filter(
213
+ (lane) =>
214
+ !prefixes.some((p) => lane.target.startsWith(p)) &&
215
+ !(presetId !== undefined && lane.target === presetAutomationTarget(presetId)),
216
+ );
217
+ if (kept.length !== automation.lanes.length) {
218
+ writeAutomation({ version: 1, lanes: kept });
219
+ }
220
+ };
221
+
222
+ const removeNodeAutomation = (nodeId: string): void => removeNodesAutomation([nodeId]);
223
+
224
+ const [analysing, setAnalysing] = useState(false);
225
+
226
+ const { carvedAgainstBy, sourceOptions, setCarve } = useFxCarve(
227
+ element,
228
+ chain,
229
+ carve,
230
+ automation,
231
+ onSetAttributeQuiet,
232
+ writeAutomation,
233
+ setAnalysing,
234
+ );
235
+
236
+ const { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller } =
237
+ useFxLevelling(
238
+ element,
239
+ chain,
240
+ automation,
241
+ onSetAttributeQuiet,
242
+ onSetAttributeLive,
243
+ setAnalysing,
244
+ );
245
+
246
+ return (
247
+ <FxSection
248
+ // Locked while the carve is measuring. `analyse` captures the chain and
249
+ // the automation BEFORE its fetch and decode, then rewrites the whole
250
+ // attribute from that snapshot — so an effect added, or a knob committed,
251
+ // during those seconds was silently discarded when the analysis landed.
252
+ // Only the Analyse button was disabled, so every other control in the rack
253
+ // stayed live throughout. Refusing the edit is honest; merging it into a
254
+ // measurement that did not account for it would not be.
255
+ disabled={analysing}
256
+ chain={chain}
257
+ automatedTargets={automatedTargets}
258
+ liveAutomationValues={liveAutomationValues}
259
+ onAutomateParam={automateParam}
260
+ onRemoveParamAutomation={removeParamAutomation}
261
+ onRemoveNodeAutomation={removeNodeAutomation}
262
+ onRemoveNodesAutomation={removeNodesAutomation}
263
+ onChainChange={(next) =>
264
+ // Live for the same reason as automation above: adding, removing or
265
+ // bypassing an effect is applied to the running graph, so a reload would
266
+ // only interrupt the audio to reach the same state.
267
+ onSetAttributeQuiet(
268
+ HF_AUDIO_FX_ATTR,
269
+ next.nodes.length ? serializeAudioFxChain(next) : null,
270
+ )
271
+ }
272
+ onAuditionTransport={auditionTransport}
273
+ onChainPreview={(next) =>
274
+ // Live writes skip the preview refresh entirely, so dragging a knob no
275
+ // longer reloads the composition and restarts playback on every pixel.
276
+ // The gesture-end write above is the one that resyncs.
277
+ onSetAttributeLive(HF_AUDIO_FX_ATTR, next.nodes.length ? serializeAudioFxChain(next) : null)
278
+ }
279
+ carve={carve}
280
+ onCarveChange={(next) => void setCarve(next)}
281
+ onCarvePreview={(next) => onSetAttributeLive(HF_AUDIO_CARVE_ATTR, JSON.stringify(next))}
282
+ sourceOptions={sourceOptions}
283
+ // What this track sounds like, by the same reading that decides which
284
+ // OTHER tracks a carve may listen to. The shelf uses it to stop offering
285
+ // "My voice sounds amateur" on a music bed.
286
+ trackKind={classifyAudioName(element.id, element.element?.getAttribute("src"))}
287
+ onLevel={() => void runLeveller()}
288
+ onRemoveLevel={removeLeveller}
289
+ levelled={chain.nodes.some((n) => n.fromLeveller)}
290
+ onAutomatePreset={automatePreset}
291
+ onRemovePresetAutomation={removePresetAutomation}
292
+ automatedPresets={automatedPresets}
293
+ onAuditionLevel={(on) => void auditionLevel(on)}
294
+ auditioningLevel={auditioningLevel}
295
+ carvedAgainstBy={carvedAgainstBy}
296
+ analysing={analysing}
297
+ />
298
+ );
299
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Small clip-timing helpers shared by the FX group and its carve/levelling
3
+ * hooks — split out of `propertyPanelAudioFxGroup.tsx` so each file that needs
4
+ * "where does this clip sit" does not have to redefine it.
5
+ */
6
+
7
+ /** Where a clip starts on the timeline, in seconds. */
8
+ export function clipStart(value: string | null | undefined): number {
9
+ const n = Number(value);
10
+ return Number.isFinite(n) ? n : 0;
11
+ }
12
+
13
+ /** A clip's span, with an unwritten duration left unbounded rather than zero. */
14
+ export function spanOf(
15
+ start: string | null | undefined,
16
+ duration: string | null | undefined,
17
+ ): { start: number; duration: number | null } {
18
+ const n =
19
+ duration === null || duration === undefined || duration === "" ? Number.NaN : Number(duration);
20
+ return { start: clipStart(start), duration: Number.isFinite(n) ? n : null };
21
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
3
+ import { automationAttrValue, withLane, withoutLane } from "./propertyPanelAutomation.js";
4
+
5
+ const carved = (): HfAutomation => ({
6
+ version: 1,
7
+ lanes: [
8
+ { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
9
+ { target: "fx.n2.gain", points: [{ t: 0, v: -9 }] },
10
+ { target: "volume", points: [{ t: 0, v: 0.8 }] },
11
+ ],
12
+ });
13
+
14
+ /**
15
+ * A script hands back a whole `HfAutomation` that describes only its OWN lane.
16
+ * Writing that to the attribute would take everything else with it — the
17
+ * carve's per-band lanes and the track's volume lane — which is silent, total,
18
+ * and only noticed later when the mix has quietly lost its ducking.
19
+ */
20
+ describe("withLane", () => {
21
+ it("keeps every lane it was not asked about", () => {
22
+ const next = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
23
+ expect(next.lanes.map((l) => l.target).sort()).toEqual([
24
+ "fx.n1.gain",
25
+ "fx.n2.gain",
26
+ "fx.n9.gain",
27
+ "volume",
28
+ ]);
29
+ expect(next.lanes.find((l) => l.target === "volume")?.points).toEqual([{ t: 0, v: 0.8 }]);
30
+ });
31
+
32
+ it("replaces a lane rather than adding a second one for the same target", () => {
33
+ // Re-running a script must not leave two lanes fighting over one parameter.
34
+ const once = withLane(carved(), { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
35
+ const twice = withLane(once, { target: "fx.n9.gain", points: [{ t: 0, v: 5 }] });
36
+ expect(twice.lanes.filter((l) => l.target === "fx.n9.gain")).toHaveLength(1);
37
+ expect(twice.lanes.find((l) => l.target === "fx.n9.gain")?.points).toEqual([{ t: 0, v: 5 }]);
38
+ expect(twice.lanes).toHaveLength(4);
39
+ });
40
+
41
+ it("does not mutate what it was given", () => {
42
+ const before = carved();
43
+ withLane(before, { target: "fx.n9.gain", points: [{ t: 0, v: 3 }] });
44
+ expect(before.lanes).toHaveLength(3);
45
+ });
46
+ });
47
+
48
+ describe("withoutLane", () => {
49
+ it("takes one lane and leaves the rest", () => {
50
+ // A node removed without its lane leaves an orphan driving a parameter that
51
+ // is no longer in the graph.
52
+ const next = withoutLane(carved(), "fx.n1.gain");
53
+ expect(next.lanes.map((l) => l.target)).toEqual(["fx.n2.gain", "volume"]);
54
+ });
55
+
56
+ it("empties the attribute when the last lane goes", () => {
57
+ const one: HfAutomation = { version: 1, lanes: [{ target: "fx.n1.gain", points: [] }] };
58
+ expect(automationAttrValue(withoutLane(one, "fx.n1.gain"))).toBe("");
59
+ });
60
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Reading and editing an element's automation from the property panel.
3
+ *
4
+ * Shared by the audio FX group (per-effect parameters) and the media section
5
+ * (track volume), so both agree on what "automated" means and both write the
6
+ * attribute the same way.
7
+ */
8
+
9
+ import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
10
+ import {
11
+ HF_AUDIO_AUTOMATION_ATTR,
12
+ HF_AUDIO_AUTOMATION_DATA_KEY,
13
+ parseAutomation,
14
+ resolveAutomation,
15
+ resolveAutomationRange,
16
+ serializeAutomation,
17
+ type HfAutomation,
18
+ } from "@hyperframes/core/audio-automation";
19
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
20
+
21
+ const EMPTY: HfAutomation = { version: 1, lanes: [] };
22
+
23
+ /**
24
+ * The element's automation as the panel should treat it.
25
+ *
26
+ * Pass the chain to have it bound: a lane whose effect has been deleted is then
27
+ * dropped rather than reported as automating something. Pass `undefined` when
28
+ * the caller genuinely does not know the chain — the volume section does not
29
+ * parse it — and every lane is preserved instead.
30
+ *
31
+ * That distinction matters because callers write this value straight back to the
32
+ * attribute. Resolving against a chain that was merely unavailable would delete
33
+ * every FX lane the moment someone automated the volume.
34
+ *
35
+ * An unreadable attribute reads as no automation rather than breaking the panel;
36
+ * it is left untouched until the author changes something.
37
+ */
38
+ export function readPanelAutomation(
39
+ raw: string | undefined,
40
+ chain: HfAudioFxChain | undefined,
41
+ ): HfAutomation {
42
+ if (!raw) return EMPTY;
43
+ try {
44
+ const parsed = parseAutomation(raw);
45
+ return chain ? resolveAutomation(parsed, chain) : parsed;
46
+ } catch {
47
+ return EMPTY;
48
+ }
49
+ }
50
+
51
+ /** Targets the element currently automates. */
52
+ export function automatedTargetsOf(automation: HfAutomation): Set<string> {
53
+ return new Set(automation.lanes.map((lane) => lane.target));
54
+ }
55
+
56
+ /**
57
+ * Add a lane for `target`, seeded with a single point at `current`.
58
+ *
59
+ * One point is a constant, so switching a parameter to an envelope does not
60
+ * change the sound — it only moves where the value comes from. The author then
61
+ * shapes it in the timeline.
62
+ */
63
+ export function withSeededLane(
64
+ automation: HfAutomation,
65
+ target: string,
66
+ current: number,
67
+ ): HfAutomation {
68
+ if (automation.lanes.some((lane) => lane.target === target)) return automation;
69
+ return { version: 1, lanes: [...automation.lanes, { target, points: [{ t: 0, v: current }] }] };
70
+ }
71
+
72
+ /** Drop one lane, handing its value back to the panel control. */
73
+ export function withoutLane(automation: HfAutomation, target: string): HfAutomation {
74
+ return { version: 1, lanes: automation.lanes.filter((lane) => lane.target !== target) };
75
+ }
76
+
77
+ /**
78
+ * Replace one lane, leaving every other lane alone.
79
+ *
80
+ * A script that hands back a whole `HfAutomation` describes only its OWN lane.
81
+ * Writing that wholesale would take the carve's lanes and the volume lane with
82
+ * it, so what the script produces has to be merged in by target rather than
83
+ * swapped for what is already there.
84
+ */
85
+ export function withLane(automation: HfAutomation, lane: HfAutomationLane): HfAutomation {
86
+ return {
87
+ version: 1,
88
+ lanes: [...automation.lanes.filter((l) => l.target !== lane.target), lane],
89
+ };
90
+ }
91
+
92
+ /** The attribute value for an automation set; empty when nothing is automated. */
93
+ export function automationAttrValue(automation: HfAutomation): string {
94
+ return automation.lanes.length > 0 ? serializeAutomation(automation) : "";
95
+ }
96
+
97
+ export { HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_AUTOMATION_DATA_KEY, resolveAutomationRange };
@@ -12,6 +12,7 @@ import {
12
12
  } from "./propertyPanelHelpers";
13
13
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
14
14
  import { FlatToggle } from "./propertyPanelFlatToggle";
15
+ import { AutomationToggle } from "./propertyPanelFxControls";
15
16
 
16
17
  // fallow-ignore-next-line complexity
17
18
  export function FlatMediaSection({
@@ -22,6 +23,9 @@ export function FlatMediaSection({
22
23
  onSetAttribute,
23
24
  onSetHtmlAttribute,
24
25
  onRemoveBackground,
26
+ volumeAutomated,
27
+ onAutomateVolume,
28
+ onRemoveVolumeAutomation,
25
29
  }: {
26
30
  projectDir: string | null;
27
31
  element: DomEditSelection;
@@ -29,6 +33,10 @@ export function FlatMediaSection({
29
33
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
30
34
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
31
35
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
36
+ /** A volume lane in the timeline drives the level; the slider cannot. */
37
+ volumeAutomated?: boolean;
38
+ onAutomateVolume?: () => void;
39
+ onRemoveVolumeAutomation?: () => void;
32
40
  onRemoveBackground?: (
33
41
  inputPath: string,
34
42
  options: {
@@ -197,15 +205,35 @@ export function FlatMediaSection({
197
205
  )}
198
206
  {(isVideo || isAudio) && (
199
207
  <>
200
- <FlatSlider
201
- label="Volume"
202
- value={volumePercent}
203
- min={0}
204
- max={100}
205
- tier={volumePercent === 100 ? "default" : "explicitCustom"}
206
- displayValue={`${volumePercent}%`}
207
- onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
208
- />
208
+ {/* The slider is disabled while a lane owns the level: a value set
209
+ here would be overwritten by the envelope on the next tick. The
210
+ toggle beside it carries the tooltip. */}
211
+ <div
212
+ className="hf-volume-row flex items-center gap-1"
213
+ data-volume-automated={volumeAutomated ? "" : undefined}
214
+ >
215
+ <div className="min-w-0 flex-1">
216
+ <FlatSlider
217
+ label="Volume"
218
+ value={volumePercent}
219
+ min={0}
220
+ max={100}
221
+ tier={volumePercent === 100 ? "default" : "explicitCustom"}
222
+ displayValue={`${volumePercent}%`}
223
+ disabled={volumeAutomated}
224
+ onCommit={(next) => void onSetAttribute("volume", formatNumericValue(next / 100))}
225
+ />
226
+ </div>
227
+ <AutomationToggle
228
+ paramKey="volume"
229
+ label="Volume"
230
+ automated={Boolean(volumeAutomated)}
231
+ onAutomate={onAutomateVolume ? () => onAutomateVolume() : undefined}
232
+ onRemoveAutomation={
233
+ onRemoveVolumeAutomation ? () => onRemoveVolumeAutomation() : undefined
234
+ }
235
+ />
236
+ </div>
209
237
  <FlatSlider
210
238
  label="Rate"
211
239
  value={playbackRate * 100}
@@ -16,6 +16,7 @@ export type PropertyPanelFlatProps = Pick<
16
16
  | "onSetAttribute"
17
17
  | "onSetAttributes"
18
18
  | "onSetAttributeLive"
19
+ | "onSetAttributeQuiet"
19
20
  | "onApplyColorGradingScope"
20
21
  | "onSetHtmlAttribute"
21
22
  | "onRemoveBackground"