@hyperframes/studio 0.8.11 → 0.8.12
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-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6431 -5515
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* is not an entry in the chain.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
|
9
|
+
import { audioFxRevealTarget, scrollRevealedRowIntoView } from "./audioFxRevealTarget.js";
|
|
9
10
|
import {
|
|
10
11
|
defaultAudioFxParams,
|
|
11
12
|
mintAudioFxNodeId,
|
|
@@ -25,6 +26,7 @@ import { applyAudioFxProfile, getAudioFxProfile } from "@hyperframes/core/audio-
|
|
|
25
26
|
import { audioFxJobNode, type HfAudioFxJob } from "@hyperframes/core/audio-fx-jobs";
|
|
26
27
|
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
|
27
28
|
import { FxRackChain } from "./propertyPanelFxRackChain.js";
|
|
29
|
+
import { CLIP_SIGNAL_PATH } from "./audioFxSignalPath.js";
|
|
28
30
|
import { FxAddMenu } from "./propertyPanelFxAddMenu.js";
|
|
29
31
|
import { useFxAudition } from "./useFxAudition.js";
|
|
30
32
|
import {
|
|
@@ -103,6 +105,9 @@ export function FxSection({
|
|
|
103
105
|
onRemovePresetAutomation,
|
|
104
106
|
automatedPresets,
|
|
105
107
|
onAuditionTransport,
|
|
108
|
+
signalPath,
|
|
109
|
+
revealTarget,
|
|
110
|
+
revealNonce,
|
|
106
111
|
}: FxSectionProps) {
|
|
107
112
|
const presetAutomated = automatedPresets ?? new Set<string>();
|
|
108
113
|
// Falls back to the persisting write when no preview handler is supplied, which
|
|
@@ -134,11 +139,18 @@ export function FxSection({
|
|
|
134
139
|
[chain, onChainPreview],
|
|
135
140
|
);
|
|
136
141
|
|
|
137
|
-
const { audition, clearAudition } = useFxAudition(
|
|
142
|
+
const { audition, clearAudition, storedChain } = useFxAudition(
|
|
143
|
+
chain,
|
|
144
|
+
onChainPreview,
|
|
145
|
+
onAuditionTransport,
|
|
146
|
+
);
|
|
138
147
|
|
|
139
148
|
const applyPreset = useCallback(
|
|
140
149
|
(id: string) => {
|
|
141
|
-
|
|
150
|
+
// The stored chain, not whatever is being auditioned on top of it — see
|
|
151
|
+
// `storedChain`. Clicking preset B while hovering preset A used to save
|
|
152
|
+
// both, which is heard as the effect running twice.
|
|
153
|
+
const next = applyPresetToChain(storedChain(), id, trackKind);
|
|
142
154
|
if (!next) return;
|
|
143
155
|
// The audition WAS this, so there is nothing to put back — and putting the
|
|
144
156
|
// old chain back over the write that just landed is a race the author
|
|
@@ -150,7 +162,7 @@ export function FxSection({
|
|
|
150
162
|
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
|
|
151
163
|
setPicking(false);
|
|
152
164
|
},
|
|
153
|
-
[
|
|
165
|
+
[storedChain, mutate, clearAudition, trackKind],
|
|
154
166
|
);
|
|
155
167
|
|
|
156
168
|
const addJob = useCallback(
|
|
@@ -311,6 +323,51 @@ export function FxSection({
|
|
|
311
323
|
}, [handBuilt, eqIds.length, showCarve]);
|
|
312
324
|
const [openEq, setOpenEq] = useState<string | null>(null);
|
|
313
325
|
|
|
326
|
+
/** The reveal request held until its row is mounted and scrolled. */
|
|
327
|
+
const [consumedRevealNonce, setConsumedRevealNonce] = useState<number | null>(null);
|
|
328
|
+
const [pendingReveal, setPendingReveal] = useState<{
|
|
329
|
+
nonce: number;
|
|
330
|
+
target: string;
|
|
331
|
+
} | null>(null);
|
|
332
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
333
|
+
if (revealNonce != null && revealNonce !== consumedRevealNonce) {
|
|
334
|
+
setConsumedRevealNonce(revealNonce);
|
|
335
|
+
const where = revealTarget ? audioFxRevealTarget(revealTarget, chain) : null;
|
|
336
|
+
if (where) {
|
|
337
|
+
// Each surface has its own open-state; the resolver says which one owns
|
|
338
|
+
// this parameter. Opening the wrong one leaves the click looking dead.
|
|
339
|
+
if (where.kind === "node") setOpenNode(where.index);
|
|
340
|
+
if (where.kind === "eq") setOpenEq(where.eqId);
|
|
341
|
+
if (where.kind === "carve") setCarveOpen(true);
|
|
342
|
+
if (where.kind === "preset") {
|
|
343
|
+
setCollapsedRuns((was) => {
|
|
344
|
+
if (!was.has(where.runKey)) return was;
|
|
345
|
+
const next = new Set(was);
|
|
346
|
+
next.delete(where.runKey);
|
|
347
|
+
return next;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
setPendingReveal(where && revealTarget ? { nonce: revealNonce, target: revealTarget } : null);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Scroll the revealed parameter into view once its row has actually mounted.
|
|
356
|
+
*
|
|
357
|
+
* The request itself is a dependency so a second click on an already-open
|
|
358
|
+
* surface still scrolls. It is cleared once used, so a later unrelated
|
|
359
|
+
* re-render does not yank the panel back to an old parameter.
|
|
360
|
+
*/
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
const target = pendingReveal?.target;
|
|
363
|
+
if (target && scrollRevealedRowIntoView(rootRef.current, target, chain)) {
|
|
364
|
+
setPendingReveal(null);
|
|
365
|
+
}
|
|
366
|
+
// `chain` is deliberately not a dependency: it changes on every knob edit,
|
|
367
|
+
// and re-running then would scroll the panel while the author is dragging.
|
|
368
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
369
|
+
}, [openNode, openEq, carveOpen, collapsedRuns, pendingReveal]);
|
|
370
|
+
|
|
314
371
|
const addEq = useCallback(() => {
|
|
315
372
|
clearAudition();
|
|
316
373
|
const { chain: next, eqId } = addAudioEq(chain);
|
|
@@ -404,12 +461,14 @@ export function FxSection({
|
|
|
404
461
|
|
|
405
462
|
return (
|
|
406
463
|
<div
|
|
464
|
+
ref={rootRef}
|
|
407
465
|
className="hf-fx-section space-y-2"
|
|
408
466
|
// Focus lives on the buttons and menu items inside, so the keystroke
|
|
409
467
|
// bubbles to here without the section needing focus of its own.
|
|
410
468
|
onKeyDown={closeMenus}
|
|
411
469
|
>
|
|
412
470
|
<FxRackChain
|
|
471
|
+
signalPath={signalPath ?? CLIP_SIGNAL_PATH}
|
|
413
472
|
chain={chain}
|
|
414
473
|
showCarve={showCarve}
|
|
415
474
|
carveNodes={carveNodes}
|
|
@@ -6,8 +6,27 @@
|
|
|
6
6
|
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
7
7
|
import type { HfAudioNameKind, HfCarveSettings } from "@hyperframes/core/audio-carve";
|
|
8
8
|
import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
|
|
9
|
+
import type { AudioFxSignalPath } from "./audioFxSignalPath.js";
|
|
9
10
|
|
|
10
11
|
export interface FxSectionProps {
|
|
12
|
+
/**
|
|
13
|
+
* An automation lane asked to be shown: its `fx.<node>.<param>` / `volume`
|
|
14
|
+
* target. The section opens whichever surface owns that parameter — a node
|
|
15
|
+
* row, an EQ module, a preset run, or the carve — and scrolls to it.
|
|
16
|
+
*/
|
|
17
|
+
revealTarget?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* The reveal request's nonce. Consumption keys on THIS, not on
|
|
20
|
+
* `revealTarget`: clicking a lane selects the clip first, which remounts this
|
|
21
|
+
* section, so a `!==` against the previous VALUE initialises to the
|
|
22
|
+
* already-set request and never fires — and a second click on the same lane
|
|
23
|
+
* would be byte-identical and inert. Same reason `PropertyPanelFlat` keys its
|
|
24
|
+
* own consumption on the nonce.
|
|
25
|
+
*/
|
|
26
|
+
revealNonce?: number | null;
|
|
27
|
+
/** What the rack's `In`/`Out` lines name — see `audioFxSignalPath`. Absent
|
|
28
|
+
* means an ungrouped clip, which is what those lines said before groups. */
|
|
29
|
+
signalPath?: AudioFxSignalPath;
|
|
11
30
|
chain: HfAudioFxChain;
|
|
12
31
|
/** Targets this track already automates, as `fx.<nodeId>.<param>` strings. */
|
|
13
32
|
automatedTargets?: ReadonlySet<string>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the Audio FX section for a lane's reveal request.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `PropertyPanelFlat.tsx` to keep it under the studio's 600-line
|
|
5
|
+
* cap. All three of the request's hazards live here rather than being restated
|
|
6
|
+
* at the call site: it must be current, it is consumed by NONCE, and it is
|
|
7
|
+
* retired when the panel goes away.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from "react";
|
|
11
|
+
import { usePlayerStore } from "../../player";
|
|
12
|
+
import { isRevealedAudioFxRequestCurrent } from "../../player/store/keyframeSlice";
|
|
13
|
+
|
|
14
|
+
export interface AudioFxRevealSectionInput {
|
|
15
|
+
/** The element the panel is showing, or null. */
|
|
16
|
+
elementId: string | null | undefined;
|
|
17
|
+
/** False when the panel does not render an Audio FX section at all. */
|
|
18
|
+
hasAudioFxSection: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The nonce this panel should act on, or null.
|
|
23
|
+
*
|
|
24
|
+
* Consumption is keyed on the NONCE, not the request object: clicking a lane
|
|
25
|
+
* selects the clip first, which REMOUNTS this panel, so a `!==` against the
|
|
26
|
+
* previous value would initialise to the already-set request and never fire.
|
|
27
|
+
* The nonce also makes a second click on the same lane a new request.
|
|
28
|
+
*/
|
|
29
|
+
export function useAudioFxRevealSection(input: AudioFxRevealSectionInput): {
|
|
30
|
+
/** Non-null exactly once per request: open the section on this commit. */
|
|
31
|
+
revealNonce: number | null;
|
|
32
|
+
consume: (nonce: number) => void;
|
|
33
|
+
} {
|
|
34
|
+
const revealedAudioFxTarget = usePlayerStore((s) => s.revealedAudioFxTarget);
|
|
35
|
+
const timelineProjectId = usePlayerStore((s) => s.timelineProjectId);
|
|
36
|
+
const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
37
|
+
const clearRevealedAudioFxTarget = usePlayerStore((s) => s.clearRevealedAudioFxTarget);
|
|
38
|
+
const [consumed, setConsumed] = useState<number | null>(null);
|
|
39
|
+
|
|
40
|
+
// Retire the request once this panel is gone. Consumption is nonce-guarded so
|
|
41
|
+
// a stale request was already harmless — but it sat in the store until the
|
|
42
|
+
// next click, and a request nobody will ever consume is state every reader
|
|
43
|
+
// then has to reason about.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (consumed === null) return;
|
|
46
|
+
return () => clearRevealedAudioFxTarget(consumed);
|
|
47
|
+
}, [consumed, clearRevealedAudioFxTarget]);
|
|
48
|
+
|
|
49
|
+
const forThisPanel =
|
|
50
|
+
revealedAudioFxTarget !== null &&
|
|
51
|
+
revealedAudioFxTarget.elementKey === input.elementId &&
|
|
52
|
+
isRevealedAudioFxRequestCurrent(revealedAudioFxTarget, {
|
|
53
|
+
timelineProjectId,
|
|
54
|
+
timelineSessionEpoch,
|
|
55
|
+
}) &&
|
|
56
|
+
input.hasAudioFxSection
|
|
57
|
+
? revealedAudioFxTarget.nonce
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
revealNonce: forThisPanel !== null && forThisPanel !== consumed ? forThisPanel : null,
|
|
62
|
+
consume: setConsumed,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { auditionStart } from "./useAuditionTransport";
|
|
3
|
+
|
|
4
|
+
const SPANS = [
|
|
5
|
+
{ start: 2, duration: 7 },
|
|
6
|
+
{ start: 18, duration: 7 },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
describe("auditionStart", () => {
|
|
10
|
+
// Nothing to aim at — a caller with no spans keeps the old behaviour: play
|
|
11
|
+
// from wherever the author left the playhead.
|
|
12
|
+
it("stays put when there are no spans", () => {
|
|
13
|
+
expect(auditionStart(undefined, 0)).toBeNull();
|
|
14
|
+
expect(auditionStart([], 0)).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Already inside the clip: moving the playhead here would be the UI taking a
|
|
18
|
+
// decision it was not asked for, and it would cost the author their place for
|
|
19
|
+
// no gain.
|
|
20
|
+
it("stays put when the playhead is already inside a span", () => {
|
|
21
|
+
expect(auditionStart(SPANS, 2)).toBeNull();
|
|
22
|
+
expect(auditionStart(SPANS, 8.9)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The bug this exists for: hovering a preset at 0:00 on a group whose members
|
|
26
|
+
// start at 0:02 played silence under the effect.
|
|
27
|
+
it("jumps to the next span when the playhead is before or between them", () => {
|
|
28
|
+
expect(auditionStart(SPANS, 0)).toBe(2);
|
|
29
|
+
expect(auditionStart(SPANS, 9)).toBe(18);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Past everything, wrap to the first rather than play out the tail in silence.
|
|
33
|
+
it("wraps to the first span when the playhead is past them all", () => {
|
|
34
|
+
expect(auditionStart(SPANS, 40)).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start playback for an audition, and put the playhead back on the way out.
|
|
3
|
+
*
|
|
4
|
+
* An audition writes the hovered preset to the running graph, which is silent
|
|
5
|
+
* while the transport is paused — so a paused author hovering a preset heard
|
|
6
|
+
* nothing at all and the affordance only worked mid-playback. Extracted from
|
|
7
|
+
* `useFxLevelling`, where the property panel's rack owned it privately, because
|
|
8
|
+
* the timeline's FX popover must render the preset shelf "exactly as FxSection
|
|
9
|
+
* renders it — same props" (runbook C1 §2) and was passing no transport at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useRef } from "react";
|
|
13
|
+
// The store's own module, not the `player` barrel: the barrel pulls the whole
|
|
14
|
+
// timeline in, and the timeline's FX button imports this hook — a cycle.
|
|
15
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
16
|
+
|
|
17
|
+
/** A clip the audition is meant to be heard through. */
|
|
18
|
+
export interface AuditionSpan {
|
|
19
|
+
start: number;
|
|
20
|
+
duration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where to start playing so the audition is actually audible, or null to stay.
|
|
25
|
+
*
|
|
26
|
+
* Playing "from the playhead" only works when the thing being auditioned is
|
|
27
|
+
* sounding there. A group whose members start at 0:02, hovered with the
|
|
28
|
+
* playhead at 0:00, plays the rest of the mix unchanged — the transport runs,
|
|
29
|
+
* the chain is in the graph, and the author hears nothing of the preset. So:
|
|
30
|
+
* inside a span, stay; otherwise jump to the next one, wrapping to the first
|
|
31
|
+
* when the playhead is past them all.
|
|
32
|
+
*
|
|
33
|
+
* Shared rather than the popover's own, which is where it went wrong the first
|
|
34
|
+
* time: the property panel's rack has the identical hole, and giving the two
|
|
35
|
+
* surfaces different audition behaviour is exactly what runbook C1 §2 forbids
|
|
36
|
+
* when it says the shelf renders "exactly as FxSection renders it".
|
|
37
|
+
*/
|
|
38
|
+
export function auditionStart(
|
|
39
|
+
spans: readonly AuditionSpan[] | undefined,
|
|
40
|
+
at: number,
|
|
41
|
+
): number | null {
|
|
42
|
+
if (!spans || spans.length === 0) return null;
|
|
43
|
+
if (spans.some((span) => at >= span.start && at < span.start + span.duration)) return null;
|
|
44
|
+
const starts = spans.map((span) => span.start).sort((a, b) => a - b);
|
|
45
|
+
return starts.find((start) => start > at) ?? starts[0] ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useAuditionTransport(): (on: boolean, spans?: readonly AuditionSpan[]) => void {
|
|
49
|
+
/**
|
|
50
|
+
* Where the playhead was when an audition started the transport, so leaving
|
|
51
|
+
* can put it back. Null means this audition did not start playback — the
|
|
52
|
+
* transport was already running and must be left alone.
|
|
53
|
+
*/
|
|
54
|
+
const auditionReturn = useRef<number | null>(null);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Already playing, this does nothing in either direction. The author started
|
|
58
|
+
* that, and stopping their transport because they passed over a preset would
|
|
59
|
+
* be the UI taking a decision that was not offered to it.
|
|
60
|
+
*/
|
|
61
|
+
return (on: boolean, spans?: readonly AuditionSpan[]): void => {
|
|
62
|
+
const store = usePlayerStore.getState();
|
|
63
|
+
if (on) {
|
|
64
|
+
if (store.isPlaying || auditionReturn.current !== null) return;
|
|
65
|
+
// Recorded BEFORE the seek, so leaving returns the author to where they
|
|
66
|
+
// actually were rather than to the clip this jumped to.
|
|
67
|
+
auditionReturn.current = store.currentTime;
|
|
68
|
+
const from = auditionStart(spans, store.currentTime);
|
|
69
|
+
if (from !== null) store.requestSeek(from);
|
|
70
|
+
store.requestPlayback(true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const returnTo = auditionReturn.current;
|
|
74
|
+
if (returnTo === null) return;
|
|
75
|
+
auditionReturn.current = null;
|
|
76
|
+
store.requestPlayback(false, returnTo);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -52,6 +52,17 @@ export function useFxAudition(
|
|
|
52
52
|
[chain, onChainPreview, onAuditionTransport],
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The chain as the DOCUMENT has it, ignoring whatever is being auditioned.
|
|
57
|
+
*
|
|
58
|
+
* An audition writes through the preview channel, and the `chain` prop is
|
|
59
|
+
* read back from that same live attribute — so mid-hover it is the hovered
|
|
60
|
+
* preset, not the stored chain. Applying on top of it stacked the auditioned
|
|
61
|
+
* preset into the saved chain: hover a reverb, click a different preset, and
|
|
62
|
+
* both were persisted, which is heard as the effect running twice.
|
|
63
|
+
*/
|
|
64
|
+
const storedChain = useCallback(() => auditionBase.current ?? chain, [chain]);
|
|
65
|
+
|
|
55
66
|
/**
|
|
56
67
|
* Drop whatever is being auditioned WITHOUT reverting the preview, for a
|
|
57
68
|
* caller that is about to mutate the real chain anyway — reverting first
|
|
@@ -91,5 +102,5 @@ export function useFxAudition(
|
|
|
91
102
|
[],
|
|
92
103
|
);
|
|
93
104
|
|
|
94
|
-
return { audition, clearAudition };
|
|
105
|
+
return { audition, clearAudition, storedChain };
|
|
95
106
|
}
|
|
Binary file
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { carveBedRoles, carverAgainst, collectCarveCandidates } from "./useFxCarveGrouping";
|
|
4
|
+
|
|
5
|
+
function previewDoc(html: string): Document {
|
|
6
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
7
|
+
doc.body.innerHTML = html;
|
|
8
|
+
return doc;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What the panel would offer as sources for `bedId`, the way useFxCarve asks. */
|
|
12
|
+
function candidatesFor(doc: Document, bedId: string) {
|
|
13
|
+
const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
|
|
14
|
+
(a) => a.id !== bedId,
|
|
15
|
+
);
|
|
16
|
+
return collectCarveCandidates(doc, others, () => true, bedId).map((c) => c.id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const GROUPED_VOICES = `
|
|
20
|
+
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
|
21
|
+
<audio id="vo-1" data-audio-group="voiceover"></audio>
|
|
22
|
+
<audio id="vo-2" data-audio-group="voiceover"></audio>
|
|
23
|
+
<audio id="music-bed"></audio>
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
describe("collectCarveCandidates", () => {
|
|
27
|
+
// The observed bug: selecting vo-2 offered "Voiceover (2)" — the group vo-2 is
|
|
28
|
+
// itself a member of. The caller filters out the bed element, but vo-1 survives
|
|
29
|
+
// that filter and rolls up into exactly that group. Being the only candidate, it
|
|
30
|
+
// was then applied without the author asking: a member ducking the bus it feeds.
|
|
31
|
+
it("never offers a member the group it belongs to", () => {
|
|
32
|
+
expect(candidatesFor(previewDoc(GROUPED_VOICES), "vo-2")).toEqual(["music-bed"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// And the mirror: a group bed's own id matches no <audio> id, so nothing
|
|
36
|
+
// excluded it. Its members rolled up and handed the group back to itself.
|
|
37
|
+
it("never offers a group itself", () => {
|
|
38
|
+
const doc = previewDoc(GROUPED_VOICES);
|
|
39
|
+
expect(candidatesFor(doc, "voiceover")).toEqual(["music-bed"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("still offers a group the bed has nothing to do with", () => {
|
|
43
|
+
const doc = previewDoc(`
|
|
44
|
+
${GROUPED_VOICES}
|
|
45
|
+
<hf-audio-group id="sfx" data-label="SFX"></hf-audio-group>
|
|
46
|
+
<audio id="sfx-click" data-audio-group="sfx"></audio>
|
|
47
|
+
`);
|
|
48
|
+
expect(candidatesFor(doc, "music-bed")).toEqual(["voiceover", "sfx"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves an ungrouped bed's candidates alone", () => {
|
|
52
|
+
const doc = previewDoc(`<audio id="music-bed"></audio><audio id="vo-1"></audio>`);
|
|
53
|
+
expect(candidatesFor(doc, "music-bed")).toEqual(["vo-1"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("carverAgainst", () => {
|
|
58
|
+
// The far-end guard has to see through a GROUP source. A plural carve names a
|
|
59
|
+
// group — that is what the lint rule pushes authors toward — so matching raw
|
|
60
|
+
// ids never found the member, the carve module was offered on a voice already
|
|
61
|
+
// being ducked against, and switching it on wrote a reciprocal carve.
|
|
62
|
+
it("finds the bed carving a voice through its group", () => {
|
|
63
|
+
const doc = previewDoc(`
|
|
64
|
+
${GROUPED_VOICES}
|
|
65
|
+
<audio id="bed" data-fx-carve='{"enabled":true,"sources":["voiceover"],"strength":0.3}'></audio>
|
|
66
|
+
`);
|
|
67
|
+
expect(carverAgainst(doc, "vo-1")).toBe("bed");
|
|
68
|
+
expect(carverAgainst(doc, "vo-2")).toBe("bed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("still finds a carve that names the clip directly", () => {
|
|
72
|
+
const doc = previewDoc(`
|
|
73
|
+
${GROUPED_VOICES}
|
|
74
|
+
<audio id="bed" data-fx-carve='{"enabled":true,"sources":["vo-1"],"strength":0.3}'></audio>
|
|
75
|
+
`);
|
|
76
|
+
expect(carverAgainst(doc, "vo-1")).toBe("bed");
|
|
77
|
+
expect(carverAgainst(doc, "vo-2")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is null for a track nobody carves against", () => {
|
|
81
|
+
const doc = previewDoc(GROUPED_VOICES);
|
|
82
|
+
expect(carverAgainst(doc, "vo-1")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("carveBedRoles", () => {
|
|
87
|
+
const roles = (html: string, id: string) => {
|
|
88
|
+
const doc = previewDoc(html);
|
|
89
|
+
return carveBedRoles(id, doc.getElementById(id));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The observed bug: the bus labelled "Music bed" classified as music, so it
|
|
93
|
+
// auto-carved against the same voiceover its own member clip had already
|
|
94
|
+
// carved against — the bed ran through both chains. A bus is never a bed, and
|
|
95
|
+
// `autoBed` matters as much as `couldBeBed`: that is the half that wrote one
|
|
96
|
+
// without being asked.
|
|
97
|
+
it("never makes a bus a bed, however it is labelled", () => {
|
|
98
|
+
expect(
|
|
99
|
+
roles(`<hf-audio-group id="music" data-label="Music bed"></hf-audio-group>`, "music"),
|
|
100
|
+
).toEqual({ couldBeBed: false, autoBed: false });
|
|
101
|
+
expect(
|
|
102
|
+
roles(`<hf-audio-group id="sfx" data-label="Sound FX"></hf-audio-group>`, "sfx"),
|
|
103
|
+
).toEqual({ couldBeBed: false, autoBed: false });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still reads a clip's label, id and src", () => {
|
|
107
|
+
expect(roles(`<audio id="a1" data-label="Music bed"></audio>`, "a1")).toEqual({
|
|
108
|
+
couldBeBed: true,
|
|
109
|
+
autoBed: true,
|
|
110
|
+
});
|
|
111
|
+
// A name that says nothing may be offered the control but never carves itself.
|
|
112
|
+
expect(roles(`<audio id="a1"></audio>`, "a1")).toEqual({ couldBeBed: true, autoBed: false });
|
|
113
|
+
expect(roles(`<audio id="vo-2"></audio>`, "vo-2")).toEqual({
|
|
114
|
+
couldBeBed: false,
|
|
115
|
+
autoBed: false,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -7,8 +7,19 @@
|
|
|
7
7
|
* `propertyPanelAudioFxGroup.tsx`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
classifyAudioName,
|
|
12
|
+
HF_AUDIO_CARVE_ATTR,
|
|
13
|
+
normalizeCarveSettings,
|
|
14
|
+
couldBeCarveBed,
|
|
15
|
+
isNamedCarveBed,
|
|
16
|
+
type HfCarveSettings,
|
|
17
|
+
} from "@hyperframes/core/audio-carve";
|
|
18
|
+
import {
|
|
19
|
+
HF_AUDIO_GROUP_TAG,
|
|
20
|
+
resolveAudioGroups,
|
|
21
|
+
resolveCarveSourceIds,
|
|
22
|
+
} from "@hyperframes/core/audio-groups";
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
25
|
* An id for a new voiceover group, de-duped against every id already in the
|
|
@@ -30,6 +41,12 @@ export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
|
30
41
|
return typeof (value as { then?: unknown })?.then === "function";
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
/**
|
|
45
|
+
* "The auto-group failed, do not persist this carve" — distinct from a
|
|
46
|
+
* legitimate `null`, which means the carve was deliberately cleared.
|
|
47
|
+
*/
|
|
48
|
+
export const CARVE_ABORTED = Symbol("carve-aborted");
|
|
49
|
+
|
|
33
50
|
/**
|
|
34
51
|
* Plural voiceover carve, always against a group — normative, not a
|
|
35
52
|
* suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
|
|
@@ -71,8 +88,16 @@ export function resolveNextCarveSettings(
|
|
|
71
88
|
nextRaw: HfCarveSettings | null,
|
|
72
89
|
doc: Document | undefined,
|
|
73
90
|
assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
|
|
74
|
-
): HfCarveSettings | Promise<HfCarveSettings> | null {
|
|
75
|
-
|
|
91
|
+
): HfCarveSettings | Promise<HfCarveSettings | typeof CARVE_ABORTED> | null {
|
|
92
|
+
const resolved = nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
|
|
93
|
+
// A failed auto-group resolves to the sentinel rather than rejecting: the
|
|
94
|
+
// write has already toasted, and the caller's job is simply not to persist a
|
|
95
|
+
// carve whose `sources` name a group that was never written — which reads, at
|
|
96
|
+
// playback, as a carve that silently stops ducking. Caught here rather than
|
|
97
|
+
// in the caller so the synchronous branch above stays synchronous.
|
|
98
|
+
return isPromiseLike(resolved)
|
|
99
|
+
? resolved.catch((): typeof CARVE_ABORTED => CARVE_ABORTED)
|
|
100
|
+
: resolved;
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
export interface CarveCandidate {
|
|
@@ -87,18 +112,38 @@ export interface CarveCandidate {
|
|
|
87
112
|
* the whole timeline is relevant even if each of its segments only overlaps
|
|
88
113
|
* part of the bed). Grouped members never appear individually.
|
|
89
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* The ids a carve may never name, for a bed of this id.
|
|
117
|
+
*
|
|
118
|
+
* A carve names what the bed ducks UNDER, so the bed can never be on that
|
|
119
|
+
* list — directly or through the bus it plays into. The caller filters the bed
|
|
120
|
+
* element out of `others`, but that only removes the bed itself: its siblings
|
|
121
|
+
* survive and roll up into the very group the bed is a member of, which came
|
|
122
|
+
* back as a candidate and, being the only one, was applied unprompted. And a
|
|
123
|
+
* group bed's own id never matches any <audio> id at all, so nothing stopped a
|
|
124
|
+
* group carving against itself.
|
|
125
|
+
*/
|
|
126
|
+
function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string): Set<string> {
|
|
127
|
+
const selfGroupId = bedId ? groupByMember.get(bedId)?.id : undefined;
|
|
128
|
+
return new Set([bedId, selfGroupId].filter((id): id is string => !!id));
|
|
129
|
+
}
|
|
130
|
+
|
|
90
131
|
export function collectCarveCandidates(
|
|
91
132
|
doc: Document,
|
|
92
133
|
others: readonly HTMLAudioElement[],
|
|
93
134
|
overlapsBed: (a: Element) => boolean,
|
|
135
|
+
/** The bed's own id, so neither it nor the group it belongs to is offered. */
|
|
136
|
+
bedId?: string,
|
|
94
137
|
): CarveCandidate[] {
|
|
95
138
|
const groupByMember = new Map(
|
|
96
139
|
resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
|
|
97
140
|
);
|
|
141
|
+
const excluded = excludedFor(groupByMember, bedId);
|
|
98
142
|
const offeredGroupIds = new Set<string>();
|
|
99
143
|
const described: CarveCandidate[] = [];
|
|
100
144
|
for (const a of others) {
|
|
101
145
|
const group = groupByMember.get(a.id);
|
|
146
|
+
if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
|
|
102
147
|
if (!group) {
|
|
103
148
|
if (overlapsBed(a)) {
|
|
104
149
|
described.push({
|
|
@@ -130,3 +175,93 @@ export function collectCarveCandidates(
|
|
|
130
175
|
}
|
|
131
176
|
return described;
|
|
132
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The two near-end questions about a track, asked of its name.
|
|
181
|
+
*
|
|
182
|
+
* `couldBeBed` — may a carve be written onto this at all? A carve makes room in
|
|
183
|
+
* a bed for a voice, so a voice track is the one thing that can never be the
|
|
184
|
+
* bed. The far-end rule (`couldBeCarveSource` — music and sfx are out) has
|
|
185
|
+
* existed since it was written and had no caller; this is the half nothing
|
|
186
|
+
* asked, and without it a narration clip was offered the control and, finding
|
|
187
|
+
* exactly one candidate, had a carve applied against the group it belonged to.
|
|
188
|
+
*
|
|
189
|
+
* `autoBed` — may one be applied WITHOUT the author asking? Stricter. Showing
|
|
190
|
+
* the module on a track named `a1` is a suggestion; writing `data-fx-carve`
|
|
191
|
+
* onto it is a decision, and a decision taken off a name that said nothing is
|
|
192
|
+
* how a carve turns up that nobody remembers configuring. The same split the
|
|
193
|
+
* source side already makes between what the picker may show (`sourceOptions`)
|
|
194
|
+
* and what it may choose unprompted (`autoSourceIds`).
|
|
195
|
+
*
|
|
196
|
+
* Reads the element's `data-label` as well as its id and `src`, because a clip's
|
|
197
|
+
* display name is a hint its filename may not carry. A BUS is excluded outright
|
|
198
|
+
* — see the first branch.
|
|
199
|
+
*/
|
|
200
|
+
export function carveBedRoles(
|
|
201
|
+
id: string | null | undefined,
|
|
202
|
+
node: Element | null | undefined,
|
|
203
|
+
): { couldBeBed: boolean; autoBed: boolean } {
|
|
204
|
+
// A BUS is never a bed, whatever it is called. Its rack reaches `useFxCarve`
|
|
205
|
+
// too, and reading `data-label` — which is what lets a group be classified at
|
|
206
|
+
// all — made a bus labelled "Music bed" read as music: it then auto-carved
|
|
207
|
+
// against the same voice its own member clip had already auto-carved against,
|
|
208
|
+
// and the bed ran through both sets of filters. Nothing caught that, because
|
|
209
|
+
// the only guard (`carverAgainst`) asks "is somebody naming ME as a source",
|
|
210
|
+
// never "is my own bus, or my own member, already carved".
|
|
211
|
+
//
|
|
212
|
+
// A bus could not do the whole job anyway: the level half of the carve reads
|
|
213
|
+
// the bed's own `src` to measure how far over the voice it sits, and a bus has
|
|
214
|
+
// no `src` — so a bus carve was always the spectral half alone, filters with
|
|
215
|
+
// no level match. `data-fx-carve` is a clip attribute; the skill has said so
|
|
216
|
+
// ("A carve stays on the clip") since the bus was documented.
|
|
217
|
+
//
|
|
218
|
+
// Not `couldBeBed: false` alone: that leaves `autoBed` free to fire from a
|
|
219
|
+
// name, which is the half that wrote these unasked.
|
|
220
|
+
if (node?.tagName?.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
|
221
|
+
return { couldBeBed: false, autoBed: false };
|
|
222
|
+
}
|
|
223
|
+
const parts = [id, node?.getAttribute("src"), node?.getAttribute("data-label")];
|
|
224
|
+
return { couldBeBed: couldBeCarveBed(...parts), autoBed: isNamedCarveBed(...parts) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether some element's own carve attribute names `targetId` as a source.
|
|
229
|
+
*
|
|
230
|
+
* Sources are EXPANDED first. A plural carve now names a group rather than a
|
|
231
|
+
* clip list — that is what `audio_carve_ungrouped_sources` exists to push
|
|
232
|
+
* authors toward — so a raw `.includes(targetId)` never matched a member again,
|
|
233
|
+
* and the far-end guard this feeds was silently defeated by the very shape the
|
|
234
|
+
* lint rule asks for. The result: the carve module was offered on a voice clip
|
|
235
|
+
* a bed is already ducking against, and switching it on wrote a reciprocal
|
|
236
|
+
* carve — each side measuring audio the other is already attenuating.
|
|
237
|
+
*/
|
|
238
|
+
function carvesAgainst(doc: Document, other: HTMLElement, targetId: string): boolean {
|
|
239
|
+
try {
|
|
240
|
+
const raw = other.getAttribute(HF_AUDIO_CARVE_ATTR);
|
|
241
|
+
if (!raw) return false;
|
|
242
|
+
const sources = normalizeCarveSettings(JSON.parse(raw)).sources;
|
|
243
|
+
return resolveCarveSourceIds(doc, sources).includes(targetId);
|
|
244
|
+
} catch {
|
|
245
|
+
// An unreadable carve on some other element says nothing about this one.
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Is some other track carving against this one, and which?
|
|
252
|
+
*
|
|
253
|
+
* A carve is a relationship — a bed is carved against a voice — and the voice is
|
|
254
|
+
* the far end of it. Offering the same control there offers to carve a track
|
|
255
|
+
* against itself by proxy, and switching it on left a setting with no source it
|
|
256
|
+
* could legally name. Read off the other elements' own carve attributes, because
|
|
257
|
+
* that is where the relationship is recorded.
|
|
258
|
+
*/
|
|
259
|
+
export function carverAgainst(
|
|
260
|
+
doc: Document | undefined,
|
|
261
|
+
id: string | null | undefined,
|
|
262
|
+
): string | null {
|
|
263
|
+
if (!doc || !id) return null;
|
|
264
|
+
const others = Array.from(doc.querySelectorAll<HTMLElement>(`[${HF_AUDIO_CARVE_ATTR}]`));
|
|
265
|
+
const carver = others.find((other) => other.id !== id && carvesAgainst(doc, other, id));
|
|
266
|
+
return carver ? carver.id || "another track" : null;
|
|
267
|
+
}
|