@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.
- package/dist/assets/{hyperframes-player-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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}
|