@hyperframes/studio 0.8.11 → 0.8.13

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
  2. package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
  3. package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
  4. package/dist/assets/index-CQMHN4nk.js +431 -0
  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 +7646 -6514
  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/domSelectionTimelineMirror.ts +12 -3
  64. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  65. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  66. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  67. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  68. package/src/hooks/timelineEditingHelpers.ts +19 -7
  69. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  70. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  71. package/src/hooks/timelineTrackVisibility.ts +6 -182
  72. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  73. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  74. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  75. package/src/hooks/useDomEditSession.test.tsx +57 -2
  76. package/src/hooks/useDomEditSession.ts +23 -24
  77. package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
  78. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  79. package/src/hooks/useLivePlayheadTime.ts +3 -1
  80. package/src/hooks/useRemoveBackground.ts +70 -0
  81. package/src/hooks/useTimelineEditing.ts +5 -19
  82. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  83. package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
  84. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  85. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  86. package/src/player/components/PlayerControls.tsx +0 -38
  87. package/src/player/components/Timeline.test.ts +40 -1
  88. package/src/player/components/Timeline.tsx +5 -2
  89. package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
  90. package/src/player/components/TimelineAutomationLane.tsx +129 -36
  91. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  92. package/src/player/components/TimelineFxButton.test.tsx +167 -21
  93. package/src/player/components/TimelineFxButton.tsx +156 -31
  94. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  95. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  96. package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
  97. package/src/player/components/TimelineGroupRow.test.tsx +222 -0
  98. package/src/player/components/TimelineGroupRow.tsx +140 -71
  99. package/src/player/components/TimelineLanes.test.tsx +3 -3
  100. package/src/player/components/TimelineLanes.tsx +25 -23
  101. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  102. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  103. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  104. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  105. package/src/player/components/automationLaneData.test.ts +94 -0
  106. package/src/player/components/automationLaneData.ts +23 -0
  107. package/src/player/components/automationLaneDragMath.ts +28 -0
  108. package/src/player/components/automationLaneGeometry.ts +52 -13
  109. package/src/player/components/groupAutomationElement.test.ts +51 -0
  110. package/src/player/components/groupAutomationElement.ts +37 -0
  111. package/src/player/components/timelineCallbacks.ts +6 -2
  112. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  113. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  114. package/src/player/components/timelineLayout.ts +0 -1
  115. package/src/player/components/timelineViewModel.ts +30 -1
  116. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  117. package/src/player/components/useAutomationLaneGestures.ts +66 -18
  118. package/src/player/components/useAutomationSegmentDrag.ts +110 -0
  119. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  120. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  121. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  122. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  123. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  124. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  125. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  126. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  127. package/src/player/hooks/previewMessageRouter.ts +4 -11
  128. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  129. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  130. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  131. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  132. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  133. package/src/player/lib/automationStoreSync.test.ts +26 -0
  134. package/src/player/lib/automationStoreSync.ts +28 -4
  135. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  136. package/src/player/lib/timelineDOM.test.ts +115 -0
  137. package/src/player/lib/timelineDOM.ts +3 -32
  138. package/src/player/lib/timelineElementHelpers.ts +27 -0
  139. package/src/player/lib/timelineGroupInfo.ts +134 -0
  140. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  141. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  142. package/src/player/store/keyframeSlice.ts +79 -5
  143. package/src/player/store/playerStore.ts +37 -47
  144. package/src/player/store/playerStoreDevHandle.ts +22 -0
  145. package/src/player/store/playerStoreSelection.ts +41 -0
  146. package/src/player/store/timelineElement.ts +32 -0
  147. package/src/utils/hmrStableContext.ts +64 -0
  148. package/src/utils/timelineInspector.test.ts +35 -1
  149. package/src/utils/timelineInspector.ts +38 -0
  150. package/dist/assets/index-Ba9zbpT9.css +0 -1
  151. package/dist/assets/index-DoW5v5eS.js +0 -428
  152. package/src/hooks/useAudioSoloBridge.ts +0 -61
  153. package/src/hooks/useGroupLevel.ts +0 -36
  154. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  155. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  156. package/src/player/components/TimelineSoloButton.tsx +0 -32
  157. package/src/player/store/audioSoloSlice.test.ts +0 -113
  158. package/src/player/store/audioSoloSlice.ts +0 -43
  159. package/src/player/store/groupLevels.ts +0 -33
  160. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  161. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -12,7 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
- import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
15
+ import { groupInfoFor } from "./timelineGroupInfo";
16
16
  import {
17
17
  resolveMediaElement,
18
18
  applyMediaMetadataFromElement,
@@ -68,37 +68,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
68
68
  return clip.tagName || clip.kind || "div";
69
69
  }
70
70
 
71
- // One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
72
- // walks the whole tree, and a parse touches every clip in it.
73
- interface GroupInfo {
74
- label: string;
75
- volume: number;
76
- hidden: boolean;
77
- fxChain?: string;
78
- }
79
-
80
- const groupInfoCache = new WeakMap<Document, Map<string, GroupInfo>>();
81
-
82
- function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
83
- if (!doc) return { label: groupId, volume: 1, hidden: false };
84
- let info = groupInfoCache.get(doc);
85
- if (!info) {
86
- info = new Map(
87
- resolveAudioGroups(doc).map((group) => [
88
- group.id,
89
- {
90
- label: group.label,
91
- volume: group.volume,
92
- hidden: group.hidden,
93
- ...(group.fxChain ? { fxChain: group.fxChain } : {}),
94
- },
95
- ]),
96
- );
97
- groupInfoCache.set(doc, info);
98
- }
99
- return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
100
- }
101
-
102
71
  // fallow-ignore-next-line complexity
103
72
  export function createTimelineElementFromManifestClip(params: {
104
73
  clip: ClipManifestClip;
@@ -178,6 +147,7 @@ export function createTimelineElementFromManifestClip(params: {
178
147
  entry.audioGroupVolume = info.volume;
179
148
  entry.audioGroupHidden = info.hidden;
180
149
  if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
150
+ if (info.automation) entry.audioGroupAutomation = info.automation;
181
151
  }
182
152
  const fxChain = hostEl.getAttribute("data-fx-chain");
183
153
  if (fxChain) entry.fxChain = fxChain;
@@ -405,6 +375,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
405
375
  entry.audioGroupVolume = domGroupInfo.volume;
406
376
  entry.audioGroupHidden = domGroupInfo.hidden;
407
377
  if (domGroupInfo.fxChain) entry.audioGroupFxChain = domGroupInfo.fxChain;
378
+ if (domGroupInfo.automation) entry.audioGroupAutomation = domGroupInfo.automation;
408
379
  }
409
380
 
410
381
  // Sub-compositions
@@ -11,6 +11,7 @@ import type { TimelineElement } from "../store/playerStore";
11
11
  import type { ClipManifestClip } from "./playbackTypes";
12
12
  import { isFinitePositive } from "./playbackAdapter";
13
13
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
+ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Layer-reveal lift transparency
@@ -81,6 +82,14 @@ function normalizePlaybackRate(raw: number): number {
81
82
  }
82
83
 
83
84
  export function isTimelineIgnoredElement(el: Element): boolean {
85
+ // An `<hf-audio-group>` is a mixer bus, not a clip: it carries the group's
86
+ // label, fader, mute and FX chain, has no timing of its own, and is drawn as
87
+ // a GROUP ROW by the group derivation. Left in, the implicit-layer fallback
88
+ // also gave it an ordinary full-duration track — so a grouped composition
89
+ // showed "Voiceover • 0.0s – 12.0s" as a phantom clip directly above the real
90
+ // group header. Harmless-looking, but that row is draggable and trimmable,
91
+ // and writing timing onto the bus is meaningless.
92
+ if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
84
93
  return Boolean(
85
94
  el.closest(
86
95
  [
@@ -346,6 +355,24 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
346
355
  return element.key ?? element.id;
347
356
  }
348
357
 
358
+ /**
359
+ * The id space the RUNTIME matches on — a bare DOM id, never a store key.
360
+ *
361
+ * Studio addresses rows by `buildTimelineElementKey`'s composite
362
+ * `<sourceFile>#<domId>`, but everything audio in `@hyperframes/core` keys off
363
+ * the live document: `resolveAudioGroups` collects `member.id`,
364
+ * `resolveCarveSourceIds` goes through `getElementById`. Anything crossing into
365
+ * that space — a group membership list, a carve source — has to be
366
+ * converted here first; a composite key silently matches nothing.
367
+ *
368
+ * `null` for a row with no DOM id at all (selector-addressed elements): such an
369
+ * element cannot be grouped, because `resolveAudioGroups` skips
370
+ * members without an `id` and would build a group that is half there.
371
+ */
372
+ export function runtimeAudioId(element: { domId?: string | null }): string | null {
373
+ return element.domId || null;
374
+ }
375
+
349
376
  /**
350
377
  * Timeline store key for a z-reorder entry built OUTSIDE the timeline
351
378
  * expansion (canvas context menu / LayersPanel), so the reorder commit can
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The audio-group scan cached per preview document, and the mechanism that
3
+ * keeps it honest.
4
+ *
5
+ * Split out of `timelineDOM.ts` (600-line studio ceiling). Self-contained: the
6
+ * cache, its revision counter, the observer that bumps it, and the one reader.
7
+ */
8
+
9
+ import {
10
+ HF_AUDIO_GROUP_ATTR,
11
+ HF_AUDIO_GROUP_TAG,
12
+ resolveAudioGroups,
13
+ } from "@hyperframes/core/audio-groups";
14
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
15
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
16
+
17
+ // One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
18
+ // walks the whole tree, and a parse touches every clip in it.
19
+ interface GroupInfo {
20
+ label: string;
21
+ volume: number;
22
+ hidden: boolean;
23
+ fxChain?: string;
24
+ automation?: string;
25
+ }
26
+
27
+ /**
28
+ * The cached scan, plus the DOM revision it was taken at.
29
+ *
30
+ * Keeping the revision beside the entry is what makes staleness *detectable*
31
+ * rather than a documented obligation. The cache is keyed on the document, and
32
+ * group edits are applied as live patches precisely so the iframe never
33
+ * reloads, so the key alone never changes: an explicit "remember to invalidate"
34
+ * contract silently rots the first time a new writer forgets — which is exactly
35
+ * what happened with the FX rack, whose group writes go through the DOM editor
36
+ * rather than the timeline's own writers.
37
+ */
38
+ const groupInfoCache = new WeakMap<
39
+ Document,
40
+ { revision: number; entries: Map<string, GroupInfo> }
41
+ >();
42
+
43
+ /** Bumped by every observed mutation to group state in a document. */
44
+ const groupRevisions = new WeakMap<Document, number>();
45
+ const groupObservers = new WeakSet<Document>();
46
+
47
+ /**
48
+ * Watch a document for any change to group state, so the cache expires itself.
49
+ *
50
+ * One observer per document, attached the first time a group is read from it.
51
+ * It watches the attributes a group's identity is made of, anywhere in the
52
+ * tree, plus added/removed nodes — which covers a group element appearing, a
53
+ * member joining or leaving, and any group attribute being edited, by any
54
+ * writer, without that writer having to know this cache exists.
55
+ */
56
+ function observeGroupState(doc: Document): void {
57
+ if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
58
+ groupObservers.add(doc);
59
+ const observer = new MutationObserver((records) => {
60
+ // `childList` fires for EVERY node added or removed anywhere in the live
61
+ // preview, which on a composition that churns nodes during playback
62
+ // (SplitText, a typewriter, anything runtime-inserted) would expire this
63
+ // cache permanently and put it back to one whole-tree scan per parse. Only
64
+ // a group ELEMENT appearing or leaving actually changes the answer, so
65
+ // childList records are filtered rather than trusted; attribute records
66
+ // always count, because the filter below already narrowed them.
67
+ const relevant = records.some(
68
+ (record) =>
69
+ record.type !== "childList" ||
70
+ [...record.addedNodes, ...record.removedNodes].some(
71
+ (node) =>
72
+ node instanceof Element &&
73
+ (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
74
+ node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
75
+ node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
76
+ ),
77
+ );
78
+ if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
79
+ });
80
+ observer.observe(doc.body, {
81
+ subtree: true,
82
+ childList: true,
83
+ attributes: true,
84
+ // Every attribute `resolveAudioGroups`/`buildGroup` reads, including
85
+ // `data-automation` — which `GroupInfo` does not cache TODAY, so omitting it
86
+ // was inert, but group automation lanes already exist and the first person
87
+ // to cache one would have got a silently never-firing observer. That is the
88
+ // precise rot this observer replaced.
89
+ attributeFilter: [
90
+ HF_AUDIO_GROUP_ATTR,
91
+ HF_AUDIO_FX_ATTR,
92
+ HF_AUDIO_AUTOMATION_ATTR,
93
+ "data-label",
94
+ "data-volume",
95
+ "data-hidden",
96
+ "id",
97
+ ],
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Drop the cached group scan for a document.
103
+ *
104
+ * Belt to the observer's braces: a caller that has just written and wants the
105
+ * very next read to be honest cannot wait for the observer's microtask. Callers
106
+ * that forget are no longer punished — the revision check catches them.
107
+ */
108
+ export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
109
+ if (doc) groupInfoCache.delete(doc);
110
+ }
111
+
112
+ export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
113
+ if (!doc) return { label: groupId, volume: 1, hidden: false };
114
+ observeGroupState(doc);
115
+ const revision = groupRevisions.get(doc) ?? 0;
116
+ const cached = groupInfoCache.get(doc);
117
+ let info = cached && cached.revision === revision ? cached.entries : undefined;
118
+ if (!info) {
119
+ info = new Map(
120
+ resolveAudioGroups(doc).map((group) => [
121
+ group.id,
122
+ {
123
+ label: group.label,
124
+ volume: group.volume,
125
+ hidden: group.hidden,
126
+ ...(group.fxChain ? { fxChain: group.fxChain } : {}),
127
+ ...(group.automation ? { automation: group.automation } : {}),
128
+ },
129
+ ]),
130
+ );
131
+ groupInfoCache.set(doc, { revision, entries: info });
132
+ }
133
+ return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
134
+ }
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
+ applyPreviewAudioFlags,
4
5
  buildMissingCompositionElements,
5
6
  scrubPreviewAudio,
6
7
  setPreviewMediaVolume,
@@ -87,3 +88,23 @@ describe("scrubPreviewAudio", () => {
87
88
  stopScrubPreviewAudio();
88
89
  });
89
90
  });
91
+
92
+ describe("applyPreviewAudioFlags", () => {
93
+ // Everything pushed here is state the runtime loses on reload and nothing else
94
+ // re-sends, so the push has to carry all of it every time. Volume in
95
+ // particular: the transport comes back at unity, so a preview the author had
96
+ // turned down came back loud.
97
+ it("re-pushes mute and volume together", () => {
98
+ const iframe = document.createElement("iframe");
99
+ document.body.append(iframe);
100
+ const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
101
+
102
+ applyPreviewAudioFlags(iframe, true, 0.4);
103
+
104
+ const actions = postMessage.mock.calls.map(
105
+ (call) => (call[0] as { action?: string }).action ?? "",
106
+ );
107
+ expect(actions).toContain("set-muted");
108
+ expect(actions).toContain("set-volume");
109
+ });
110
+ });
@@ -142,6 +142,23 @@ export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume:
142
142
  } catch {}
143
143
  }
144
144
 
145
+ /**
146
+ * Everything the preview runtime has to be told about audio after it loads.
147
+ * Called from `applyPreviewAudioState`, which is the path that re-runs after a
148
+ * preview reload — the runtime comes back with the transport at its defaults
149
+ * and nothing else pushes them again.
150
+ */
151
+ export function applyPreviewAudioFlags(
152
+ iframe: HTMLIFrameElement | null,
153
+ muted: boolean,
154
+ volume: number,
155
+ ): void {
156
+ setPreviewMediaMuted(iframe, muted);
157
+ // Volume too: the transport comes back at unity after a reload, so a preview
158
+ // the author had turned down came back loud.
159
+ setPreviewMediaVolume(iframe, volume);
160
+ }
161
+
145
162
  export function setPreviewPlaybackRate(
146
163
  iframe: HTMLIFrameElement | null,
147
164
  playbackRate: number,
@@ -34,6 +34,38 @@ export interface FocusedEaseSegment {
34
34
 
35
35
  type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
36
36
 
37
+ /**
38
+ * A request to reveal one automated parameter in the audio FX rack.
39
+ *
40
+ * `elementKey` is the timeline element whose rack it belongs to — a group's own
41
+ * id for a group lane, the clip's key otherwise — so the panel can refuse a
42
+ * request aimed at something it is not showing.
43
+ */
44
+ export interface RevealedAudioFxTarget {
45
+ elementKey: string;
46
+ /** The lane's own `fx.<node>.<param>` / `volume` target. */
47
+ automationTarget: string;
48
+ projectId: string | null;
49
+ sessionEpoch: number;
50
+ nonce: number;
51
+ }
52
+
53
+ export type RevealedAudioFxTargetRequest = Omit<
54
+ RevealedAudioFxTarget,
55
+ "projectId" | "sessionEpoch" | "nonce"
56
+ >;
57
+
58
+ /** Whether a reveal request still belongs to what is on screen. */
59
+ export function isRevealedAudioFxRequestCurrent(
60
+ request: RevealedAudioFxTarget,
61
+ state: TimelineSessionIdentity,
62
+ ): boolean {
63
+ return (
64
+ request.projectId === state.timelineProjectId &&
65
+ request.sessionEpoch === state.timelineSessionEpoch
66
+ );
67
+ }
68
+
37
69
  interface TimelineSessionIdentity {
38
70
  timelineProjectId: string | null;
39
71
  timelineSessionEpoch: number;
@@ -63,8 +95,16 @@ export interface KeyframeSlice {
63
95
  /** Union-expand clips (keyframed clips are expanded by default on load). */
64
96
  expandClips: (ids: readonly string[]) => void;
65
97
 
66
- /** Groups whose member rows the caret has shown (structural, not lanes). */
67
- expandedGroupIds: Set<string>;
98
+ /**
99
+ * Groups whose member rows the caret has HIDDEN (structural, not lanes).
100
+ *
101
+ * Inverted deliberately. As an expanded-set, "not in the set" could not tell
102
+ * never-touched from deliberately-collapsed, so every group defaulted to
103
+ * collapsed — and since nothing seeds the set on create, grouping three
104
+ * tracks made all three vanish behind a header the user had not yet learned
105
+ * to open. Groups are expanded until someone closes one.
106
+ */
107
+ collapsedGroupIds: Set<string>;
68
108
  toggleGroupExpanded: (id: string) => void;
69
109
 
70
110
  /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
@@ -80,6 +120,19 @@ export interface KeyframeSlice {
80
120
  setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
81
121
  clearFocusedEaseSegment: (nonce: number) => void;
82
122
 
123
+ /**
124
+ * "Show me this automated parameter in the rack" — raised by clicking an
125
+ * automation lane's label in the timeline, consumed by the property panel.
126
+ *
127
+ * Session-stamped and nonce-guarded exactly like `focusedEaseSegment`: a
128
+ * request outlives the click, so one made against a different project or
129
+ * before a reload must not reopen a rack on whatever is mounted later.
130
+ */
131
+ revealedAudioFxTarget: RevealedAudioFxTarget | null;
132
+ revealedAudioFxNonce: number;
133
+ setRevealedAudioFxTarget: (target: RevealedAudioFxTargetRequest) => void;
134
+ clearRevealedAudioFxTarget: (nonce: number) => void;
135
+
83
136
  /** Keyframe data per element id, populated from parsed GSAP animations. */
84
137
  keyframeCache: Map<string, KeyframeCacheEntry>;
85
138
  /** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
@@ -127,13 +180,13 @@ export function createKeyframeSlice(
127
180
  return { expandedClipIds: next };
128
181
  }),
129
182
 
130
- expandedGroupIds: new Set(),
183
+ collapsedGroupIds: new Set(),
131
184
  toggleGroupExpanded: (id) =>
132
185
  set((state) => {
133
- const next = new Set(state.expandedGroupIds);
186
+ const next = new Set(state.collapsedGroupIds);
134
187
  if (next.has(id)) next.delete(id);
135
188
  else next.add(id);
136
- return { expandedGroupIds: next };
189
+ return { collapsedGroupIds: next };
137
190
  }),
138
191
 
139
192
  expandedLaneOwnerIds: new Set(),
@@ -166,6 +219,27 @@ export function createKeyframeSlice(
166
219
  state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
167
220
  ),
168
221
 
222
+ revealedAudioFxTarget: null,
223
+ revealedAudioFxNonce: 0,
224
+ setRevealedAudioFxTarget: (target) =>
225
+ set((state) => {
226
+ const nonce = state.revealedAudioFxNonce + 1;
227
+ const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
228
+ return {
229
+ revealedAudioFxNonce: nonce,
230
+ revealedAudioFxTarget: {
231
+ ...target,
232
+ projectId: timelineProjectId,
233
+ sessionEpoch: timelineSessionEpoch,
234
+ nonce,
235
+ },
236
+ };
237
+ }),
238
+ clearRevealedAudioFxTarget: (nonce) =>
239
+ set((state) =>
240
+ state.revealedAudioFxTarget?.nonce === nonce ? { revealedAudioFxTarget: null } : state,
241
+ ),
242
+
169
243
  keyframeCache: new Map(),
170
244
  setKeyframeCache: (elementId, data) =>
171
245
  set((state) => {
@@ -1,4 +1,6 @@
1
1
  import { create } from "zustand";
2
+ import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle";
3
+ import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection";
2
4
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
3
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
6
  import type { BeatEditState } from "../../utils/beatEditing";
@@ -14,15 +16,14 @@ import {
14
16
  createAutomationSelectionSlice,
15
17
  type AutomationSelectionSlice,
16
18
  } from "./automationSelectionSlice";
19
+ import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
17
20
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
18
21
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
19
- import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
20
- import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
21
22
 
22
23
  export type { KeyframeCacheEntry } from "./keyframeSlice";
23
24
  export { liveTime } from "./liveTime";
24
25
 
25
- import type { TimelineElement } from "./timelineElement";
26
+ import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
26
27
 
27
28
  export type { TimelineElement };
28
29
  export type ZoomMode = "fit" | "manual";
@@ -50,12 +51,7 @@ function resolveElementSelection(
50
51
  }
51
52
 
52
53
  interface PlayerState
53
- extends
54
- KeyframeSlice,
55
- AutomationSelectionSlice,
56
- ThumbnailSlice,
57
- AudioSoloSlice,
58
- EditingModeSlice {
54
+ extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, EditingModeSlice {
59
55
  isPlaying: boolean;
60
56
  currentTime: number;
61
57
  duration: number;
@@ -135,22 +131,7 @@ interface PlayerState
135
131
  setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
136
132
  /** Move the selection anchor within an active multi-selection without collapsing it. */
137
133
  setSelectionAnchor: (id: string | null) => void;
138
- updateElement: (
139
- elementId: string,
140
- updates: Partial<
141
- Pick<
142
- TimelineElement,
143
- | "start"
144
- | "duration"
145
- | "track"
146
- | "zIndex"
147
- | "hasExplicitZIndex"
148
- | "playbackStart"
149
- | "hidden"
150
- | "audioGroup"
151
- >
152
- >,
153
- ) => void;
134
+ updateElement: (elementId: string, updates: TimelineElementPatch) => void;
154
135
  setZoomMode: (mode: ZoomMode) => void;
155
136
  setManualZoomPercent: (percent: number) => void;
156
137
  bumpZEditVersion: () => void;
@@ -241,6 +222,19 @@ export interface DomClipChild {
241
222
  hostId: string;
242
223
  label: string;
243
224
  stackingContextId: string;
225
+ /**
226
+ * The child's audio-group state, read off its live element during the DOM
227
+ * walk — the only place that sees it. A sub-composition can declare a group
228
+ * and its members entirely within itself, and those members never reach the
229
+ * flat store, so an expanded child has no twin to inherit membership from.
230
+ */
231
+ audioGroup?: string;
232
+ audioGroupLabel?: string;
233
+ audioGroupVolume?: number;
234
+ audioGroupHidden?: boolean;
235
+ audioGroupFxChain?: string;
236
+ /** The group element's `data-automation`, mirrored the same way. */
237
+ audioGroupAutomation?: string;
244
238
  }
245
239
 
246
240
  interface BeatHistoryEntry {
@@ -271,9 +265,11 @@ export function createTimelineResetState() {
271
265
  // paste through `sel.elementKey === paste.elementKey` to a stale t0.
272
266
  automationSelection: null,
273
267
  expandedClipIds: new Set<string>(),
274
- expandedGroupIds: new Set<string>(),
268
+ // Per-composition: ids from comp A match nothing in B, silencing all of it.
269
+ collapsedGroupIds: new Set<string>(),
275
270
  expandedLaneOwnerIds: new Set<string>(),
276
271
  focusedEaseSegment: null,
272
+ revealedAudioFxTarget: null,
277
273
  selectedElementIds: new Set<string>(),
278
274
  requestedSeekTime: null,
279
275
  lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
@@ -323,7 +319,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
323
319
  ...createThumbnailSlice(set),
324
320
 
325
321
  ...createAutomationSelectionSlice(set),
326
- ...createAudioSoloSlice(set, get),
327
322
  ...createEditingModeSlice(set),
328
323
 
329
324
  activeKeyframePct: null,
@@ -519,18 +514,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
519
514
  // echoes that must preserve a group go through setSelectionAnchor instead.
520
515
  setSelectedElementId: (id, options) =>
521
516
  set((s) => {
522
- const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
523
- const selectedElementIds = preserveSet
524
- ? new Set(s.selectedElementIds)
525
- : options?.preserveSet
526
- ? new Set<string>()
527
- : id
528
- ? new Set([id])
529
- : new Set<string>();
517
+ const selectedElementIds = nextSelectionSet(s.selectedElementIds, id, options?.preserveSet);
530
518
  // Selecting a different element drops any active keyframe selection — otherwise
531
519
  // a stale activeKeyframePct from a prior diamond click would force the next drag
532
520
  // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
533
521
  // calling setSelectedElementId, so this never clobbers a genuine keyframe select.
522
+ // A reveal request survives the selection it is FOR. `openClipFxRack`
523
+ // raises the request and then selects the clip asynchronously, so the
524
+ // selection lands afterwards and used to clear the very request that
525
+ // caused it — the panel then read null and the section never opened.
526
+ // Any OTHER selection still drops it: a request aimed elsewhere is stale.
527
+ //
528
+ // Compared across the ID-SPACE BOUNDARY, which is why this needs saying:
529
+ // a request carries the BARE dom id (`runtimeAudioId`, because the panel
530
+ // and the runtime speak that), while this store's ids are
531
+ // `sourceFile#domId`. A direct `===` was silently never true — the exact
532
+ // shape of failure the id-space split produces.
533
+ const revealSurvives = revealTargetsSelection(s.revealedAudioFxTarget, id);
534
534
  return id !== s.selectedElementId
535
535
  ? {
536
536
  selectedElementId: id,
@@ -538,6 +538,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
538
538
  activeKeyframePct: null,
539
539
  motionPathArmed: false,
540
540
  focusedEaseSegment: null,
541
+ ...(revealSurvives ? {} : { revealedAudioFxTarget: null }),
541
542
  }
542
543
  : { selectedElementId: id, selectedElementIds };
543
544
  }),
@@ -579,15 +580,4 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
579
580
  reset: () => set(createTimelineResetState()),
580
581
  }));
581
582
 
582
- function isDevBuild(): boolean {
583
- try {
584
- return import.meta.env.DEV === true;
585
- } catch {
586
- // Turbopack and other non-Vite bundlers may not provide import.meta.env.
587
- return false;
588
- }
589
- }
590
- if (isDevBuild() && typeof window !== "undefined") {
591
- // Console handle for dumping live Studio state during bug-bash reproduction.
592
- (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
593
- }
583
+ attachPlayerStoreDevHandle(usePlayerStore);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A console handle on the live store, dev builds only.
3
+ *
4
+ * Split out of `playerStore.ts` to keep it under the studio's 600-line cap. The
5
+ * dev check is its own function because `import.meta.env` is absent under
6
+ * Turbopack and other non-Vite bundlers, where reading it throws.
7
+ */
8
+
9
+ function isDevBuild(): boolean {
10
+ try {
11
+ return import.meta.env.DEV === true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /** Expose `store` as `window.__playerStore` for dumping live Studio state
18
+ * during bug-bash reproduction. No-op outside a dev build or a browser. */
19
+ export function attachPlayerStoreDevHandle(store: unknown): void {
20
+ if (!isDevBuild() || typeof window === "undefined") return;
21
+ (window as unknown as { __playerStore?: unknown }).__playerStore = store;
22
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Selection-set arithmetic for the player store, and the reveal request's
3
+ * cross-id-space match.
4
+ *
5
+ * Its own module so `playerStore.ts` stays under the studio's 600-line cap.
6
+ */
7
+
8
+ import { splitTimelineElementKey } from "../lib/timelineElementHelpers";
9
+
10
+ /**
11
+ * The id set a selection change leaves behind.
12
+ *
13
+ * `preserveSet` means "keep the multi-selection if this id is already in it" —
14
+ * a DOM→store echo re-announcing a member must not collapse the set — and
15
+ * anything else is a genuine single selection.
16
+ */
17
+ export function nextSelectionSet(
18
+ current: ReadonlySet<string>,
19
+ id: string | null,
20
+ preserveSet: boolean | undefined,
21
+ ): Set<string> {
22
+ if (preserveSet) return id && current.has(id) ? new Set(current) : new Set<string>();
23
+ return id ? new Set([id]) : new Set<string>();
24
+ }
25
+
26
+ /**
27
+ * Is a pending reveal request aimed at the element being selected?
28
+ *
29
+ * Compared across the ID-SPACE BOUNDARY, which is why it is a named function:
30
+ * a request carries the BARE dom id (`runtimeAudioId` — the panel and the
31
+ * runtime speak that), while the store's ids are `sourceFile#domId`. A direct
32
+ * `===` between the two is silently never true, which is the exact shape of
33
+ * failure the split produces and how the reveal came to be dead.
34
+ */
35
+ export function revealTargetsSelection(
36
+ request: { elementKey: string } | null,
37
+ id: string | null,
38
+ ): boolean {
39
+ if (!request || id === null) return false;
40
+ return request.elementKey === splitTimelineElementKey(id).domId;
41
+ }