@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
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The pure half of the timeline's preview sync: reading a runtime clip manifest
3
+ * and a live preview DOM into `TimelineElement`s, and the steps that hydrate a
4
+ * freshly-loaded adapter.
5
+ *
6
+ * Split out of `useTimelineSyncCallbacks.ts`, which held all of this inline
7
+ * inside `processTimelineMessage` and `initializeAdapter` and stood at 642 lines
8
+ * against the studio's 600-line cap. Every function here takes what it needs as
9
+ * an argument, so each is callable — and readable — on its own.
10
+ */
11
+
12
+ import { usePlayerStore } from "../store/playerStore";
13
+ import type { TimelineElement, DomClipChild } from "../store/playerStore";
14
+ import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
15
+ import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
16
+ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
17
+ import { groupInfoFor } from "../lib/timelineGroupInfo";
18
+ import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
19
+ import {
20
+ buildStandaloneRootTimelineElement,
21
+ createImplicitTimelineLayersFromDOM,
22
+ createTimelineElementFromManifestClip,
23
+ findTimelineDomNodeForClip,
24
+ getTimelineElementSelector,
25
+ parseTimelineFromDOM,
26
+ } from "../lib/timelineDOM";
27
+ import {
28
+ autoHealMissingCompositionIds,
29
+ normalizePreviewViewport,
30
+ } from "../lib/timelineIframeHelpers";
31
+ import { inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
32
+
33
+ /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
34
+ export function sanitizeDurationSeconds(value: number): number {
35
+ return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
36
+ }
37
+
38
+ /**
39
+ * A sub-comp child's audio-group membership, read off its live element.
40
+ *
41
+ * Captured during the DOM walk because that walk holds the only reference to
42
+ * the element. A sub-composition that declares both a group and its members
43
+ * keeps those members out of the flat store entirely, so an expanded child has
44
+ * no flat twin to inherit membership from later — without this, a group defined
45
+ * inside a sub-composition produced no group row at all.
46
+ */
47
+ function readChildAudioGroupState(child: Element): Partial<DomClipChild> {
48
+ const audioGroup = child.getAttribute(HF_AUDIO_GROUP_ATTR);
49
+ if (!audioGroup) return {};
50
+ const info = groupInfoFor(child.ownerDocument, audioGroup);
51
+ return {
52
+ audioGroup,
53
+ audioGroupLabel: info.label,
54
+ audioGroupVolume: info.volume,
55
+ audioGroupHidden: info.hidden,
56
+ ...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}),
57
+ ...(info.automation ? { audioGroupAutomation: info.automation } : {}),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * The runtime's clip tree as a child-id -> parent-id map.
63
+ *
64
+ * Empty when the tree is absent (cross-origin, or the runtime has not published
65
+ * it yet), which the caller treats the same as "no nesting".
66
+ */
67
+ export function clipTreeParentMap(win: Window | null): Map<string, string> {
68
+ const parentMap = new Map<string, string>();
69
+ const clipTree = (win as (Window & { __clipTree?: ClipTree }) | null)?.__clipTree;
70
+ if (!clipTree) return parentMap;
71
+ const walk = (nodes: ClipTree["roots"]) => {
72
+ for (const node of nodes) {
73
+ if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
74
+ if (node.children.length > 0) walk(node.children);
75
+ }
76
+ };
77
+ walk(clipTree.roots);
78
+ return parentMap;
79
+ }
80
+
81
+ /**
82
+ * One sub-composition host's id'd descendants, as timeline-expandable rows.
83
+ *
84
+ * Descends through id-less structural wrappers (the inlined sub-comp body) and
85
+ * one level into groups for drill-in. Also records each child's parent in
86
+ * `parentMap`, which it mutates: the walk is the only place both ends of the
87
+ * link are in hand.
88
+ */
89
+ function collectHostDomChildren(
90
+ hostId: string,
91
+ parentEl: Element,
92
+ parentId: string,
93
+ parentMap: Map<string, string>,
94
+ out: DomClipChild[],
95
+ ): void {
96
+ for (const child of Array.from(parentEl.children)) {
97
+ if (!child.id) {
98
+ collectHostDomChildren(hostId, child, parentId, parentMap, out); // id-less wrapper
99
+ continue;
100
+ }
101
+ const isGroup = child.hasAttribute("data-hf-group");
102
+ out.push({
103
+ id: child.id,
104
+ parentId,
105
+ hostId,
106
+ label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
107
+ stackingContextId: resolveCssStackingContextId(child),
108
+ ...readChildAudioGroupState(child),
109
+ });
110
+ parentMap.set(child.id, parentId);
111
+ if (isGroup) collectHostDomChildren(hostId, child, child.id, parentMap, out);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Every sub-composition's internal elements, across the whole manifest.
117
+ *
118
+ * Those elements (group wrappers + their children) carry no `data-start`, so the
119
+ * clip tree and the manifest never enumerate them. Surfacing them studio-side
120
+ * as DOM children + parent links is what lets the timeline expand a
121
+ * sub-comp/group row; the manifest stays lean (timed clips only).
122
+ */
123
+ export function collectSubCompositionDomChildren(
124
+ iframeDoc: Document | null,
125
+ clips: readonly ClipManifestClip[],
126
+ parentMap: Map<string, string>,
127
+ ): DomClipChild[] {
128
+ const out: DomClipChild[] = [];
129
+ if (!iframeDoc) return out;
130
+ for (const clip of clips) {
131
+ if (clip.kind !== "composition" || !clip.id) continue;
132
+ const hostEl = iframeDoc.getElementById(clip.id);
133
+ if (!hostEl) continue;
134
+ const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
135
+ collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** An iframe's document, or null when reading it throws (cross-origin, or the
141
+ * frame is mid-navigation). */
142
+ export function safeContentDocument(iframe: HTMLIFrameElement | null): Document | null {
143
+ try {
144
+ return iframe?.contentDocument ?? null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The manifest's root clips as TimelineElements, each bound to the live DOM node
152
+ * it was authored as. `usedHostEls` makes the binding one-to-one: two clips with
153
+ * the same shape must not both claim the same element.
154
+ */
155
+ export function buildTimelineElementsFromClips(
156
+ clips: readonly ClipManifestClip[],
157
+ iframeDoc: Document | null,
158
+ ): TimelineElement[] {
159
+ const usedHostEls = new Set<Element>();
160
+ return clips.map((clip, index) => {
161
+ const hostEl = iframeDoc
162
+ ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
163
+ : null;
164
+ if (hostEl) usedHostEls.add(hostEl);
165
+ return createTimelineElementFromManifestClip({
166
+ clip,
167
+ fallbackIndex: index,
168
+ doc: iframeDoc,
169
+ hostEl,
170
+ });
171
+ });
172
+ }
173
+
174
+ /**
175
+ * The clamped manifest elements plus the layers that exist only in the DOM.
176
+ * Both halves need the same resolved duration, which is why they land together.
177
+ */
178
+ export function withImplicitDomLayers(
179
+ els: readonly TimelineElement[],
180
+ iframeDoc: Document | null,
181
+ effectiveDuration: number,
182
+ ): TimelineElement[] {
183
+ const clamped = clampElementsToDuration(els, effectiveDuration);
184
+ if (!iframeDoc || effectiveDuration <= 0) return clamped;
185
+ return [
186
+ ...clamped,
187
+ ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clamped),
188
+ ];
189
+ }
190
+
191
+ /**
192
+ * Drop elements that start past the composition's end and trim the ones that
193
+ * straddle it. A non-positive duration means "not known yet" — pass through
194
+ * untouched rather than clamping everything to nothing.
195
+ */
196
+ function clampElementsToDuration(
197
+ els: readonly TimelineElement[],
198
+ effectiveDuration: number,
199
+ ): TimelineElement[] {
200
+ if (effectiveDuration <= 0) return [...els];
201
+ return els
202
+ .filter((element) => element.start < effectiveDuration)
203
+ .map((element) => ({
204
+ ...element,
205
+ duration: Math.min(element.duration, effectiveDuration - element.start),
206
+ }))
207
+ .filter((element) => element.duration > 0);
208
+ }
209
+
210
+ /**
211
+ * Seek a freshly-loaded adapter to the playhead the session should resume at,
212
+ * and return it.
213
+ *
214
+ * Honors a seek requested before the adapter was ready. It may sit in either
215
+ * place: `pendingSeekRef` if the store subscription was mounted when requestSeek
216
+ * fired, or only in the store's `requestedSeekTime` if it fired earlier still
217
+ * (deep-link hydration runs before the player subscription mounts, so the
218
+ * request never reaches pendingSeekRef). Reconciling with the store here is what
219
+ * makes a deep-linked `?t=` land instead of starting at 0.
220
+ *
221
+ * The double seek forces a REAL render, not a no-op. After a post-edit reload the
222
+ * freshly rebuilt GSAP timeline can already report being at `startTime`
223
+ * internally (the reload restores the same playhead), so a single
224
+ * `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
225
+ * doesn't re-evaluate. That's why a just-dropped clip stayed invisible until the
226
+ * user nudged the playhead: its element's state was never applied at the restore
227
+ * position. Seeking to a DIFFERENT guard value first (a hair off, or 0 when
228
+ * startTime is already ~0) guarantees the follow-up seek crosses a time boundary
229
+ * and re-renders every clip — including the new one.
230
+ */
231
+ export function resolveReloadSeekTime(input: {
232
+ pendingSeek: number | null;
233
+ requestedSeek: number | null;
234
+ storeCurrentTime: number;
235
+ duration: number;
236
+ }): number {
237
+ const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
238
+ if (!Number.isFinite(target) || target <= 0) return 0;
239
+ // Only clamp to duration when it's a usable positive number. A non-finite or
240
+ // non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
241
+ // Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
242
+ // unclamped instead so the playhead lands at the intended position.
243
+ if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
244
+ return Math.min(target, input.duration);
245
+ }
246
+
247
+ export function seekAdapterToRestorePoint(
248
+ adapter: PlaybackAdapter,
249
+ pendingSeekRef: { current: number | null },
250
+ ): number {
251
+ const storeSeek = usePlayerStore.getState().requestedSeekTime;
252
+ const startTime = resolveReloadSeekTime({
253
+ pendingSeek: pendingSeekRef.current,
254
+ requestedSeek: storeSeek,
255
+ storeCurrentTime: usePlayerStore.getState().currentTime,
256
+ duration: adapter.getDuration(),
257
+ });
258
+ pendingSeekRef.current = null;
259
+ if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
260
+ adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001);
261
+ adapter.seek(startTime);
262
+ return startTime;
263
+ }
264
+
265
+ /** Push the adapter's own duration into the store, ignoring the values
266
+ * `sanitizeDurationSeconds` rejects and a value already in place. */
267
+ export function syncAdapterDuration(
268
+ adapter: PlaybackAdapter,
269
+ setDuration: (d: number) => void,
270
+ ): void {
271
+ const adapterDur = sanitizeDurationSeconds(adapter.getDuration());
272
+ if (adapterDur > 0 && adapterDur !== usePlayerStore.getState().duration) {
273
+ setDuration(adapterDur);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Last-resort timeline for a preview whose manifest produced nothing: parse the
279
+ * DOM, and failing that stand the root composition up as a single element.
280
+ * Without it a composition the runtime never enumerated shows an empty timeline
281
+ * rather than one row spanning its own duration.
282
+ */
283
+ function syncFallbackTimelineFromDom(
284
+ doc: Document,
285
+ iframe: HTMLIFrameElement | null,
286
+ rootDuration: number,
287
+ syncTimelineElements: (els: TimelineElement[], duration?: number) => void,
288
+ ): void {
289
+ const els = parseTimelineFromDOM(doc, rootDuration);
290
+ if (els.length > 0) {
291
+ syncTimelineElements(els);
292
+ return;
293
+ }
294
+ const rootComp = doc.querySelector("[data-composition-id]");
295
+ if (!rootComp || rootDuration <= 0) return;
296
+ const fallbackElement = buildStandaloneRootTimelineElement({
297
+ compositionId: rootComp.getAttribute("data-composition-id") || "composition",
298
+ tagName: (rootComp as HTMLElement).tagName || "div",
299
+ rootDuration,
300
+ iframeSrc: iframe?.src || "",
301
+ selector: getTimelineElementSelector(rootComp),
302
+ });
303
+ if (fallbackElement) syncTimelineElements([fallbackElement]);
304
+ }
305
+
306
+ /** The runtime's timeline message, as the preview posts it. */
307
+ export interface RuntimeTimelineMessage {
308
+ clips: ClipManifestClip[];
309
+ durationInFrames: number;
310
+ scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
311
+ protocolVersion?: unknown;
312
+ capabilities?: unknown;
313
+ fps?: unknown;
314
+ }
315
+
316
+ /** Whether a window message came from the preview iframe we are watching.
317
+ * A message with no `source` (jsdom, synthetic dispatch) is not rejected. */
318
+ function isFromPreviewFrame(e: MessageEvent, iframe: HTMLIFrameElement | null): boolean {
319
+ if (!e.source || !iframe) return true;
320
+ return e.source === iframe.contentWindow;
321
+ }
322
+
323
+ /**
324
+ * Whether a message is a preview readiness signal this listener should act on.
325
+ *
326
+ * The main message handler owns protocol-error diagnostics. This readiness-only
327
+ * listener mirrors its acceptance gate without dispatching a duplicate event: an
328
+ * unsupported runtime must not make the iframe appear successfully settled.
329
+ */
330
+ export function isPreviewReadinessMessage(
331
+ e: MessageEvent,
332
+ iframe: HTMLIFrameElement | null,
333
+ ): boolean {
334
+ if (!isFromPreviewFrame(e, iframe)) return false;
335
+ const data = e.data;
336
+ if (data?.source !== "hf-preview") return false;
337
+ if (data?.type !== "state" && data?.type !== "timeline") return false;
338
+ return inspectStudioRuntimeMessage(data).status !== "unsupported";
339
+ }
340
+
341
+ export interface HydrateTimelineFromPreviewInput {
342
+ iframe: HTMLIFrameElement | null;
343
+ adapter: PlaybackAdapter;
344
+ processTimelineMessage: (manifest: RuntimeTimelineMessage) => void;
345
+ enrichMissingCompositions: () => void;
346
+ applyPreviewAudioState: () => void;
347
+ attachIframeShortcutListeners: () => void;
348
+ syncTimelineElements: (els: TimelineElement[], duration?: number) => void;
349
+ }
350
+
351
+ /**
352
+ * Everything the timeline reads off a newly-loaded preview: viewport
353
+ * normalisation, the runtime's own clip manifest, composition enrichment, audio
354
+ * state, and the DOM fallbacks when none of that produced a row.
355
+ *
356
+ * Wrapped in one try, as it always was: any of these can throw on a
357
+ * cross-origin or mid-navigation frame, and none of them is worth failing the
358
+ * adapter's initialisation over.
359
+ */
360
+ function normalizePreviewDom(
361
+ doc: Document | null,
362
+ iframeWin: IframeWindow | null,
363
+ attachIframeShortcutListeners: () => void,
364
+ ): void {
365
+ if (!doc || !iframeWin) return;
366
+ normalizePreviewViewport(doc, iframeWin);
367
+ autoHealMissingCompositionIds(doc);
368
+ attachIframeShortcutListeners();
369
+ }
370
+
371
+ /** Hand the runtime's own clip manifest to the timeline, if it published one. */
372
+ function applyRuntimeClipManifest(
373
+ iframeWin: IframeWindow | null,
374
+ processTimelineMessage: (manifest: RuntimeTimelineMessage) => void,
375
+ ): void {
376
+ const manifest = iframeWin?.__clipManifest;
377
+ if (manifest && manifest.clips.length > 0) processTimelineMessage(manifest);
378
+ }
379
+
380
+ export function hydrateTimelineFromPreview(input: HydrateTimelineFromPreviewInput): void {
381
+ const { iframe, adapter, syncTimelineElements } = input;
382
+ try {
383
+ const doc = safeContentDocument(iframe);
384
+ const iframeWin = (iframe?.contentWindow as IframeWindow | null) ?? null;
385
+ normalizePreviewDom(doc, iframeWin, input.attachIframeShortcutListeners);
386
+ applyRuntimeClipManifest(iframeWin, input.processTimelineMessage);
387
+ input.enrichMissingCompositions();
388
+ input.applyPreviewAudioState();
389
+ if (doc && usePlayerStore.getState().elements.length === 0) {
390
+ syncFallbackTimelineFromDom(doc, iframe, adapter.getDuration(), syncTimelineElements);
391
+ }
392
+ } catch {
393
+ // Cross-origin or mid-navigation preview — the adapter is still initialised.
394
+ }
395
+ }
@@ -645,4 +645,87 @@ describe("buildExpandedElements — collision-free synthetic rows (cross-file la
645
645
  // Distinct ordered rows per child.
646
646
  expect(children[0].track).not.toBe(children[1].track);
647
647
  });
648
+
649
+ /**
650
+ * A sub-composition that declares BOTH a group and its members keeps those
651
+ * members out of the flat store entirely — the store holds only the host.
652
+ * So "inherit membership from the flat twin" had nothing to inherit from,
653
+ * and the group produced no timeline row at all, for exactly the case group
654
+ * support was extended to cover. Verified against a real studio session
655
+ * before this test was written: the flat store held three elements (the
656
+ * panel, the sub-comp host and an ungrouped bed) and neither voice.
657
+ */
658
+ it("takes group membership from the DOM child when there is no flat store twin", () => {
659
+ const elements = [
660
+ el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
661
+ ];
662
+ const manifest = [
663
+ clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
664
+ ];
665
+ const parentMap = new Map([
666
+ ["voice-1", "voices-host"],
667
+ ["voice-2", "voices-host"],
668
+ ]);
669
+ const domClipChildren = [
670
+ {
671
+ id: "voice-1",
672
+ parentId: "voices-host",
673
+ hostId: "voices-host",
674
+ label: "voice-1",
675
+ stackingContextId: "css:0",
676
+ audioGroup: "voiceover",
677
+ audioGroupLabel: "Voiceover",
678
+ audioGroupVolume: 0.8,
679
+ audioGroupHidden: false,
680
+ },
681
+ {
682
+ id: "voice-2",
683
+ parentId: "voices-host",
684
+ hostId: "voices-host",
685
+ label: "voice-2",
686
+ stackingContextId: "css:0",
687
+ audioGroup: "voiceover",
688
+ audioGroupLabel: "Voiceover",
689
+ audioGroupVolume: 0.8,
690
+ audioGroupHidden: false,
691
+ },
692
+ ];
693
+
694
+ const out = buildExpandedElements(
695
+ elements,
696
+ manifest,
697
+ parentMap,
698
+ "voices-host",
699
+ "voices-host",
700
+ domClipChildren,
701
+ );
702
+
703
+ const voices = out.filter((e) => e.domId?.startsWith("voice-"));
704
+ expect(voices).toHaveLength(2);
705
+ for (const voice of voices) {
706
+ expect(voice.audioGroup).toBe("voiceover");
707
+ expect(voice.audioGroupLabel).toBe("Voiceover");
708
+ expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6);
709
+ }
710
+ });
711
+ });
712
+
713
+ describe("sub-comp child rows never collide with a group anchor", () => {
714
+ /**
715
+ * A group row anchors at exactly `firstMemberTrack - 0.5`. The old child
716
+ * scheme `k / (n + 2)` hit 0.5 dead on for a host with TWO children (2/4),
717
+ * producing a duplicate row key and a duplicated group header.
718
+ */
719
+ it("keeps every child strictly below the host's half-lane", () => {
720
+ for (const childCount of [1, 2, 3, 4, 7]) {
721
+ const fractions = Array.from(
722
+ { length: childCount },
723
+ (_unused, i) => (0.5 * (i + 1)) / (childCount + 1),
724
+ );
725
+ expect(fractions.every((f) => f > 0 && f < 0.5)).toBe(true);
726
+ // Still distinct and ordered, which is what makes them usable as rows.
727
+ expect(new Set(fractions).size).toBe(childCount);
728
+ expect([...fractions].sort((a, b) => a - b)).toEqual(fractions);
729
+ }
730
+ });
648
731
  });
@@ -143,6 +143,32 @@ interface DisplayBounds {
143
143
  * could never be shown again (not even after a reload, since the attribute is in
144
144
  * the source).
145
145
  */
146
+ /**
147
+ * Audio-group membership for an expanded child, from whichever source has it.
148
+ *
149
+ * The flat store twin when there is one; otherwise the `DomClipChild` record,
150
+ * which carried it off the live element during the DOM walk. That fallback is
151
+ * the ONLY source for a sub-composition that declares both a group and its
152
+ * members: those members never enter the flat store, so "inherit from the flat
153
+ * twin" silently produced no membership and therefore no group row — for
154
+ * exactly the case group support was extended to cover.
155
+ */
156
+ function childGroupState(
157
+ flat: TimelineElement | undefined,
158
+ domChild: DomClipChild | undefined,
159
+ ): Partial<TimelineElement> {
160
+ const source = flat?.audioGroup ? flat : domChild?.audioGroup ? domChild : null;
161
+ if (!source) return {};
162
+ return {
163
+ audioGroup: source.audioGroup,
164
+ audioGroupLabel: source.audioGroupLabel,
165
+ audioGroupVolume: source.audioGroupVolume,
166
+ audioGroupHidden: source.audioGroupHidden,
167
+ audioGroupFxChain: source.audioGroupFxChain,
168
+ audioGroupAutomation: source.audioGroupAutomation,
169
+ };
170
+ }
171
+
146
172
  function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
147
173
  if (!flat) return {};
148
174
  return {
@@ -169,6 +195,7 @@ function buildChildElements(
169
195
  editBasis: { start: number; sourceFile: string | undefined },
170
196
  expandedHostKey: string,
171
197
  elements: readonly TimelineElement[],
198
+ domChildrenById: ReadonlyMap<string, DomClipChild>,
172
199
  ): TimelineElement[] {
173
200
  const result: TimelineElement[] = [];
174
201
  for (const child of siblings) {
@@ -197,6 +224,10 @@ function buildChildElements(
197
224
  result.push({
198
225
  ...base,
199
226
  ...hostElementState(elements.find((element) => element.key === key)),
227
+ ...childGroupState(
228
+ elements.find((element) => element.key === key),
229
+ domId ? domChildrenById.get(domId) : undefined,
230
+ ),
200
231
  key,
201
232
  start: clamped.start,
202
233
  duration: clamped.duration,
@@ -215,7 +246,13 @@ function buildChildElements(
215
246
  // clips. Fractions strictly between the host's lane and the next integer
216
247
  // can never equal a normalized (integer) lane, while still rendering the
217
248
  // children as their own ordered rows directly under the host.
218
- track: display.track + (result.length + 1) / (siblings.length + 2),
249
+ //
250
+ // Confined to the LOWER half of that gap, because a GROUP row anchors at
251
+ // exactly `firstMemberTrack - 0.5` (`useTimelineTrackDerivations`) — and
252
+ // the old `k / (n + 2)` hit 0.5 dead on for a host with two children
253
+ // (2/4), producing a duplicate row key and a duplicated group header. This
254
+ // scheme's maximum is `0.5 * n / (n + 1)`, strictly under 0.5 for every n.
255
+ track: display.track + (0.5 * (result.length + 1)) / (siblings.length + 1),
219
256
  authoredTrack: base.authoredTrack,
220
257
  stackingContextId: base.stackingContextId,
221
258
  expandedParentStart: editBasis.start,
@@ -299,6 +336,7 @@ export function buildExpandedElements(
299
336
  };
300
337
 
301
338
  const parentKey = topLevelElement.key ?? topLevelElement.id;
339
+ const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child]));
302
340
  const expanded = buildChildElements(
303
341
  siblings,
304
342
  {
@@ -309,6 +347,7 @@ export function buildExpandedElements(
309
347
  editBasis,
310
348
  parentKey,
311
349
  elements,
350
+ domChildrenById,
312
351
  );
313
352
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
314
353
 
@@ -37,11 +37,7 @@ import {
37
37
  mergeTimelineElementsPreservingDowngrades,
38
38
  } from "../lib/timelineDOM";
39
39
  import { normalizeToZones } from "../components/timelineZones";
40
- import {
41
- setPreviewMediaMuted,
42
- setPreviewMediaVolume,
43
- setPreviewPlaybackRate,
44
- } from "../lib/timelineIframeHelpers";
40
+ import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
45
41
  import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
46
42
  import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
47
43
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
@@ -235,8 +231,7 @@ export function useTimelinePlayer() {
235
231
  }, []);
236
232
  const applyPreviewAudioState = useCallback(() => {
237
233
  const { audioMuted, audioVolume } = usePlayerStore.getState();
238
- setPreviewMediaMuted(iframeRef.current, audioMuted);
239
- setPreviewMediaVolume(iframeRef.current, audioVolume);
234
+ applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
240
235
  }, []);
241
236
  const play = useCallback(() => {
242
237
  stopRAFLoop();