@hyperframes/studio 0.8.5 → 0.8.7

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-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10337 -7200
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
  142. package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
  143. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  144. package/src/player/lib/timelineDOM.ts +51 -0
  145. package/src/player/store/audioSoloSlice.test.ts +113 -0
  146. package/src/player/store/audioSoloSlice.ts +43 -0
  147. package/src/player/store/editingModeSlice.ts +45 -0
  148. package/src/player/store/groupLevels.ts +33 -0
  149. package/src/player/store/keyframeSlice.ts +26 -0
  150. package/src/player/store/playerStore.ts +21 -22
  151. package/src/player/store/timelineElement.ts +10 -0
  152. package/src/telemetry/events.ts +12 -0
  153. package/dist/assets/index-Cx_UrhoW.js +0 -428
  154. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
8
8
  import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
9
9
  import {
10
+ STRIP_H,
10
11
  TRACK_H,
11
12
  createTimelineRowGeometry,
12
13
  type TimelineRowGeometry,
13
14
  trackHeights,
14
15
  type TimelineTrackHeightClip,
15
16
  } from "./timelineLayout";
17
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
16
18
 
17
19
  export { getTrackStyle } from "./timelineIcons";
18
20
 
@@ -127,13 +129,35 @@ function computeLaneCounts(
127
129
  return laneCounts;
128
130
  }
129
131
 
132
+ /** Group anchor rows have no elements of their own (`groupTimelineTracks`
133
+ * pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
134
+ * looks at a row's clips — always gives them TRACK_H. Override those
135
+ * specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
136
+ * group's own `∿` (bus strip) is open. */
137
+ function applyGroupStripHeights(
138
+ tracks: readonly (readonly [number, readonly TimelineElement[]])[],
139
+ rowHeights: number[],
140
+ groups: readonly TimelineTrackGroupInfo[],
141
+ expandedLaneOwnerIds: ReadonlySet<string>,
142
+ ): number[] {
143
+ if (groups.length === 0) return rowHeights;
144
+ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
145
+ return tracks.map(([track], index) => {
146
+ const group = groupByAnchor.get(track);
147
+ if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
148
+ return TRACK_H + STRIP_H;
149
+ });
150
+ }
151
+
130
152
  function useTimelineRowHeights(
131
153
  tracks: [number, TimelineElement[]][],
132
154
  gsapAnimations: Map<string, GsapAnimation[]>,
133
155
  selectedElementId: string | null,
134
156
  selectedElementIds: ReadonlySet<string>,
157
+ groups: readonly TimelineTrackGroupInfo[],
135
158
  ) {
136
159
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
160
+ const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
137
161
  const { laneCounts, rowGeometry } = useMemo(() => {
138
162
  const laneCounts = computeLaneCounts(tracks, gsapAnimations);
139
163
  // Keyframe lanes follow only the active clip, so a track with several
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
163
187
  },
164
188
  ];
165
189
  });
166
- const rowHeights = trackHeights(heightTracks, expandedClipIds);
190
+ const rowHeights = applyGroupStripHeights(
191
+ tracks,
192
+ trackHeights(heightTracks, expandedClipIds),
193
+ groups,
194
+ expandedLaneOwnerIds,
195
+ );
167
196
  return {
168
197
  laneCounts,
169
198
  rowGeometry: createTimelineRowGeometry(
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
171
200
  rowHeights,
172
201
  ),
173
202
  };
174
- }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
203
+ }, [
204
+ expandedClipIds,
205
+ expandedLaneOwnerIds,
206
+ gsapAnimations,
207
+ groups,
208
+ tracks,
209
+ selectedElementId,
210
+ selectedElementIds,
211
+ ]);
175
212
  const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
176
213
  rowGeometryRef.current = rowGeometry;
177
214
  return {
@@ -188,7 +225,8 @@ export function useTimelineTrackLayout(
188
225
  selectedElementId: string | null,
189
226
  selectedElementIds: ReadonlySet<string>,
190
227
  ) {
191
- const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
228
+ const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
229
+ useTimelineTrackDerivations(expandedElements);
192
230
  const trackOrderRef = useRef(trackOrder);
193
231
  trackOrderRef.current = trackOrder;
194
232
  const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
@@ -196,6 +234,7 @@ export function useTimelineTrackLayout(
196
234
  gsapAnimations,
197
235
  selectedElementId,
198
236
  selectedElementIds,
237
+ groups,
199
238
  );
200
239
 
201
240
  return {
@@ -207,6 +246,8 @@ export function useTimelineTrackLayout(
207
246
  rowGeometry,
208
247
  rowGeometryRef,
209
248
  rowHeights,
249
+ groups,
250
+ trackGroupOf,
210
251
  };
211
252
  }
212
253
 
@@ -227,7 +268,23 @@ function useDisplayRowHeights(
227
268
  function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
228
269
  return useMemo(() => {
229
270
  if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
230
- return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
271
+ // A group's members sit out of raw numeric order (pulled under their
272
+ // anchor row), so a plain numeric sort here would undo that grouping the
273
+ // moment a clip drags onto a brand-new track. Insert the new preview
274
+ // track only relative to other REAL (integer) tracks, leaving any
275
+ // fractional group-anchor keys exactly where grouping placed them.
276
+ const preview = draggedClip.previewTrack;
277
+ const result: number[] = [];
278
+ let inserted = false;
279
+ for (const key of trackOrder) {
280
+ if (!inserted && Number.isInteger(key) && key > preview) {
281
+ result.push(preview);
282
+ inserted = true;
283
+ }
284
+ result.push(key);
285
+ }
286
+ if (!inserted) result.push(preview);
287
+ return result;
231
288
  }, [draggedClip, trackOrder]);
232
289
  }
233
290
 
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The `window.message` router for the preview iframe.
3
+ *
4
+ * Extracted from `useTimelinePlayer`, which had grown past the studio's 600-line
5
+ * file cap and carried a `fallow-ignore-next-line complexity` on this function
6
+ * admitting the same thing. Nothing here is new logic — it is the same four
7
+ * branches (accept-gate, group levels, state, timeline) against the same refs,
8
+ * with the suppression retired rather than moved.
9
+ */
10
+
11
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
12
+ import type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
13
+ import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
14
+ import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
15
+ import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
16
+ import { parseTimelineFromDOM } from "../lib/timelineDOM";
17
+
18
+ /** What `processTimelineMessage` accepts — the clip-manifest postMessage. */
19
+ export interface ClipManifestMessage {
20
+ clips: ClipManifestClip[];
21
+ durationInFrames: number;
22
+ scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
23
+ protocolVersion?: unknown;
24
+ capabilities?: unknown;
25
+ fps?: unknown;
26
+ }
27
+
28
+ interface PreviewMessage {
29
+ source?: string;
30
+ type?: string;
31
+ clips?: unknown;
32
+ }
33
+
34
+ export interface PreviewMessageRouterDeps {
35
+ iframeRef: { current: HTMLIFrameElement | null };
36
+ /** Kept as refs by the caller so the listener can be registered once. */
37
+ processTimelineMessageRef: { current: (data: ClipManifestMessage) => void };
38
+ enrichMissingCompositionsRef: { current: () => void };
39
+ lastTimelineMessageRef: { current: number };
40
+ getAdapter: () => PlaybackAdapter | null;
41
+ syncTimelineElements: (elements: TimelineElement[]) => void;
42
+ }
43
+
44
+ /** True when the message did not come from OUR preview iframe. */
45
+ function isForeignSource(e: MessageEvent, iframe: HTMLIFrameElement | null): boolean {
46
+ return Boolean(e.source && iframe && e.source !== iframe.contentWindow);
47
+ }
48
+
49
+ /** A preview message worth acting on, or null: the fixture lease, the sender
50
+ * check and the protocol accept-gate collapsed into one answer so the listener
51
+ * below stays a flat dispatch. */
52
+ function acceptedPreviewMessage(
53
+ e: MessageEvent,
54
+ iframe: HTMLIFrameElement | null,
55
+ ): PreviewMessage | null {
56
+ if (hasTimelinePerformanceFixtureLease()) return null;
57
+ if (isForeignSource(e, iframe)) return null;
58
+ const data = e.data as PreviewMessage | null;
59
+ if (data?.source !== "hf-preview") return null;
60
+ return acceptStudioRuntimeMessage(data) ? data : null;
61
+ }
62
+
63
+ /** One meter reading per group with an active member. */
64
+ function handleGroupLevelsMessage(data: PreviewMessage): void {
65
+ const levels = parseGroupLevelsMessage(data);
66
+ if (levels) groupLevels.notify(levels);
67
+ }
68
+
69
+ /**
70
+ * A `state` tick doubles as a recovery hook: if the store still has no
71
+ * elements, read the manifest straight off the iframe, and if no `timeline`
72
+ * message has arrived recently, re-run enrichment.
73
+ */
74
+ function handleStateMessage(
75
+ deps: PreviewMessageRouterDeps,
76
+ iframe: HTMLIFrameElement | null,
77
+ ): void {
78
+ try {
79
+ if (usePlayerStore.getState().elements.length === 0) {
80
+ const manifest = (iframe?.contentWindow as IframeWindow | null)?.__clipManifest;
81
+ if (manifest && manifest.clips.length > 0) {
82
+ deps.processTimelineMessageRef.current(manifest);
83
+ }
84
+ }
85
+ if (Date.now() - deps.lastTimelineMessageRef.current > 500) {
86
+ deps.enrichMissingCompositionsRef.current();
87
+ }
88
+ } catch {}
89
+ }
90
+
91
+ /**
92
+ * The clip list. When it arrives empty-handed — the store still has nothing —
93
+ * fall back to parsing the live DOM, which is the only source left before the
94
+ * next tick.
95
+ */
96
+ function handleTimelineMessage(
97
+ deps: PreviewMessageRouterDeps,
98
+ iframe: HTMLIFrameElement | null,
99
+ data: ClipManifestMessage,
100
+ ): void {
101
+ deps.lastTimelineMessageRef.current = Date.now();
102
+ deps.processTimelineMessageRef.current(data);
103
+ deps.enrichMissingCompositionsRef.current();
104
+ if (usePlayerStore.getState().elements.length > 0) return;
105
+ try {
106
+ const doc = iframe?.contentDocument;
107
+ const adapter = deps.getAdapter();
108
+ if (!doc || !adapter) return;
109
+ const els = parseTimelineFromDOM(doc, adapter.getDuration());
110
+ if (els.length > 0) deps.syncTimelineElements(els);
111
+ } catch {}
112
+ }
113
+
114
+ export function createPreviewMessageHandler(
115
+ deps: PreviewMessageRouterDeps,
116
+ ): (e: MessageEvent) => void {
117
+ return (e: MessageEvent) => {
118
+ const iframe = deps.iframeRef.current;
119
+ const data = acceptedPreviewMessage(e, iframe);
120
+ if (!data) return;
121
+ if (data.type === "group-levels") return handleGroupLevelsMessage(data);
122
+ if (data.type === "state") return handleStateMessage(deps, iframe);
123
+ if (data.type === "timeline" && Array.isArray(data.clips)) {
124
+ handleTimelineMessage(deps, iframe, data as unknown as ClipManifestMessage);
125
+ }
126
+ };
127
+ }
@@ -1,12 +1,19 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useEffect } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
2
6
  import {
3
7
  buildExpandedElements,
4
8
  resolveTimelineExpansionRawId,
9
+ useExpandedTimelineElements,
5
10
  } from "./useExpandedTimelineElements";
6
11
  import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
7
- import type { TimelineElement } from "../store/playerStore";
12
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
13
  import type { ClipManifestClip } from "../lib/playbackTypes";
9
14
 
15
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
16
+
10
17
  const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
11
18
  id: "x",
12
19
  label: "x",
@@ -31,6 +38,19 @@ const el = (over: Partial<TimelineElement>): TimelineElement => ({
31
38
  ...over,
32
39
  });
33
40
 
41
+ function TimelineExpansionHarness({ onValue }: { onValue: (value: TimelineElement[]) => void }) {
42
+ const value = useExpandedTimelineElements();
43
+ useEffect(() => {
44
+ onValue(value);
45
+ }, [onValue, value]);
46
+ return null;
47
+ }
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = "";
51
+ usePlayerStore.getState().reset();
52
+ });
53
+
34
54
  describe("buildExpandedElements", () => {
35
55
  it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
36
56
  // host s3 at absolute 16 → stats-panel.html; children live in that file.
@@ -357,7 +377,7 @@ describe("buildExpandedElements", () => {
357
377
  it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
358
378
  // Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
359
379
  // seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
360
- // so the whole lane went with the host row whenever the paused drill-in
380
+ // so the whole lane went with the host row whenever the store-time drill-in
361
381
  // substituted it for its children.
362
382
  const elements = [
363
383
  el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
@@ -371,7 +391,6 @@ describe("buildExpandedElements", () => {
371
391
  for (const currentTime of [0, 7.68, 0.2]) {
372
392
  const rawId = resolveTimelineExpansionRawId({
373
393
  selectedElementId: null,
374
- isPlaying: false,
375
394
  currentTime,
376
395
  manifest,
377
396
  parentMap,
@@ -424,13 +443,12 @@ describe("buildExpandedElements", () => {
424
443
  });
425
444
 
426
445
  describe("resolveTimelineExpansionRawId", () => {
427
- it("returns null when paused inside a childless top-level clip", () => {
446
+ it("returns null inside a childless top-level clip", () => {
428
447
  const manifest = [clip({ id: "title", start: 0, duration: 4 })];
429
448
 
430
449
  expect(
431
450
  resolveTimelineExpansionRawId({
432
451
  selectedElementId: null,
433
- isPlaying: false,
434
452
  currentTime: 2,
435
453
  manifest,
436
454
  parentMap: new Map(),
@@ -438,7 +456,7 @@ describe("resolveTimelineExpansionRawId", () => {
438
456
  ).toBeNull();
439
457
  });
440
458
 
441
- it("auto-expands an active composition with children when paused and nothing is selected", () => {
459
+ it("auto-expands an active composition with children when nothing is selected", () => {
442
460
  const manifest = [
443
461
  clip({ id: "scene", start: 1, duration: 5 }),
444
462
  clip({ id: "headline", start: 1.5, duration: 2 }),
@@ -448,7 +466,6 @@ describe("resolveTimelineExpansionRawId", () => {
448
466
  expect(
449
467
  resolveTimelineExpansionRawId({
450
468
  selectedElementId: null,
451
- isPlaying: false,
452
469
  currentTime: 2,
453
470
  manifest,
454
471
  parentMap,
@@ -468,7 +485,6 @@ describe("resolveTimelineExpansionRawId", () => {
468
485
  expect(
469
486
  resolveTimelineExpansionRawId({
470
487
  selectedElementId: null,
471
- isPlaying: false,
472
488
  currentTime: 12,
473
489
  manifest,
474
490
  parentMap,
@@ -491,7 +507,6 @@ describe("resolveTimelineExpansionRawId", () => {
491
507
  expect(
492
508
  resolveTimelineExpansionRawId({
493
509
  selectedElementId: null,
494
- isPlaying: false,
495
510
  currentTime: 5,
496
511
  manifest,
497
512
  parentMap,
@@ -499,7 +514,7 @@ describe("resolveTimelineExpansionRawId", () => {
499
514
  ).toBe("second");
500
515
  });
501
516
 
502
- it("auto-expands the innermost active nested composition when paused", () => {
517
+ it("auto-expands the innermost active nested composition", () => {
503
518
  const manifest = [
504
519
  clip({ id: "outer", start: 0, duration: 10 }),
505
520
  clip({ id: "inner", start: 2, duration: 5 }),
@@ -513,7 +528,6 @@ describe("resolveTimelineExpansionRawId", () => {
513
528
  expect(
514
529
  resolveTimelineExpansionRawId({
515
530
  selectedElementId: null,
516
- isPlaying: false,
517
531
  currentTime: 3.5,
518
532
  manifest,
519
533
  parentMap,
@@ -521,7 +535,7 @@ describe("resolveTimelineExpansionRawId", () => {
521
535
  ).toBe("inner");
522
536
  });
523
537
 
524
- it("does not auto-expand an active composition while playing", () => {
538
+ it("resolves an active composition from the current store time", () => {
525
539
  const manifest = [
526
540
  clip({ id: "scene", start: 0, duration: 5 }),
527
541
  clip({ id: "headline", start: 1, duration: 2 }),
@@ -531,15 +545,49 @@ describe("resolveTimelineExpansionRawId", () => {
531
545
  expect(
532
546
  resolveTimelineExpansionRawId({
533
547
  selectedElementId: null,
534
- isPlaying: true,
535
548
  currentTime: 2,
536
549
  manifest,
537
550
  parentMap,
538
551
  }),
539
- ).toBeNull();
552
+ ).toBe("scene");
553
+ });
554
+
555
+ it("keeps inline children visible when the master timeline is playing", () => {
556
+ const elements = [
557
+ el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 5 }),
558
+ ];
559
+ const manifest = [
560
+ clip({ id: "scene", start: 0, duration: 5, compositionSrc: "scene.html" }),
561
+ clip({ id: "headline", start: 1, duration: 2 }),
562
+ ];
563
+ const parentMap = new Map([["headline", "scene"]]);
564
+ let rows: TimelineElement[] | undefined;
565
+ const host = document.createElement("div");
566
+ document.body.append(host);
567
+ const root = createRoot(host);
568
+
569
+ act(() => {
570
+ usePlayerStore.setState({
571
+ elements,
572
+ clipManifest: manifest,
573
+ clipParentMap: parentMap,
574
+ currentTime: 2,
575
+ isPlaying: true,
576
+ });
577
+ root.render(
578
+ React.createElement(TimelineExpansionHarness, {
579
+ onValue: (value) => (rows = value),
580
+ }),
581
+ );
582
+ });
583
+
584
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
585
+ expect(rows?.map((row) => row.domId ?? row.id)).toEqual(["scene", "headline"]);
586
+
587
+ act(() => root.unmount());
540
588
  });
541
589
 
542
- it("keeps selected elements ahead of paused active composition auto-expansion", () => {
590
+ it("keeps selected elements ahead of active composition auto-expansion", () => {
543
591
  const manifest = [
544
592
  clip({ id: "scene", start: 0, duration: 6 }),
545
593
  clip({ id: "headline", start: 1, duration: 2 }),
@@ -553,7 +601,6 @@ describe("resolveTimelineExpansionRawId", () => {
553
601
  expect(
554
602
  resolveTimelineExpansionRawId({
555
603
  selectedElementId: "caption",
556
- isPlaying: false,
557
604
  currentTime: 1.5,
558
605
  manifest,
559
606
  parentMap,
@@ -35,7 +35,6 @@ function resolveRawId(
35
35
 
36
36
  interface TimelineExpansionRawIdInput {
37
37
  selectedElementId: string | null;
38
- isPlaying: boolean;
39
38
  currentTime: number;
40
39
  manifest: ClipManifestClip[];
41
40
  parentMap: Map<string, string>;
@@ -97,14 +96,12 @@ function findActiveExpandableCompositionId(
97
96
 
98
97
  export function resolveTimelineExpansionRawId({
99
98
  selectedElementId,
100
- isPlaying,
101
99
  currentTime,
102
100
  manifest,
103
101
  parentMap,
104
102
  }: TimelineExpansionRawIdInput): string | null {
105
103
  const selectedRawId = resolveRawId(selectedElementId, manifest, parentMap);
106
104
  if (selectedRawId) return selectedRawId;
107
- if (isPlaying) return null;
108
105
  return findActiveExpandableCompositionId(currentTime, manifest, parentMap);
109
106
  }
110
107
 
@@ -334,7 +331,7 @@ export function buildExpandedElements(
334
331
  : (el.key ?? el.id) === parentKey;
335
332
 
336
333
  // ADDITIVE drill-in: the host row stays and its children are appended under
337
- // it. Expansion is also triggered by the playhead alone (paused auto-expand),
334
+ // it. Expansion is also triggered by the playhead alone (active auto-expand),
338
335
  // so substituting the host row made it vanish on an ordinary seek, and with
339
336
  // it the host's keyframe lane, since diamonds render per row from
340
337
  // `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
@@ -356,13 +353,14 @@ export function useExpandedTimelineElements(): TimelineElement[] {
356
353
  const clipParentMap = usePlayerStore((s) => s.clipParentMap);
357
354
  const domClipChildren = usePlayerStore((s) => s.domClipChildren);
358
355
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
359
- const isPlaying = usePlayerStore((s) => s.isPlaying);
360
356
  const currentTime = usePlayerStore((s) => s.currentTime);
361
357
 
362
- // Resolve which raw clip drives expansion. This reads currentTime (for paused
363
- // auto-expand) so it re-runs each scrub tick, but it's a cheap manifest scan and
364
- // its RESULT only changes when the playhead crosses a composition boundary. Keying
365
- // the expensive build below on these ids (not raw currentTime) avoids re-allocating
358
+ // Resolve which raw clip drives expansion from the store's committed playhead
359
+ // time. The RAF loop keeps live time out of React and Zustand during playback,
360
+ // so this target deliberately stays stable until the loop commits or a seek /
361
+ // pause updates the store. The scan re-runs on scrub ticks, but its RESULT only
362
+ // changes when the playhead crosses a composition boundary. Keying the expensive
363
+ // build below on these ids (not raw currentTime) avoids re-allocating
366
364
  // expandedElements — and cascading TimelineClip re-renders — on every tick.
367
365
  const { rawId, selectedRawId } = useMemo(() => {
368
366
  if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
@@ -371,14 +369,13 @@ export function useExpandedTimelineElements(): TimelineElement[] {
371
369
  return {
372
370
  rawId: resolveTimelineExpansionRawId({
373
371
  selectedElementId,
374
- isPlaying,
375
372
  currentTime,
376
373
  manifest: clipManifest,
377
374
  parentMap: clipParentMap,
378
375
  }),
379
376
  selectedRawId: resolveRawId(selectedElementId, clipManifest, clipParentMap),
380
377
  };
381
- }, [clipManifest, clipParentMap, selectedElementId, isPlaying, currentTime]);
378
+ }, [clipManifest, clipParentMap, selectedElementId, currentTime]);
382
379
 
383
380
  return useMemo(() => {
384
381
  if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
@@ -35,7 +35,6 @@ import {
35
35
  import {
36
36
  readTimelineDurationFromDocument,
37
37
  mergeTimelineElementsPreservingDowngrades,
38
- parseTimelineFromDOM,
39
38
  } from "../lib/timelineDOM";
40
39
  import { normalizeToZones } from "../components/timelineZones";
41
40
  import {
@@ -48,7 +47,7 @@ import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFi
48
47
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
49
48
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
50
49
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
51
- import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
50
+ import { createPreviewMessageHandler } from "./previewMessageRouter";
52
51
  import { timelineElementsChanged } from "./timelinePlayerSync";
53
52
 
54
53
  export function useTimelinePlayer() {
@@ -488,51 +487,14 @@ export function useTimelinePlayer() {
488
487
  const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
489
488
  const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
490
489
 
491
- // Pre-existing message-router complexity — surfaced by line shifts, not new logic.
492
- // fallow-ignore-next-line complexity
493
- const handleMessage = (e: MessageEvent) => {
494
- if (hasTimelinePerformanceFixtureLease()) return;
495
- const data = e.data;
496
- const ourIframe = iframeRef.current;
497
- if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
498
- return;
499
- }
500
- if (data?.source === "hf-preview") {
501
- if (!acceptStudioRuntimeMessage(data)) return;
502
- }
503
- if (data?.source === "hf-preview" && data?.type === "state") {
504
- try {
505
- if (usePlayerStore.getState().elements.length === 0) {
506
- const iframeWin = ourIframe?.contentWindow as IframeWindow | null;
507
- const manifest = iframeWin?.__clipManifest;
508
- if (manifest && manifest.clips.length > 0) {
509
- processTimelineMessageRef.current(manifest);
510
- }
511
- }
512
- const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
513
- if (msSinceTimeline > 500) {
514
- enrichMissingCompositionsRef.current();
515
- }
516
- } catch {}
517
- }
518
- if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
519
- lastTimelineMessageRef.current = Date.now();
520
- processTimelineMessageRef.current(data);
521
- enrichMissingCompositionsRef.current();
522
- if (usePlayerStore.getState().elements.length === 0) {
523
- try {
524
- const doc = ourIframe?.contentDocument;
525
- const adapter = getAdapter();
526
- if (doc && adapter) {
527
- const els = parseTimelineFromDOM(doc, adapter.getDuration());
528
- if (els.length > 0) {
529
- syncTimelineElements(els);
530
- }
531
- }
532
- } catch {}
533
- }
534
- }
535
- };
490
+ const handleMessage = createPreviewMessageHandler({
491
+ iframeRef,
492
+ processTimelineMessageRef,
493
+ enrichMissingCompositionsRef,
494
+ lastTimelineMessageRef,
495
+ getAdapter,
496
+ syncTimelineElements,
497
+ });
536
498
 
537
499
  const handleVisibilityChange = () => {
538
500
  if (document.hidden && usePlayerStore.getState().isPlaying) {
@@ -12,6 +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
16
  import {
16
17
  resolveMediaElement,
17
18
  applyMediaMetadataFromElement,
@@ -67,6 +68,37 @@ function resolveClipTag(clip: ClipManifestClip): string {
67
68
  return clip.tagName || clip.kind || "div";
68
69
  }
69
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
+
70
102
  // fallow-ignore-next-line complexity
71
103
  export function createTimelineElementFromManifestClip(params: {
72
104
  clip: ClipManifestClip;
@@ -138,6 +170,15 @@ export function createTimelineElementFromManifestClip(params: {
138
170
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
139
171
  const timelineRole = hostEl.getAttribute("data-timeline-role");
140
172
  if (timelineRole) entry.timelineRole = timelineRole;
173
+ const audioGroup = hostEl.getAttribute("data-audio-group");
174
+ if (audioGroup) {
175
+ entry.audioGroup = audioGroup;
176
+ const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
177
+ entry.audioGroupLabel = info.label;
178
+ entry.audioGroupVolume = info.volume;
179
+ entry.audioGroupHidden = info.hidden;
180
+ if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
181
+ }
141
182
  const fxChain = hostEl.getAttribute("data-fx-chain");
142
183
  if (fxChain) entry.fxChain = fxChain;
143
184
  const automation = hostEl.getAttribute("data-automation");
@@ -356,6 +397,16 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
356
397
  const timelineRole = el.getAttribute("data-timeline-role");
357
398
  if (timelineRole) entry.timelineRole = timelineRole;
358
399
 
400
+ const domAudioGroup = el.getAttribute("data-audio-group");
401
+ if (domAudioGroup) {
402
+ entry.audioGroup = domAudioGroup;
403
+ const domGroupInfo = groupInfoFor(doc, domAudioGroup);
404
+ entry.audioGroupLabel = domGroupInfo.label;
405
+ entry.audioGroupVolume = domGroupInfo.volume;
406
+ entry.audioGroupHidden = domGroupInfo.hidden;
407
+ if (domGroupInfo.fxChain) entry.audioGroupFxChain = domGroupInfo.fxChain;
408
+ }
409
+
359
410
  // Sub-compositions
360
411
  const compSrc =
361
412
  el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");