@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
@@ -10,24 +10,27 @@
10
10
 
11
11
  import { useCallback } from "react";
12
12
  import { liveTime, usePlayerStore } from "../store/playerStore";
13
- import type { TimelineElement, DomClipChild } from "../store/playerStore";
14
- import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
15
- import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
13
+ import type { TimelineElement } from "../store/playerStore";
14
+ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
15
+ import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
16
+ import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
17
+ import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
16
18
  import {
17
- parseTimelineFromDOM,
18
- createTimelineElementFromManifestClip,
19
- findTimelineDomNodeForClip,
20
- createImplicitTimelineLayersFromDOM,
21
- buildStandaloneRootTimelineElement,
22
- getTimelineElementSelector,
23
- readTimelineDurationFromDocument,
24
- } from "../lib/timelineDOM";
25
- import {
26
- normalizePreviewViewport,
27
- autoHealMissingCompositionIds,
28
- buildMissingCompositionElements,
29
- } from "../lib/timelineIframeHelpers";
30
- import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
19
+ buildTimelineElementsFromClips,
20
+ clipTreeParentMap,
21
+ collectSubCompositionDomChildren,
22
+ hydrateTimelineFromPreview,
23
+ isPreviewReadinessMessage,
24
+ safeContentDocument,
25
+ sanitizeDurationSeconds,
26
+ seekAdapterToRestorePoint,
27
+ syncAdapterDuration,
28
+ withImplicitDomLayers,
29
+ type RuntimeTimelineMessage,
30
+ } from "./timelineSyncHydration";
31
+
32
+ // Re-exported for the tests and callers that have always imported it from here.
33
+ export { resolveReloadSeekTime } from "./timelineSyncHydration";
31
34
 
32
35
  interface UseTimelineSyncCallbacksParams {
33
36
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -70,27 +73,6 @@ export function revealIframe(iframe: HTMLIFrameElement | null): void {
70
73
  }
71
74
  }
72
75
 
73
- export function resolveReloadSeekTime(input: {
74
- pendingSeek: number | null;
75
- requestedSeek: number | null;
76
- storeCurrentTime: number;
77
- duration: number;
78
- }): number {
79
- const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
80
- if (!Number.isFinite(target) || target <= 0) return 0;
81
- // Only clamp to duration when it's a usable positive number. A non-finite or
82
- // non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
83
- // Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
84
- // unclamped instead so the playhead lands at the intended position.
85
- if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
86
- return Math.min(target, input.duration);
87
- }
88
-
89
- /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
90
- function sanitizeDurationSeconds(value: number): number {
91
- return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
92
- }
93
-
94
76
  /**
95
77
  * The transport TOTAL a clip-manifest message should write to the store.
96
78
  *
@@ -130,14 +112,7 @@ export function useTimelineSyncCallbacks({
130
112
  }: UseTimelineSyncCallbacksParams) {
131
113
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
132
114
  const processTimelineMessage = useCallback(
133
- (data: {
134
- clips: ClipManifestClip[];
135
- durationInFrames: number;
136
- scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
137
- protocolVersion?: unknown;
138
- capabilities?: unknown;
139
- fps?: unknown;
140
- }) => {
115
+ (data: RuntimeTimelineMessage) => {
141
116
  if (!data.clips || data.clips.length === 0) {
142
117
  return;
143
118
  }
@@ -149,86 +124,18 @@ export function useTimelineSyncCallbacks({
149
124
  const filtered = data.clips.filter(
150
125
  (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
151
126
  );
152
- let iframeDoc: Document | null = null;
153
- try {
154
- iframeDoc = iframeRef.current?.contentDocument ?? null;
155
- } catch {
156
- iframeDoc = null;
157
- }
127
+ const iframeDoc = safeContentDocument(iframeRef.current);
158
128
 
159
129
  try {
160
- const iframeWin = iframeRef.current?.contentWindow as
161
- | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
162
- | null;
163
- const clipTree = iframeWin?.__clipTree;
164
- const parentMap = new Map<string, string>();
165
- if (clipTree) {
166
- const walk = (nodes: typeof clipTree.roots) => {
167
- for (const node of nodes) {
168
- if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
169
- if (node.children.length > 0) walk(node.children);
170
- }
171
- };
172
- walk(clipTree.roots);
173
- }
174
-
175
- // Descend into each sub-composition host: its internal elements (group
176
- // wrappers + their children) carry no `data-start`, so the clip
177
- // tree/manifest never enumerate them. Surface them studio-side as DOM
178
- // children + parent links so the timeline can expand a sub-comp/group
179
- // row to show them. Manifest stays lean (timed clips only).
180
- const domClipChildren: DomClipChild[] = [];
181
- if (iframeDoc) {
182
- for (const clip of data.clips) {
183
- if (clip.kind !== "composition" || !clip.id) continue;
184
- const hostEl = iframeDoc.getElementById(clip.id);
185
- if (!hostEl) continue;
186
- const hostId = clip.id;
187
- const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
188
- // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they
189
- // expand into timeline rows. Descends through id-less structural wrappers
190
- // (the inlined sub-comp body), and one level into groups for drill-in.
191
- const collect = (parentEl: Element, parentId: string) => {
192
- for (const child of Array.from(parentEl.children)) {
193
- if (!child.id) {
194
- collect(child, parentId); // unwrap id-less structural containers
195
- continue;
196
- }
197
- const isGroup = child.hasAttribute("data-hf-group");
198
- domClipChildren.push({
199
- id: child.id,
200
- parentId,
201
- hostId,
202
- label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
203
- stackingContextId: resolveCssStackingContextId(child),
204
- });
205
- parentMap.set(child.id, parentId);
206
- if (isGroup) collect(child, child.id);
207
- }
208
- };
209
- collect(innerRoot, hostId);
210
- }
211
- }
130
+ const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
131
+ const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
212
132
  usePlayerStore.getState().setClipParentMap(parentMap);
213
133
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
214
134
  } catch {
215
135
  // cross-origin or __clipTree not available — maps stay empty
216
136
  }
217
137
 
218
- const usedHostEls = new Set<Element>();
219
- const els: TimelineElement[] = filtered.map((clip, index) => {
220
- const hostEl = iframeDoc
221
- ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
222
- : null;
223
- if (hostEl) usedHostEls.add(hostEl);
224
- return createTimelineElementFromManifestClip({
225
- clip,
226
- fallbackIndex: index,
227
- doc: iframeDoc,
228
- hostEl,
229
- });
230
- });
231
- const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
138
+ const els = buildTimelineElementsFromClips(filtered, iframeDoc);
232
139
  // Clamp non-finite or absurdly large durations — the runtime can emit
233
140
  // Infinity when it detects a loop-inflated GSAP timeline without an
234
141
  // explicit data-duration on the root composition. Floor the manifest total
@@ -236,27 +143,14 @@ export function useTimelineSyncCallbacks({
236
143
  // furthest clip end (shorter than the authored window) can't leave a stale,
237
144
  // too-short total in the transport (the "0:44/0:40" bug).
238
145
  const newDuration = resolveTimelineTotalDuration({
239
- manifestDurationSeconds: rawDuration,
146
+ manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
240
147
  authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
241
148
  });
242
- const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
243
- const clampedEls =
244
- effectiveDuration > 0
245
- ? els
246
- .filter((element) => element.start < effectiveDuration)
247
- .map((element) => ({
248
- ...element,
249
- duration: Math.min(element.duration, effectiveDuration - element.start),
250
- }))
251
- .filter((element) => element.duration > 0)
252
- : els;
253
- const timelineEls =
254
- iframeDoc && effectiveDuration > 0
255
- ? [
256
- ...clampedEls,
257
- ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
258
- ]
259
- : clampedEls;
149
+ const timelineEls = withImplicitDomLayers(
150
+ els,
151
+ iframeDoc,
152
+ newDuration > 0 ? newDuration : usePlayerStore.getState().duration,
153
+ );
260
154
  if (timelineEls.length > 0) {
261
155
  syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
262
156
  }
@@ -294,34 +188,7 @@ export function useTimelineSyncCallbacks({
294
188
  if (!adapter || adapter.getDuration() <= 0) return false;
295
189
 
296
190
  adapter.pause();
297
- // Honor a seek requested before the adapter was ready. It may sit in either
298
- // place: `pendingSeekRef` if the store subscription was mounted when requestSeek
299
- // fired, or only in the store's `requestedSeekTime` if it fired earlier still
300
- // (deep-link hydration runs before the player subscription mounts, so the request
301
- // never reaches pendingSeekRef). Reconciling with the store here is what makes a
302
- // deep-linked `?t=` land instead of starting at 0.
303
- const storeSeek = usePlayerStore.getState().requestedSeekTime;
304
- const startTime = resolveReloadSeekTime({
305
- pendingSeek: pendingSeekRef.current,
306
- requestedSeek: storeSeek,
307
- storeCurrentTime: usePlayerStore.getState().currentTime,
308
- duration: adapter.getDuration(),
309
- });
310
- pendingSeekRef.current = null;
311
- if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
312
-
313
- // Force a REAL render at startTime, not a no-op. After a post-edit reload the
314
- // freshly rebuilt GSAP timeline can already report being at `startTime`
315
- // internally (the reload restores the same playhead), so a single
316
- // `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
317
- // doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
318
- // the user nudged the playhead: its element's state was never applied at the
319
- // restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
320
- // when startTime is already ~0) guarantees the follow-up seek to `startTime`
321
- // crosses a time boundary and re-renders every clip — including the new one.
322
- const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
323
- adapter.seek(guardTime);
324
- adapter.seek(startTime);
191
+ const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
325
192
  // The correct frame is now rendered — reveal the iframe that refreshPlayer hid
326
193
  // for the reload, so the user sees the restored frame directly (never the raw
327
194
  // all-clips DOM). Cleared unconditionally: any later failure path must not leave
@@ -330,15 +197,7 @@ export function useTimelineSyncCallbacks({
330
197
  // Keep non-React listeners such as the capture link and time display in sync
331
198
  // with the initial adapter seek on iframe load.
332
199
  liveTime.notify(startTime);
333
- const adapterDur = adapter.getDuration();
334
- if (
335
- Number.isFinite(adapterDur) &&
336
- adapterDur > 0 &&
337
- adapterDur < 7200 &&
338
- adapterDur !== usePlayerStore.getState().duration
339
- ) {
340
- setDuration(adapterDur);
341
- }
200
+ syncAdapterDuration(adapter, setDuration);
342
201
  setCurrentTime(startTime);
343
202
  if (!isRefreshingRef.current) {
344
203
  setTimelineReady(true);
@@ -346,42 +205,15 @@ export function useTimelineSyncCallbacks({
346
205
  isRefreshingRef.current = false;
347
206
  setIsPlaying(false);
348
207
 
349
- try {
350
- const iframe = iframeRef.current;
351
- const doc = iframe?.contentDocument;
352
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
353
- if (doc && iframeWin) {
354
- normalizePreviewViewport(doc, iframeWin);
355
- autoHealMissingCompositionIds(doc);
356
- attachIframeShortcutListeners();
357
- }
358
-
359
- const manifest = iframeWin?.__clipManifest;
360
- if (manifest && manifest.clips.length > 0) {
361
- processTimelineMessage(manifest);
362
- }
363
- enrichMissingCompositions();
364
- applyPreviewAudioState();
365
-
366
- if (usePlayerStore.getState().elements.length === 0 && doc) {
367
- const els = parseTimelineFromDOM(doc, adapter.getDuration());
368
- if (els.length > 0) syncTimelineElements(els);
369
- }
370
- if (usePlayerStore.getState().elements.length === 0 && doc) {
371
- const rootComp = doc.querySelector("[data-composition-id]");
372
- const rootDuration = adapter.getDuration();
373
- if (rootComp && rootDuration > 0) {
374
- const fallbackElement = buildStandaloneRootTimelineElement({
375
- compositionId: rootComp.getAttribute("data-composition-id") || "composition",
376
- tagName: (rootComp as HTMLElement).tagName || "div",
377
- rootDuration,
378
- iframeSrc: iframe?.src || "",
379
- selector: getTimelineElementSelector(rootComp),
380
- });
381
- if (fallbackElement) syncTimelineElements([fallbackElement]);
382
- }
383
- }
384
- } catch {}
208
+ hydrateTimelineFromPreview({
209
+ iframe: iframeRef.current,
210
+ adapter,
211
+ processTimelineMessage,
212
+ enrichMissingCompositions,
213
+ applyPreviewAudioState,
214
+ attachIframeShortcutListeners,
215
+ syncTimelineElements,
216
+ });
385
217
  return true;
386
218
  }, [
387
219
  getAdapter,
@@ -421,15 +253,7 @@ export function useTimelineSyncCallbacks({
421
253
  };
422
254
 
423
255
  const onMessage = (e: MessageEvent) => {
424
- if (e.source && iframe && e.source !== iframe.contentWindow) return;
425
- const data = e.data;
426
- if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
427
- // The main message handler owns protocol-error diagnostics. This readiness-only
428
- // listener mirrors its acceptance gate without dispatching a duplicate event:
429
- // an unsupported runtime must not make the iframe appear successfully settled.
430
- if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
431
- trySettle();
432
- }
256
+ if (isPreviewReadinessMessage(e, iframe)) trySettle();
433
257
  };
434
258
  window.addEventListener("message", onMessage);
435
259
 
@@ -80,4 +80,30 @@ describe("syncStoredAutomationFromPreview", () => {
80
80
  syncStoredAutomationFromPreview(null);
81
81
  expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
82
82
  });
83
+ // The reported symptom: automate a parameter on a GROUP from the FX rack and
84
+ // the group's row shows no automation. The rack is not group-aware — it
85
+ // writes the attribute on the group node through the ordinary element path —
86
+ // and the timeline reads a group's lanes from the mirrors its MEMBERS carry,
87
+ // which that path used to leave untouched.
88
+ it("re-reads what the group carries onto every member that belongs to it", () => {
89
+ const chain = '{"version":1,"nodes":[{"type":"gain","id":"n1","params":{"gain":0}}]}';
90
+ const groupAutomation =
91
+ '{"version":1,"lanes":[{"target":"fx.n1.gain","points":[{"t":0,"v":0}]}]}';
92
+ const doc = document.implementation.createHTMLDocument("preview");
93
+ const group = doc.createElement("hf-audio-group");
94
+ group.id = "voiceover";
95
+ group.setAttribute("data-fx-chain", chain);
96
+ group.setAttribute("data-automation", groupAutomation);
97
+ const audio = doc.createElement("audio");
98
+ audio.id = "bgm";
99
+ audio.setAttribute("data-audio-group", "voiceover");
100
+ doc.body.append(group, audio);
101
+
102
+ usePlayerStore.setState({ elements: [el({ audioGroup: "voiceover" })] });
103
+ syncStoredAutomationFromPreview(doc);
104
+
105
+ const stored = usePlayerStore.getState().elements[0];
106
+ expect(stored?.audioGroupAutomation).toBe(groupAutomation);
107
+ expect(stored?.audioGroupFxChain).toBe(chain);
108
+ });
83
109
  });
@@ -16,6 +16,7 @@
16
16
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
17
17
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
18
18
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
19
+ import { groupInfoFor } from "./timelineGroupInfo";
19
20
 
20
21
  /** The preview node an element stands for, by dom id and then by `data-hf-id`. */
21
22
  function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
@@ -38,6 +39,27 @@ function previewNodeFor(doc: Document, element: TimelineElement): Element | null
38
39
  * Reads rather than being told: an undo restores whole files, so the attribute it
39
40
  * reverted is only known by looking.
40
41
  */
42
+ /**
43
+ * What an element's four synced fields SHOULD read, given the preview.
44
+ *
45
+ * Its own two come off its node; the other two are its copy of what its group
46
+ * carries. The timeline derives a group's lanes and chain from these mirrors,
47
+ * never from the group element — and the FX rack is not group-aware: selecting
48
+ * a group and automating one of its parameters writes `data-automation` on the
49
+ * group node through the ordinary element path, which used to refresh an
50
+ * element's own two fields and nothing else. So the group's row went on reading
51
+ * the value it was born with, and its `∿` never appeared.
52
+ */
53
+ function syncedFields(doc: Document, element: TimelineElement, node: Element) {
54
+ const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
55
+ return {
56
+ automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
57
+ fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
58
+ audioGroupAutomation: group?.automation,
59
+ audioGroupFxChain: group?.fxChain,
60
+ };
61
+ }
62
+
41
63
  export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
42
64
  if (!doc) return;
43
65
  usePlayerStore.setState((state) => {
@@ -45,11 +67,13 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
45
67
  const elements = state.elements.map((element) => {
46
68
  const node = previewNodeFor(doc, element);
47
69
  if (!node) return element;
48
- const automation = node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined;
49
- const fxChain = node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined;
50
- if (automation === element.automation && fxChain === element.fxChain) return element;
70
+ const fields = syncedFields(doc, element, node);
71
+ // Same array back when nothing moved: `elements` keys memos all over the
72
+ // timeline, and a fresh object per sync would re-render every one.
73
+ const keys = Object.keys(fields) as (keyof typeof fields)[];
74
+ if (keys.every((key) => fields[key] === element[key])) return element;
51
75
  changed = true;
52
- return { ...element, automation, fxChain };
76
+ return { ...element, ...fields };
53
77
  });
54
78
  return changed ? { elements } : {};
55
79
  });
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * The studio → runtime boundary, which nothing else crosses.
5
+ *
6
+ * Studio addresses rows by `buildTimelineElementKey`'s composite
7
+ * `<sourceFile>#<domId>`; every audio predicate in `@hyperframes/core` keys off
8
+ * the live document instead. Both halves have their own passing tests — one
9
+ * with composite keys, one with bare ids — and the mismatch between them lived
10
+ * in the gap. These parse a real document, take the ids the way the UI does,
11
+ * and hand them to the real core predicates.
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+ import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
16
+ import { parseTimelineFromDOM } from "./timelineDOM";
17
+ import { runtimeAudioId } from "./timelineElementHelpers";
18
+
19
+ function docWith(body: string): Document {
20
+ const doc = document.implementation.createHTMLDocument("comp");
21
+ doc.body.innerHTML = body;
22
+ return doc;
23
+ }
24
+
25
+ const COMPOSITION = `
26
+ <div data-composition-id="root" data-duration="30"></div>
27
+ <audio id="voice-1" data-start="0" data-duration="10" data-audio-group="voiceover"></audio>
28
+ <audio id="voice-2" data-start="10" data-duration="10" data-audio-group="voiceover"></audio>
29
+ <audio id="music-bed" data-start="0" data-duration="30"></audio>
30
+ <hf-audio-group id="voiceover"></hf-audio-group>
31
+ `;
32
+
33
+ describe("group membership ids cross into the runtime", () => {
34
+ it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
35
+ const doc = docWith(COMPOSITION);
36
+ const trackElements = parseTimelineFromDOM(doc, 30).filter(
37
+ (el) => el.tag.toLowerCase() === "audio",
38
+ );
39
+ const clipIds = trackElements.map(runtimeAudioId).filter((id): id is string => id !== null);
40
+ expect(clipIds).toEqual(["voice-1", "voice-2", "music-bed"]);
41
+
42
+ // Same space membership is read back in — a composite key here produces a
43
+ // group whose members nothing can find.
44
+ const memberIds = resolveAudioGroups(doc).flatMap((g) => g.memberIds);
45
+ expect(memberIds.every((id) => doc.getElementById(id) !== null)).toBe(true);
46
+ for (const id of memberIds) expect(clipIds).toContain(id);
47
+ });
48
+
49
+ it("an element with no DOM id is not groupable", () => {
50
+ const doc = docWith(`
51
+ <div data-composition-id="root" data-duration="10"></div>
52
+ <audio data-start="0" data-duration="5"></audio>
53
+ `);
54
+ const [clip] = parseTimelineFromDOM(doc, 10);
55
+ expect(clip).toBeDefined();
56
+ expect(runtimeAudioId(clip)).toBeNull();
57
+ });
58
+ });
@@ -6,6 +6,8 @@ import {
6
6
  createImplicitTimelineLayersFromDOM,
7
7
  mergeTimelineElementsPreservingDowngrades,
8
8
  } from "./timelineDOM";
9
+ import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
+ import { invalidateGroupInfoCache } from "./timelineGroupInfo";
9
11
  import type { TimelineElement } from "../store/playerStore";
10
12
 
11
13
  function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
@@ -110,6 +112,92 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
110
112
  });
111
113
  });
112
114
 
115
+ describe("group info cache", () => {
116
+ const parseMember = (doc: Document) =>
117
+ createTimelineElementFromManifestClip({
118
+ clip: {
119
+ id: "voice-1",
120
+ label: "voice-1",
121
+ kind: "element",
122
+ tagName: "audio",
123
+ start: 0,
124
+ duration: 5,
125
+ track: 0,
126
+ compositionId: null,
127
+ parentCompositionId: null,
128
+ compositionSrc: null,
129
+ assetUrl: null,
130
+ },
131
+ fallbackIndex: 0,
132
+ doc,
133
+ hostEl: doc.getElementById("voice-1"),
134
+ });
135
+
136
+ // Group edits are applied as LIVE patches so the preview iframe never
137
+ // reloads, which means the document identity this cache is keyed on never
138
+ // changes either. Without an explicit drop, a muted group could never be
139
+ // unmuted: the header kept reading the cached `hidden: false` and re-wrote
140
+ // `data-hidden` forever.
141
+ it("re-reads group state after an invalidation", () => {
142
+ const doc = makeDoc(`
143
+ <div data-composition-id="root">
144
+ <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
145
+ <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
146
+ </div>
147
+ `);
148
+
149
+ expect(parseMember(doc).audioGroupHidden).toBe(false);
150
+
151
+ doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
152
+ expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
153
+
154
+ invalidateGroupInfoCache(doc);
155
+ expect(parseMember(doc).audioGroupHidden).toBe(true);
156
+
157
+ doc.getElementById("voiceover")?.removeAttribute("data-hidden");
158
+ invalidateGroupInfoCache(doc);
159
+ expect(parseMember(doc).audioGroupHidden).toBe(false);
160
+ });
161
+
162
+ // The explicit invalidator is a convenience, not the contract. A cache whose
163
+ // only defence is "every writer must remember to call this" rots the first
164
+ // time a writer does not know it exists — which is precisely what happened
165
+ // with the FX rack, whose group writes go through the DOM editor rather than
166
+ // the timeline's own writers. The scan carries the DOM revision it was taken
167
+ // at, so a forgotten call costs a re-scan rather than a wrong answer.
168
+ it("expires itself on a group edit nobody announced", async () => {
169
+ const doc = makeDoc(`
170
+ <div data-composition-id="root">
171
+ <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
172
+ <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
173
+ </div>
174
+ `);
175
+
176
+ expect(parseMember(doc).audioGroupHidden).toBe(false);
177
+
178
+ // No invalidateGroupInfoCache call anywhere in this test.
179
+ doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
180
+ await new Promise((resolve) => setTimeout(resolve, 0)); // observer microtask
181
+
182
+ expect(parseMember(doc).audioGroupHidden).toBe(true);
183
+ });
184
+
185
+ it("notices a member joining the group, not just an attribute edit", async () => {
186
+ const doc = makeDoc(`
187
+ <div data-composition-id="root">
188
+ <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
189
+ <hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
190
+ </div>
191
+ `);
192
+ expect(parseMember(doc).audioGroupLabel).toBe("Voices");
193
+
194
+ doc.getElementById("voiceover")?.setAttribute("data-label", "Narration");
195
+ await new Promise((resolve) => setTimeout(resolve, 0));
196
+
197
+ expect(parseMember(doc).audioGroupLabel).toBe("Narration");
198
+ });
199
+ });
200
+
113
201
  describe("parseTimelineFromDOM — canonical playback rate", () => {
114
202
  it.each([
115
203
  ["10", 5],
@@ -207,6 +295,33 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
207
295
  });
208
296
  });
209
297
 
298
+ // Caught by looking at the studio, not by reading: a grouped composition drew
299
+ // "Voiceover • 0.0s – 12.0s" as a full-duration clip row directly above its own
300
+ // group header. `<hf-audio-group>` is a mixer bus — no timing, drawn as a group
301
+ // row by the group derivation — but it is still a body child with an id, so the
302
+ // implicit-layer fallback happily gave it a track. Draggable and trimmable, and
303
+ // writing timing onto a bus means nothing.
304
+ describe("<hf-audio-group> is not a timeline layer", () => {
305
+ it("gets no implicit row of its own", () => {
306
+ const doc = makeDoc(`
307
+ <div data-composition-id="root">
308
+ <audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio>
309
+ <hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
310
+ </div>
311
+ `);
312
+
313
+ const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []);
314
+
315
+ expect(implicit.map((el) => el.domId)).not.toContain("voiceover");
316
+ });
317
+
318
+ it("is excluded by the shared ignore predicate", () => {
319
+ const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`);
320
+ expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true);
321
+ expect(isTimelineIgnoredElement(doc.getElementById("panel") as Element)).toBe(false);
322
+ });
323
+ });
324
+
210
325
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
211
326
  it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
212
327
  const doc = makeDoc(`