@hyperframes/studio 0.8.6 → 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.
- package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10336 -7195
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +2 -0
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +7 -7
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +5 -0
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.ts +150 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- 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 =
|
|
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
|
-
}, [
|
|
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 } =
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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");
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { usePlayerStore } from "./playerStore";
|
|
5
|
+
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "./audioSoloSlice";
|
|
6
|
+
import * as studioFileHistory from "../../utils/studioFileHistory";
|
|
7
|
+
|
|
8
|
+
describe("audioSoloSlice", () => {
|
|
9
|
+
it("is exclusive by default, and clicking the only soloed element again clears it", () => {
|
|
10
|
+
usePlayerStore.getState().toggleSolo("a");
|
|
11
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a"]));
|
|
12
|
+
usePlayerStore.getState().toggleSolo("b");
|
|
13
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
|
14
|
+
usePlayerStore.getState().toggleSolo("b");
|
|
15
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("⌘/Ctrl-click adds and removes membership without disturbing the rest", () => {
|
|
19
|
+
usePlayerStore.getState().toggleSolo("a");
|
|
20
|
+
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
21
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set(["a", "b"]));
|
|
22
|
+
usePlayerStore.getState().toggleSolo("a", { add: true });
|
|
23
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set(["b"]));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("clearSolo empties the set", () => {
|
|
27
|
+
usePlayerStore.getState().toggleSolo("a");
|
|
28
|
+
usePlayerStore.getState().clearSolo();
|
|
29
|
+
expect(usePlayerStore.getState().soloed).toEqual(new Set());
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("export-safety: solo never touches an attribute or the save path", () => {
|
|
34
|
+
it("toggling, ⌘-adding, and clearing solo never calls setAttribute/removeAttribute on any element", () => {
|
|
35
|
+
usePlayerStore.getState().clearSolo();
|
|
36
|
+
const setAttributeSpy = vi.spyOn(Element.prototype, "setAttribute");
|
|
37
|
+
const removeAttributeSpy = vi.spyOn(Element.prototype, "removeAttribute");
|
|
38
|
+
|
|
39
|
+
usePlayerStore.getState().toggleSolo("a");
|
|
40
|
+
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
41
|
+
usePlayerStore.getState().toggleSolo("a", { add: true });
|
|
42
|
+
usePlayerStore.getState().clearSolo();
|
|
43
|
+
|
|
44
|
+
expect(setAttributeSpy).not.toHaveBeenCalled();
|
|
45
|
+
expect(removeAttributeSpy).not.toHaveBeenCalled();
|
|
46
|
+
setAttributeSpy.mockRestore();
|
|
47
|
+
removeAttributeSpy.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("toggling, ⌘-adding, and clearing solo never invokes the project save path (setElementsHidden's own write function)", () => {
|
|
51
|
+
usePlayerStore.getState().clearSolo();
|
|
52
|
+
const saveSpy = vi.spyOn(studioFileHistory, "saveProjectFilesWithHistory");
|
|
53
|
+
|
|
54
|
+
usePlayerStore.getState().toggleSolo("a");
|
|
55
|
+
usePlayerStore.getState().toggleSolo("b", { add: true });
|
|
56
|
+
usePlayerStore.getState().clearSolo();
|
|
57
|
+
|
|
58
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
59
|
+
saveSpy.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("isAudibleUnderSolo — the three-bullet rule", () => {
|
|
64
|
+
it("everything is audible when no solo is active", () => {
|
|
65
|
+
expect(isAudibleUnderSolo(new Set(), "clip-a")).toBe(true);
|
|
66
|
+
expect(isAudibleUnderSolo(new Set(), "clip-a", "group-1")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("a soloed element is audible", () => {
|
|
70
|
+
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-a")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("a sibling of a soloed element is silent", () => {
|
|
74
|
+
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("a member of a soloed group is audible (group solo = members solo)", () => {
|
|
78
|
+
expect(isAudibleUnderSolo(new Set(["group-1"]), "clip-a", "group-1")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a member of an UNsoloed group, while a sibling group is soloed, is silent", () => {
|
|
82
|
+
expect(isAudibleUnderSolo(new Set(["group-2"]), "clip-a", "group-1")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("soloing one member of a group does not un-attenuate its siblings in the same group", () => {
|
|
86
|
+
// clip-a is soloed directly; clip-b shares its group but is not itself
|
|
87
|
+
// soloed and the group itself is not soloed — clip-b stays silent.
|
|
88
|
+
expect(isAudibleUnderSolo(new Set(["clip-a"]), "clip-b", "group-1")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("the group itself, monitored as a bus, is audible only when it or a member is soloed", () => {
|
|
92
|
+
expect(isAudibleUnderSolo(new Set(["clip-a"]), "group-1", null)).toBe(false);
|
|
93
|
+
expect(isAudibleUnderSolo(new Set(["group-1"]), "group-1", null)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("isGroupHalfLitUnderSolo", () => {
|
|
98
|
+
it("is false when no solo is active", () => {
|
|
99
|
+
expect(isGroupHalfLitUnderSolo(new Set(), "group-1", ["a", "b"])).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("is false when the group itself is soloed (fully lit, not half)", () => {
|
|
103
|
+
expect(isGroupHalfLitUnderSolo(new Set(["group-1"]), "group-1", ["a", "b"])).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("is true when a member is soloed but the group is not", () => {
|
|
107
|
+
expect(isGroupHalfLitUnderSolo(new Set(["a"]), "group-1", ["a", "b"])).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("is false when an unrelated element is soloed", () => {
|
|
111
|
+
expect(isGroupHalfLitUnderSolo(new Set(["other"]), "group-1", ["a", "b"])).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Hear only this" — session-only monitoring override, never persisted.
|
|
3
|
+
*
|
|
4
|
+
* Holds clip ids and group ids (never track numbers, per the design doc: solo
|
|
5
|
+
* is a per-element/per-group concept, not a row-position one). Exclusive by
|
|
6
|
+
* default (a plain toggle replaces the set); ⌘/Ctrl-click adds/removes one
|
|
7
|
+
* member without disturbing the rest. Never written to any attribute or
|
|
8
|
+
* document — the export-safety guarantee this slice exists to hold.
|
|
9
|
+
*/
|
|
10
|
+
import type { StoreApi } from "zustand";
|
|
11
|
+
import { isAudibleUnderSolo, isGroupHalfLitUnderSolo } from "@hyperframes/core/audio-groups";
|
|
12
|
+
|
|
13
|
+
export { isAudibleUnderSolo, isGroupHalfLitUnderSolo };
|
|
14
|
+
|
|
15
|
+
export interface AudioSoloSlice {
|
|
16
|
+
soloed: ReadonlySet<string>;
|
|
17
|
+
toggleSolo: (id: string, options?: { add?: boolean }) => void;
|
|
18
|
+
clearSolo: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createAudioSoloSlice(
|
|
22
|
+
set: StoreApi<AudioSoloSlice>["setState"],
|
|
23
|
+
get: StoreApi<AudioSoloSlice>["getState"],
|
|
24
|
+
): AudioSoloSlice {
|
|
25
|
+
return {
|
|
26
|
+
soloed: new Set(),
|
|
27
|
+
toggleSolo: (id, options) => {
|
|
28
|
+
const current = get().soloed;
|
|
29
|
+
if (options?.add) {
|
|
30
|
+
const next = new Set(current);
|
|
31
|
+
if (next.has(id)) next.delete(id);
|
|
32
|
+
else next.add(id);
|
|
33
|
+
set({ soloed: next });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Exclusive click: soloing the only-soloed element again clears it;
|
|
37
|
+
// otherwise it replaces the set.
|
|
38
|
+
const isOnlyMember = current.size === 1 && current.has(id);
|
|
39
|
+
set({ soloed: isOnlyMember ? new Set() : new Set([id]) });
|
|
40
|
+
},
|
|
41
|
+
clearSolo: () => set({ soloed: new Set() }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two editing-mode toggles the timeline toolbar owns.
|
|
3
|
+
*
|
|
4
|
+
* A store slice rather than toolbar-local state because both are read far from
|
|
5
|
+
* where they are set: the motion-path arm is consumed by the canvas click
|
|
6
|
+
* handler and published by MotionPathOverlay, and the auto-keyframe flag
|
|
7
|
+
* decides what a drag/resize/rotate does inside the edit commit path.
|
|
8
|
+
*
|
|
9
|
+
* `motionPathArmed` is also cleared by playerStore on selection change — arming
|
|
10
|
+
* survives neither a new element nor a project switch, so those resets stay
|
|
11
|
+
* with the state they clear alongside.
|
|
12
|
+
*/
|
|
13
|
+
import type { StoreApi } from "zustand";
|
|
14
|
+
|
|
15
|
+
export interface EditingModeSlice {
|
|
16
|
+
/** Motion-path "set destination" mode. Armed from the preview toolbar
|
|
17
|
+
* (replaces the old double-click-on-canvas UX); while armed, one canvas
|
|
18
|
+
* click places the new path's destination. */
|
|
19
|
+
motionPathArmed: boolean;
|
|
20
|
+
setMotionPathArmed: (armed: boolean) => void;
|
|
21
|
+
/** Published by MotionPathOverlay so the toolbar shows the button only when
|
|
22
|
+
* the selected element can actually take a path. */
|
|
23
|
+
motionPathCreateAvailable: boolean;
|
|
24
|
+
setMotionPathCreateAvailable: (available: boolean) => void;
|
|
25
|
+
/** Global toggle for the "Add keyframe" diamond in the timeline toolbar
|
|
26
|
+
* (#1808). When false, a manual drag/resize/rotate edit on an element that
|
|
27
|
+
* already has a live tween shifts every keyframe by the edit's delta
|
|
28
|
+
* (preserving the animation's shape) instead of inserting/updating a
|
|
29
|
+
* keyframe at the playhead. */
|
|
30
|
+
autoKeyframeEnabled: boolean;
|
|
31
|
+
setAutoKeyframeEnabled: (enabled: boolean) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createEditingModeSlice(
|
|
35
|
+
set: StoreApi<EditingModeSlice>["setState"],
|
|
36
|
+
): EditingModeSlice {
|
|
37
|
+
return {
|
|
38
|
+
motionPathArmed: false,
|
|
39
|
+
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
|
40
|
+
motionPathCreateAvailable: false,
|
|
41
|
+
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
|
42
|
+
autoKeyframeEnabled: true,
|
|
43
|
+
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
|
|
2
|
+
/** A group's live meter reading, or absent when idle/unknown (never zero). */
|
|
3
|
+
export type GroupLevelReading = { level: number; clipped: boolean };
|
|
4
|
+
|
|
5
|
+
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
|
|
6
|
+
const listeners = new Set<Listener>();
|
|
7
|
+
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
|
|
8
|
+
|
|
9
|
+
export const groupLevels = {
|
|
10
|
+
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
|
|
11
|
+
latest = levels;
|
|
12
|
+
listeners.forEach((listener) => listener(levels));
|
|
13
|
+
},
|
|
14
|
+
subscribe: (listener: Listener) => {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => listeners.delete(listener);
|
|
17
|
+
},
|
|
18
|
+
get: () => latest,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
|
|
22
|
+
export function parseGroupLevelsMessage(
|
|
23
|
+
data: unknown,
|
|
24
|
+
): ReadonlyMap<string, GroupLevelReading> | null {
|
|
25
|
+
const levels = (data as { levels?: unknown } | null)?.levels;
|
|
26
|
+
if (!Array.isArray(levels)) return null;
|
|
27
|
+
return new Map(
|
|
28
|
+
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
|
|
29
|
+
entry.groupId,
|
|
30
|
+
{ level: entry.level, clipped: entry.clipped },
|
|
31
|
+
]),
|
|
32
|
+
);
|
|
33
|
+
}
|