@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,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
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the shape of a chain this panel did not write.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `propertyPanelAudioFxGroup.tsx`, which owned this whole
|
|
5
|
+
* provenance mechanism before the file grew past a size where it was still
|
|
6
|
+
* one thing to read alongside the carve and the leveller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from "react";
|
|
10
|
+
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
11
|
+
import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
|
|
12
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
13
|
+
import { trackChainObserved } from "./audioFxTelemetry.js";
|
|
14
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* This is the only way agent-applied effects become visible. An agent asked to
|
|
18
|
+
* fix a mix does not drive this panel — it edits the composition HTML, or runs
|
|
19
|
+
* `scripts/carve.mjs`, and the rack simply finds the work already done. Not
|
|
20
|
+
* one of the panel's own events fires for any of it.
|
|
21
|
+
*
|
|
22
|
+
* So: watch the chain's shape, and report it when it changes without a panel
|
|
23
|
+
* edit behind it. `panelEdits` is the discriminator — this session's own
|
|
24
|
+
* writes bump it, so a chain that moved while the counter stood still moved
|
|
25
|
+
* because something outside the studio moved it.
|
|
26
|
+
*
|
|
27
|
+
* Keyed on the shape rather than fired once per mount: a soft reload after an
|
|
28
|
+
* agent edits the file re-mounts this component, and a mount-only event would
|
|
29
|
+
* either miss the change or double-count every HMR. Comparing the fingerprint
|
|
30
|
+
* reports real changes and stays quiet through both.
|
|
31
|
+
*
|
|
32
|
+
* Returns a wrapped `onSetAttributeQuiet` that every other write in the panel
|
|
33
|
+
* must go through instead of the raw prop: the count is only meaningful if it
|
|
34
|
+
* is exhaustive, and a write added later that forgot to route through here
|
|
35
|
+
* would silently start reporting the author's own edits as having come from
|
|
36
|
+
* outside.
|
|
37
|
+
*/
|
|
38
|
+
export function useFxChainObserved(
|
|
39
|
+
element: DomEditSelection,
|
|
40
|
+
chain: HfAudioFxChain,
|
|
41
|
+
carve: HfCarveSettings | null,
|
|
42
|
+
automation: HfAutomation,
|
|
43
|
+
onSetAttributeQuietRaw: (attr: string, value: string | null) => void | Promise<void>,
|
|
44
|
+
): (attr: string, value: string | null) => void | Promise<void> {
|
|
45
|
+
const lastShape = useRef<string | null>(null);
|
|
46
|
+
const panelEdits = useRef(0);
|
|
47
|
+
|
|
48
|
+
const onSetAttributeQuiet = (attr: string, value: string | null): void | Promise<void> => {
|
|
49
|
+
panelEdits.current += 1;
|
|
50
|
+
return onSetAttributeQuietRaw(attr, value);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const shape = JSON.stringify([
|
|
55
|
+
chain.nodes.map((n) => `${n.type}:${n.fromPreset ?? ""}:${n.fromCarve ? 1 : 0}`),
|
|
56
|
+
carve?.enabled ?? false,
|
|
57
|
+
automation.lanes.length,
|
|
58
|
+
]);
|
|
59
|
+
if (lastShape.current === shape) return;
|
|
60
|
+
const firstSight = lastShape.current === null;
|
|
61
|
+
lastShape.current = shape;
|
|
62
|
+
// An empty chain on first sight is the ordinary case — nothing to report.
|
|
63
|
+
if (firstSight && chain.nodes.length === 0 && !carve) return;
|
|
64
|
+
trackChainObserved(
|
|
65
|
+
chain,
|
|
66
|
+
{
|
|
67
|
+
firstSight,
|
|
68
|
+
panelEdits: panelEdits.current,
|
|
69
|
+
hasCarve: Boolean(carve?.enabled),
|
|
70
|
+
hasAutomation: automation.lanes.length > 0,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
trackKind: classifyAudioName(element.id, element.element?.getAttribute("src")) ?? undefined,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
// Each observation is measured against the edits made SINCE the last one, so
|
|
77
|
+
// a session that edits, then receives an outside change, still reports that
|
|
78
|
+
// second change as unattributed.
|
|
79
|
+
panelEdits.current = 0;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return onSetAttributeQuiet;
|
|
83
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The levelling script and its playhead-transport audition: measure this
|
|
3
|
+
* track, write the "Even Out Levels" node and lane, and preview the result
|
|
4
|
+
* before committing to it.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `propertyPanelAudioFxGroup.tsx`, which owned all of this before
|
|
7
|
+
* the file grew past a size where "levelling" was still one thing to read.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useRef, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
HF_AUDIO_FX_ATTR,
|
|
13
|
+
serializeAudioFxChain,
|
|
14
|
+
type HfAudioFxChain,
|
|
15
|
+
} from "@hyperframes/core/audio-fx";
|
|
16
|
+
import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
|
|
17
|
+
import type { HfAutomation } from "@hyperframes/core/audio-automation";
|
|
18
|
+
import {
|
|
19
|
+
automationAttrValue,
|
|
20
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
21
|
+
withLane,
|
|
22
|
+
withoutLane,
|
|
23
|
+
} from "./propertyPanelAutomation";
|
|
24
|
+
import { trackLeveller } from "./audioFxTelemetry.js";
|
|
25
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
26
|
+
import { usePlayerStore } from "../../player";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rate the track is decoded at. Analysis is self-consistent because it reads
|
|
30
|
+
* the decoded buffer's own rate, so this only has to be a sane audio rate.
|
|
31
|
+
*/
|
|
32
|
+
const DECODE_SAMPLE_RATE = 48000;
|
|
33
|
+
|
|
34
|
+
/** A parsed attribute, kept only when it is a usable positive number. */
|
|
35
|
+
function positiveFinite(n: number): number | null {
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useFxLevelling(
|
|
40
|
+
element: DomEditSelection,
|
|
41
|
+
chain: HfAudioFxChain,
|
|
42
|
+
automation: HfAutomation,
|
|
43
|
+
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
44
|
+
onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>,
|
|
45
|
+
setAnalysing: (value: boolean) => void,
|
|
46
|
+
) {
|
|
47
|
+
/**
|
|
48
|
+
* This track's audio, decoded once and kept.
|
|
49
|
+
*
|
|
50
|
+
* Levelling is measured from it, and hover-auditioning means measuring on every
|
|
51
|
+
* pass over the button — fetching and decoding a several-minute voiceover each
|
|
52
|
+
* time would make the audition slower than the thing it is previewing. Keyed by
|
|
53
|
+
* `src` so a track pointed at a different file re-decodes.
|
|
54
|
+
*/
|
|
55
|
+
const decoded = useRef<{ src: string; samples: Float32Array; sampleRate: number } | null>(null);
|
|
56
|
+
|
|
57
|
+
const decodeTrack = async (): Promise<{ samples: Float32Array; sampleRate: number } | null> => {
|
|
58
|
+
const el = element.element;
|
|
59
|
+
const src = el?.getAttribute("src");
|
|
60
|
+
const doc = el?.ownerDocument;
|
|
61
|
+
if (!src || !doc) return null;
|
|
62
|
+
const cached = decoded.current;
|
|
63
|
+
if (cached?.src === src) return cached;
|
|
64
|
+
const Ctor =
|
|
65
|
+
window.OfflineAudioContext ??
|
|
66
|
+
(window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
|
|
67
|
+
.webkitOfflineAudioContext;
|
|
68
|
+
if (!Ctor) return null;
|
|
69
|
+
const res = await fetch(new URL(src, doc.baseURI).href);
|
|
70
|
+
const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
|
|
71
|
+
await res.arrayBuffer(),
|
|
72
|
+
);
|
|
73
|
+
const next = { src, samples: buffer.getChannelData(0), sampleRate: buffer.sampleRate };
|
|
74
|
+
decoded.current = next;
|
|
75
|
+
return next;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The part of the decoded file this clip actually plays.
|
|
80
|
+
*
|
|
81
|
+
* A lane's `t` is seconds from the start of the CLIP, but the decode is the
|
|
82
|
+
* whole file from its first sample — so measuring a trimmed clip produced an
|
|
83
|
+
* envelope offset by the trim, and every correction landed early by exactly
|
|
84
|
+
* `media-start`. Slicing here is what puts the two clocks back on the same
|
|
85
|
+
* zero.
|
|
86
|
+
*/
|
|
87
|
+
const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
|
|
88
|
+
const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
|
|
89
|
+
const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
|
|
90
|
+
const from = mediaStart
|
|
91
|
+
? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
|
|
92
|
+
: 0;
|
|
93
|
+
const to = duration
|
|
94
|
+
? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
|
|
95
|
+
: audio.samples.length;
|
|
96
|
+
return from === 0 && to === audio.samples.length
|
|
97
|
+
? audio.samples
|
|
98
|
+
: audio.samples.subarray(from, to);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const runLeveller = async (): Promise<void> => {
|
|
102
|
+
setAnalysing(true);
|
|
103
|
+
try {
|
|
104
|
+
const audio = await decodeTrack();
|
|
105
|
+
if (!audio) return;
|
|
106
|
+
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
|
|
107
|
+
if (!result) return;
|
|
108
|
+
trackLeveller("run");
|
|
109
|
+
await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
110
|
+
// Merged by target, never written wholesale: the script describes its own
|
|
111
|
+
// lane only, and replacing the attribute would take the carve's lanes and
|
|
112
|
+
// the volume lane with it.
|
|
113
|
+
const lane = result.automation.lanes[0];
|
|
114
|
+
if (lane) {
|
|
115
|
+
void onSetAttributeQuiet(
|
|
116
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
117
|
+
automationAttrValue(withLane(automation, lane)) || null,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// A track whose audio cannot be fetched or decoded simply gets no
|
|
122
|
+
// levelling, the same way an unreadable carve source is skipped.
|
|
123
|
+
} finally {
|
|
124
|
+
setAnalysing(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where the playhead was when an audition started the transport, so leaving
|
|
130
|
+
* can put it back. Null means this audition did not start playback — the
|
|
131
|
+
* transport was already running and must be left alone.
|
|
132
|
+
*/
|
|
133
|
+
const auditionReturn = useRef<number | null>(null);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Start playback for an audition, and stop it again on the way out.
|
|
137
|
+
*
|
|
138
|
+
* An audition writes the preset to the running graph, which is silent while
|
|
139
|
+
* the transport is paused — so a paused author hovering a preset heard
|
|
140
|
+
* nothing at all, and the whole affordance only worked mid-playback. Hovering
|
|
141
|
+
* now plays from the playhead, and leaving stops and rewinds to exactly where
|
|
142
|
+
* it started: browsing the shelf must not cost the author their place.
|
|
143
|
+
*
|
|
144
|
+
* Already playing, this does nothing in either direction. The author started
|
|
145
|
+
* that, and stopping their transport because they passed over a preset would
|
|
146
|
+
* be the panel taking a decision that was not offered to it.
|
|
147
|
+
*/
|
|
148
|
+
const auditionTransport = (on: boolean): void => {
|
|
149
|
+
const store = usePlayerStore.getState();
|
|
150
|
+
if (on) {
|
|
151
|
+
if (store.isPlaying || auditionReturn.current !== null) return;
|
|
152
|
+
auditionReturn.current = store.currentTime;
|
|
153
|
+
store.requestPlayback(true);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const returnTo = auditionReturn.current;
|
|
157
|
+
if (returnTo === null) return;
|
|
158
|
+
auditionReturn.current = null;
|
|
159
|
+
store.requestPlayback(false, returnTo);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const [auditioningLevel, setAuditioningLevel] = useState(false);
|
|
163
|
+
/**
|
|
164
|
+
* Bumped on every enter and leave, so a measurement can tell whether the
|
|
165
|
+
* pointer is still on the button when it finishes.
|
|
166
|
+
*
|
|
167
|
+
* Decoding a long voiceover takes seconds, and a hover that takes seconds is
|
|
168
|
+
* one the author has usually already left. Applying the result then would put
|
|
169
|
+
* levelling on a track nobody asked to level, through a channel that does not
|
|
170
|
+
* persist — so it would be audible, invisible in the document, and gone on the
|
|
171
|
+
* next reload. This counter is what makes a late result a no-op.
|
|
172
|
+
*/
|
|
173
|
+
const auditionRun = useRef(0);
|
|
174
|
+
|
|
175
|
+
/** Both attributes back to the stored chain, because levelling is a node AND the lane that drives it. */
|
|
176
|
+
const stopLevelAudition = (): void => {
|
|
177
|
+
setAuditioningLevel(false);
|
|
178
|
+
void onSetAttributeLive(
|
|
179
|
+
HF_AUDIO_FX_ATTR,
|
|
180
|
+
chain.nodes.length ? serializeAudioFxChain(chain) : null,
|
|
181
|
+
);
|
|
182
|
+
void onSetAttributeLive(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(automation) || null);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Measure this track and play the levelling without persisting it. */
|
|
186
|
+
const startLevelAudition = async (run: number): Promise<void> => {
|
|
187
|
+
setAuditioningLevel(true);
|
|
188
|
+
try {
|
|
189
|
+
const audio = await decodeTrack();
|
|
190
|
+
// Gone, or superseded by a later hover. Either way this result is stale.
|
|
191
|
+
if (!audio || run !== auditionRun.current) return;
|
|
192
|
+
const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
|
|
193
|
+
if (!result || run !== auditionRun.current) return;
|
|
194
|
+
void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
|
|
195
|
+
const lane = result.automation.lanes[0];
|
|
196
|
+
if (lane) {
|
|
197
|
+
void onSetAttributeLive(
|
|
198
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
199
|
+
automationAttrValue(withLane(automation, lane)) || null,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Same as the real run: a track that cannot be decoded simply does not
|
|
204
|
+
// audition, rather than failing the panel.
|
|
205
|
+
} finally {
|
|
206
|
+
if (run === auditionRun.current) setAuditioningLevel(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `false` puts the stored chain and automation back; `true` measures and
|
|
212
|
+
* plays the result without persisting it.
|
|
213
|
+
*/
|
|
214
|
+
const auditionLevel = async (on: boolean): Promise<void> => {
|
|
215
|
+
const run = ++auditionRun.current;
|
|
216
|
+
if (!on) return stopLevelAudition();
|
|
217
|
+
await startLevelAudition(run);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const removeLeveller = (): void => {
|
|
221
|
+
trackLeveller("removed");
|
|
222
|
+
const { chain: next, removedTarget } = removeLevelling(chain);
|
|
223
|
+
void onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(next));
|
|
224
|
+
// The lane goes with the node. An orphan keeps driving a parameter that is
|
|
225
|
+
// no longer in the graph.
|
|
226
|
+
if (removedTarget) {
|
|
227
|
+
void onSetAttributeQuiet(
|
|
228
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
229
|
+
automationAttrValue(withoutLane(automation, removedTarget)) || null,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return { runLeveller, auditionTransport, auditioningLevel, auditionLevel, removeLeveller };
|
|
235
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useRef, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { InlineTextToolbar } from "./InlineTextToolbar";
|
|
3
|
+
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
|
4
|
+
import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
|
|
5
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
+
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
7
|
+
import {
|
|
8
|
+
canEditElementTextInline,
|
|
9
|
+
canEditTextInline,
|
|
10
|
+
isDoublePress,
|
|
11
|
+
type PressMark,
|
|
12
|
+
} from "./domEditInlineText";
|
|
13
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The canvas' side of editing text where it sits.
|
|
17
|
+
*
|
|
18
|
+
* Holds the session, decides which presses open one, and knows the two things
|
|
19
|
+
* the canvas has to do differently while one is open: stand aside so the caret
|
|
20
|
+
* underneath can be reached, and stop taking focus back.
|
|
21
|
+
*
|
|
22
|
+
* The actions context is read here rather than threaded through the overlay's
|
|
23
|
+
* props, the same way the agent surfaces in that overlay read it, and it is
|
|
24
|
+
* absent in standalone player mounts, which have no project to edit.
|
|
25
|
+
*/
|
|
26
|
+
export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection | null>): {
|
|
27
|
+
editing: boolean;
|
|
28
|
+
/** Open an edit when this press pairs with the last one. */
|
|
29
|
+
startFromPress: (event: { clientX: number; clientY: number }) => boolean;
|
|
30
|
+
/** Handle a key on the canvas. Returns true when it opened an edit. */
|
|
31
|
+
handleKeyDown: (event: {
|
|
32
|
+
key: string;
|
|
33
|
+
shiftKey: boolean;
|
|
34
|
+
ctrlKey: boolean;
|
|
35
|
+
metaKey: boolean;
|
|
36
|
+
altKey: boolean;
|
|
37
|
+
}) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The styling controls for the current selection, for the caller to render.
|
|
40
|
+
*
|
|
41
|
+
* Handed back rather than mounted somewhere central because it belongs to the
|
|
42
|
+
* session this hook owns, and appears and disappears with it.
|
|
43
|
+
*/
|
|
44
|
+
toolbar: ReactNode;
|
|
45
|
+
} {
|
|
46
|
+
const actions = useDomEditActionsContextOptional();
|
|
47
|
+
const inlineText = useInlineTextEdit({
|
|
48
|
+
onCommit: (commit) => void actions?.handleDomRichTextCommit(commit),
|
|
49
|
+
onPause: () => usePlayerStore.getState().setIsPlaying(false),
|
|
50
|
+
});
|
|
51
|
+
const lastPressRef = useRef<PressMark | null>(null);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The element under this press, hit-tested now rather than read from state.
|
|
55
|
+
*
|
|
56
|
+
* Every cached answer in the overlay is React state that has not caught up
|
|
57
|
+
* with the press happening now: the hover is documented as an async cache,
|
|
58
|
+
* and the selection from the first press has not re-rendered. Reading either
|
|
59
|
+
* meant the first double press on any element opened nothing, which is every
|
|
60
|
+
* double press that matters.
|
|
61
|
+
*/
|
|
62
|
+
const elementUnderPress = (event: { clientX: number; clientY: number }) => {
|
|
63
|
+
const iframe = actions?.previewIframeRef?.current;
|
|
64
|
+
if (!iframe) return null;
|
|
65
|
+
// Studio suppresses pointer events inside the composition so the canvas
|
|
66
|
+
// overlay can own input, which means a plain elementFromPoint only ever
|
|
67
|
+
// finds wrappers. This helper lifts that for the length of the hit test,
|
|
68
|
+
// and is the same one the canvas uses to decide what was clicked.
|
|
69
|
+
return getPreviewTargetFromPointer(
|
|
70
|
+
iframe,
|
|
71
|
+
event.clientX,
|
|
72
|
+
event.clientY,
|
|
73
|
+
selectionRef.current?.compositionPath ?? null,
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A point on Studio's canvas, in the scaled composition's coordinates. */
|
|
78
|
+
const compositionPoint = (event: { clientX: number; clientY: number }) => {
|
|
79
|
+
const iframe = actions?.previewIframeRef?.current;
|
|
80
|
+
const view = iframe?.contentWindow;
|
|
81
|
+
if (!iframe || !view?.innerWidth) return undefined;
|
|
82
|
+
const box = iframe.getBoundingClientRect();
|
|
83
|
+
const scale = box.width / view.innerWidth || 1;
|
|
84
|
+
return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
editing: inlineText.session !== null,
|
|
89
|
+
toolbar: (
|
|
90
|
+
<InlineTextToolbar
|
|
91
|
+
session={inlineText.session}
|
|
92
|
+
iframe={actions?.previewIframeRef?.current ?? null}
|
|
93
|
+
/>
|
|
94
|
+
),
|
|
95
|
+
// Enter opens the selected element's text, the way every design tool does,
|
|
96
|
+
// and is the dependable way in: a double press has to survive the canvas'
|
|
97
|
+
// gesture machinery, while this is one key on a selection that has settled.
|
|
98
|
+
handleKeyDown: (event) => {
|
|
99
|
+
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
|
|
100
|
+
return false;
|
|
101
|
+
const target = selectionRef.current;
|
|
102
|
+
if (inlineText.session || !target || !canEditTextInline(target)) return false;
|
|
103
|
+
return inlineText.start(target.element);
|
|
104
|
+
},
|
|
105
|
+
startFromPress: (event) => {
|
|
106
|
+
if (inlineText.session) return false;
|
|
107
|
+
const element = elementUnderPress(event);
|
|
108
|
+
const press = { x: event.clientX, y: event.clientY, at: Date.now(), element };
|
|
109
|
+
const paired = isDoublePress(lastPressRef.current, press);
|
|
110
|
+
lastPressRef.current = press;
|
|
111
|
+
|
|
112
|
+
if (!paired) return false;
|
|
113
|
+
if (!element || !canEditElementTextInline(element)) return false;
|
|
114
|
+
// The caret opens where the press landed, which means mapping the point
|
|
115
|
+
// out of Studio's coordinates and into the composition's own.
|
|
116
|
+
return inlineText.start(element, compositionPoint(event));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { useVolumeAutomation, type VolumeAutomationBinding } from "./useVolumeAutomation";
|
|
6
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
function bind(dataAttributes: Record<string, string>) {
|
|
11
|
+
const onSetAttributeQuiet = vi.fn();
|
|
12
|
+
const captured: { current: VolumeAutomationBinding | null } = { current: null };
|
|
13
|
+
function Probe() {
|
|
14
|
+
captured.current = useVolumeAutomation(
|
|
15
|
+
{ dataAttributes } as unknown as DomEditSelection,
|
|
16
|
+
onSetAttributeQuiet,
|
|
17
|
+
);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const host = document.createElement("div");
|
|
21
|
+
document.body.append(host);
|
|
22
|
+
act(() => {
|
|
23
|
+
createRoot(host).render(<Probe />);
|
|
24
|
+
});
|
|
25
|
+
if (!captured.current) throw new Error("hook never ran");
|
|
26
|
+
return { binding: captured.current, onSetAttributeQuiet };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const volumeLane = (v: number) =>
|
|
30
|
+
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v }] }] });
|
|
31
|
+
|
|
32
|
+
describe("useVolumeAutomation", () => {
|
|
33
|
+
it("reports an unautomated track", () => {
|
|
34
|
+
expect(bind({ volume: "0.55" }).binding.volumeAutomated).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reports a track with a volume lane", () => {
|
|
38
|
+
expect(bind({ volume: "0.55", automation: volumeLane(0.2) }).binding.volumeAutomated).toBe(
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not count an FX lane as automating the volume", () => {
|
|
44
|
+
const automation = JSON.stringify({
|
|
45
|
+
version: 1,
|
|
46
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
47
|
+
});
|
|
48
|
+
expect(bind({ volume: "0.55", automation }).binding.volumeAutomated).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("seeds a new lane at the level the slider already shows", () => {
|
|
52
|
+
// Automating a track must not change how loud it is.
|
|
53
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.55" });
|
|
54
|
+
act(() => binding.onAutomateVolume());
|
|
55
|
+
expect(onSetAttributeQuiet).toHaveBeenCalledWith(
|
|
56
|
+
"data-automation",
|
|
57
|
+
JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [{ t: 0, v: 0.55 }] }] }),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("treats a missing data-volume as unity", () => {
|
|
62
|
+
const { binding, onSetAttributeQuiet } = bind({});
|
|
63
|
+
act(() => binding.onAutomateVolume());
|
|
64
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps FX lanes when adding the volume one", () => {
|
|
68
|
+
const automation = JSON.stringify({
|
|
69
|
+
version: 1,
|
|
70
|
+
lanes: [{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] }],
|
|
71
|
+
});
|
|
72
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
73
|
+
act(() => binding.onAutomateVolume());
|
|
74
|
+
expect(
|
|
75
|
+
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
|
|
76
|
+
(l: { target: string }) => l.target,
|
|
77
|
+
),
|
|
78
|
+
).toEqual(["fx.n1.frequency", "volume"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deletes only the volume lane", () => {
|
|
82
|
+
const automation = JSON.stringify({
|
|
83
|
+
version: 1,
|
|
84
|
+
lanes: [
|
|
85
|
+
{ target: "volume", points: [{ t: 0, v: 0.2 }] },
|
|
86
|
+
{ target: "fx.n1.frequency", points: [{ t: 0, v: 400 }] },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation });
|
|
90
|
+
act(() => binding.onRemoveVolumeAutomation());
|
|
91
|
+
expect(
|
|
92
|
+
JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes.map(
|
|
93
|
+
(l: { target: string }) => l.target,
|
|
94
|
+
),
|
|
95
|
+
).toEqual(["fx.n1.frequency"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("clears the attribute when the volume lane was the only one", () => {
|
|
99
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "0.4", automation: volumeLane(0.2) });
|
|
100
|
+
act(() => binding.onRemoveVolumeAutomation());
|
|
101
|
+
// Null, not "": the quiet path removes an attribute it is given null for.
|
|
102
|
+
expect(onSetAttributeQuiet).toHaveBeenCalledWith("data-automation", null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reads an unreadable attribute as no automation", () => {
|
|
106
|
+
expect(bind({ volume: "0.55", automation: "{not json" }).binding.volumeAutomated).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("seeds at unity when data-volume is present but empty", () => {
|
|
110
|
+
// Number("") is 0, so `?? "1"` alone seeded the lane at silence while the
|
|
111
|
+
// engine read the same empty attribute as unity.
|
|
112
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "" });
|
|
113
|
+
act(() => binding.onAutomateVolume());
|
|
114
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("seeds at unity when data-volume is not a number", () => {
|
|
118
|
+
const { binding, onSetAttributeQuiet } = bind({ volume: "loud" });
|
|
119
|
+
act(() => binding.onAutomateVolume());
|
|
120
|
+
expect(JSON.parse(String(onSetAttributeQuiet.mock.calls[0][1])).lanes[0].points[0].v).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
});
|