@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  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 +3 -5
  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 +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  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 +8 -8
  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 +8 -1
  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.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -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 { 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");
@@ -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
+ }
@@ -63,6 +63,14 @@ export interface KeyframeSlice {
63
63
  /** Union-expand clips (keyframed clips are expanded by default on load). */
64
64
  expandClips: (ids: readonly string[]) => void;
65
65
 
66
+ /** Groups whose member rows the caret has shown (structural, not lanes). */
67
+ expandedGroupIds: Set<string>;
68
+ toggleGroupExpanded: (id: string) => void;
69
+
70
+ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
71
+ expandedLaneOwnerIds: Set<string>;
72
+ toggleLaneOwnerExpanded: (id: string) => void;
73
+
66
74
  /**
67
75
  * Project/session/element-scoped request. Its nonce is monotonic across store
68
76
  * resets so a stale consumer can never collide with a later request.
@@ -119,6 +127,24 @@ export function createKeyframeSlice(
119
127
  return { expandedClipIds: next };
120
128
  }),
121
129
 
130
+ expandedGroupIds: new Set(),
131
+ toggleGroupExpanded: (id) =>
132
+ set((state) => {
133
+ const next = new Set(state.expandedGroupIds);
134
+ if (next.has(id)) next.delete(id);
135
+ else next.add(id);
136
+ return { expandedGroupIds: next };
137
+ }),
138
+
139
+ expandedLaneOwnerIds: new Set(),
140
+ toggleLaneOwnerExpanded: (id) =>
141
+ set((state) => {
142
+ const next = new Set(state.expandedLaneOwnerIds);
143
+ if (next.has(id)) next.delete(id);
144
+ else next.add(id);
145
+ return { expandedLaneOwnerIds: next };
146
+ }),
147
+
122
148
  focusedEaseSegment: null,
123
149
  focusedEaseRequestNonce: 0,
124
150
  setFocusedEaseSegment: (target) =>
@@ -16,6 +16,8 @@ import {
16
16
  } from "./automationSelectionSlice";
17
17
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
18
18
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
19
+ import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice";
20
+ import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
19
21
 
20
22
  export type { KeyframeCacheEntry } from "./keyframeSlice";
21
23
  export { liveTime } from "./liveTime";
@@ -47,7 +49,13 @@ function resolveElementSelection(
47
49
  };
48
50
  }
49
51
 
50
- interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice {
52
+ interface PlayerState
53
+ extends
54
+ KeyframeSlice,
55
+ AutomationSelectionSlice,
56
+ ThumbnailSlice,
57
+ AudioSoloSlice,
58
+ EditingModeSlice {
51
59
  isPlaying: boolean;
52
60
  currentTime: number;
53
61
  duration: number;
@@ -87,20 +95,6 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
87
95
  * (drag, resize, rotate) target this instead of recomputing from playhead. */
88
96
  activeKeyframePct: number | null;
89
97
  setActiveKeyframePct: (pct: number | null) => void;
90
- /** Motion-path "set destination" mode. Armed from the preview toolbar (replaces
91
- * the old double-click-on-canvas UX); while armed, one canvas click places the
92
- * new path's destination. `available` is published by MotionPathOverlay so the
93
- * toolbar shows the button only when the selected element can take a path. */
94
- motionPathArmed: boolean;
95
- setMotionPathArmed: (armed: boolean) => void;
96
- motionPathCreateAvailable: boolean;
97
- setMotionPathCreateAvailable: (available: boolean) => void;
98
- /** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
99
- * When false, a manual drag/resize/rotate edit on an element that already has
100
- * a live tween shifts every keyframe by the edit's delta (preserving the
101
- * animation's shape) instead of inserting/updating a keyframe at the playhead. */
102
- autoKeyframeEnabled: boolean;
103
- setAutoKeyframeEnabled: (enabled: boolean) => void;
104
98
 
105
99
  /** Multi-select: additional selected elements beyond selectedElementId. */
106
100
  selectedElementIds: Set<string>;
@@ -146,7 +140,14 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
146
140
  updates: Partial<
147
141
  Pick<
148
142
  TimelineElement,
149
- "start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden"
143
+ | "start"
144
+ | "duration"
145
+ | "track"
146
+ | "zIndex"
147
+ | "hasExplicitZIndex"
148
+ | "playbackStart"
149
+ | "hidden"
150
+ | "audioGroup"
150
151
  >
151
152
  >,
152
153
  ) => void;
@@ -270,6 +271,8 @@ export function createTimelineResetState() {
270
271
  // paste through `sel.elementKey === paste.elementKey` to a stale t0.
271
272
  automationSelection: null,
272
273
  expandedClipIds: new Set<string>(),
274
+ expandedGroupIds: new Set<string>(),
275
+ expandedLaneOwnerIds: new Set<string>(),
273
276
  focusedEaseSegment: null,
274
277
  selectedElementIds: new Set<string>(),
275
278
  requestedSeekTime: null,
@@ -320,15 +323,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
320
323
  ...createThumbnailSlice(set),
321
324
 
322
325
  ...createAutomationSelectionSlice(set),
326
+ ...createAudioSoloSlice(set, get),
327
+ ...createEditingModeSlice(set),
323
328
 
324
329
  activeKeyframePct: null,
325
330
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
326
- motionPathArmed: false,
327
- setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
328
- motionPathCreateAvailable: false,
329
- setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
330
- autoKeyframeEnabled: true,
331
- setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
332
331
 
333
332
  selectedElementIds: new Set<string>(),
334
333
  setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)),
@@ -67,6 +67,16 @@ export interface TimelineElement {
67
67
  hidden?: boolean;
68
68
  /** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
69
69
  timelineRole?: string;
70
+ /** Verbatim `data-audio-group` — the id of the `<hf-audio-group>` this clip belongs to, when any. */
71
+ audioGroup?: string;
72
+ /** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
73
+ audioGroupLabel?: string;
74
+ /** The owning group's `data-volume` (defaults to 1) — resolved once per parse. */
75
+ audioGroupVolume?: number;
76
+ /** The owning group's `data-hidden` (defaults to false) — resolved once per parse. */
77
+ audioGroupHidden?: boolean;
78
+ /** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
79
+ audioGroupFxChain?: string;
70
80
  /**
71
81
  * Set by useExpandedTimelineElements on an inline-expanded sub-composition
72
82
  * child: the absolute master-timeline start of the sub-comp host the child