@hyperframes/studio 0.8.10 → 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.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6440 -5514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /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(chain, onChainPreview, onAuditionTransport);
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
- const next = applyPresetToChain(chain, id, trackKind);
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
- [chain, mutate, clearAudition, trackKind],
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 { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
11
- import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
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
- return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
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
+ }