@hyperframes/studio 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6440 -5514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * What a carve analysis COMPILES TO: the filter nodes it mints and the
3
+ * automation lanes it writes for them.
4
+ *
5
+ * Split out of `useFxCarve.ts` to keep it under the studio's 600-line cap. This
6
+ * half is pure — it takes measurements and returns nodes and lanes — while the
7
+ * hook keeps the effects, the persistence and the auto-carve decisions.
8
+ */
9
+
10
+ import {
11
+ defaultAudioFxParams,
12
+ mintAudioFxNodeId,
13
+ type HfAudioFxChain,
14
+ type HfAudioFxNode,
15
+ } from "@hyperframes/core/audio-fx";
16
+ import {
17
+ analyseCarveBands,
18
+ analyseCarveDuck,
19
+ analyseCarveDynamics,
20
+ carveBandsToChain,
21
+ carveProfile,
22
+ mixCarveSources,
23
+ } from "@hyperframes/core/audio-carve";
24
+ import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
25
+ import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
26
+
27
+ /** Decode rate every carve measurement shares — see `measureCarve`. */
28
+ const DECODE_SAMPLE_RATE = 48000;
29
+
30
+ /**
31
+ * Turns the analysed bands (and, if the carve is asked to match levels, a
32
+ * ducking envelope) into chain nodes — tagged so a re-run replaces them
33
+ * instead of stacking, and minted against the nodes already claiming an id
34
+ * because a dynamic carve automates these filters and a lane addresses its
35
+ * node by id.
36
+ */
37
+ export function mintCarveNodes(
38
+ chain: HfAudioFxChain,
39
+ carved: HfAudioFxChain,
40
+ duck: { t: number; v: number }[],
41
+ ): { next: HfAudioFxChain; carvedNodes: HfAudioFxNode[]; duckNode: HfAudioFxNode | null } {
42
+ const kept = chain.nodes.filter((n) => !n.fromCarve);
43
+ let claimed: HfAudioFxChain = { version: 1, nodes: kept };
44
+ const mint = (node: HfAudioFxNode): HfAudioFxNode => {
45
+ const withId = { ...node, id: mintAudioFxNodeId(claimed), fromCarve: true };
46
+ claimed = { version: 1, nodes: [...claimed.nodes, withId] };
47
+ return withId;
48
+ };
49
+ const carvedNodes: HfAudioFxNode[] = carved.nodes.map(mint);
50
+ // The gain stage sits after the filters, and only exists when the carve was
51
+ // asked to make level room. It sits at 0 and is driven by the envelope below.
52
+ const duckNode =
53
+ duck.length > 0
54
+ ? mint({ type: "gain", enabled: true, params: { ...defaultAudioFxParams("gain"), gain: 0 } })
55
+ : null;
56
+ return {
57
+ next: { version: 1, nodes: [...carvedNodes, ...(duckNode ? [duckNode] : []), ...kept] },
58
+ carvedNodes,
59
+ duckNode,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Decode every voice, mix them onto the bed's own clock, and measure the
65
+ * bands (and, if the profile calls for it, the ducking envelope) from that
66
+ * mix. Null on anything that leaves nothing to build a carve from — the
67
+ * platform lacking an offline context, or a mix that decoded to silence.
68
+ */
69
+ export async function measureCarve(
70
+ doc: Document,
71
+ voices: { src: string; start: string | null }[],
72
+ strength: number,
73
+ bedStartAttr: string | null | undefined,
74
+ bedSrc: string | null | undefined,
75
+ ): Promise<{
76
+ bands: ReturnType<typeof analyseCarveBands>;
77
+ carved: HfAudioFxChain;
78
+ duck: { t: number; v: number }[];
79
+ voiceMix: Float32Array;
80
+ } | null> {
81
+ // Decoded in an OfflineAudioContext, not a live one. Opening a second output
82
+ // device mid-playback makes the running track glitch while the hardware is
83
+ // reconfigured; an offline context touches no device.
84
+ const Ctor =
85
+ window.OfflineAudioContext ??
86
+ (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
87
+ .webkitOfflineAudioContext;
88
+ if (!Ctor) return null;
89
+ const decode = async (relative: string): Promise<AudioBuffer> => {
90
+ const res = await fetch(new URL(relative, doc.baseURI).href);
91
+ return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
92
+ };
93
+ const bedStart = clipStart(bedStartAttr);
94
+ // Every voice, summed onto the bed's own clock. One question — where and
95
+ // when is speech masking this bed — with one answer, even when the answer
96
+ // comes from three people talking at different times. Doing this before the
97
+ // analysis is also what lets the bands and the envelopes stay a single set:
98
+ // the chain is fixed, so there is no per-voice filter to switch between.
99
+ const decoded = await Promise.all(
100
+ voices.map(async (voice) => ({
101
+ samples: (await decode(voice.src)).getChannelData(0),
102
+ offsetSeconds: clipStart(voice.start) - bedStart,
103
+ })),
104
+ );
105
+ const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
106
+ if (voiceMix.length === 0) return null;
107
+ // Strength is what the author set; these are the numbers it means.
108
+ const profile = carveProfile(strength);
109
+ // The bed as well as the voice, when the carve is asked to match levels:
110
+ // "how far over the voice is this bed" cannot be answered by listening to
111
+ // one of them.
112
+ const bedBuffer = profile.duckDb > 0 && bedSrc ? await decode(bedSrc).catch(() => null) : null;
113
+ const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
114
+ // The level half of the carve, measured against the speech it has to sit
115
+ // under. No offset to apply: the mix is already on the bed's clock.
116
+ const duck = bedBuffer
117
+ ? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
118
+ : [];
119
+ return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
120
+ }
121
+
122
+ /** Each filter's depth as an envelope, plus the level envelope if there is one. */
123
+ export function carveLanes(
124
+ carvedNodes: HfAudioFxNode[],
125
+ duckNode: HfAudioFxNode | null,
126
+ duck: { t: number; v: number }[],
127
+ voiceMix: Float32Array,
128
+ bands: ReturnType<typeof analyseCarveBands>,
129
+ ): HfAutomationLane[] {
130
+ // Each filter's depth becomes an envelope of the speech's level in that
131
+ // band, so pauses leave the bed alone and whoever is talking sets the depth.
132
+ const lanes = analyseCarveDynamics(voiceMix, DECODE_SAMPLE_RATE, bands).flatMap((dyn, i) => {
133
+ const id = carvedNodes[i]?.id;
134
+ return id ? carveLaneFor(id, dyn.points) : [];
135
+ });
136
+ // The level envelope rides the gain stage, on the same clock as the bands.
137
+ if (duckNode?.id && duck.length > 0) lanes.push(...carveLaneFor(duckNode.id, duck));
138
+ return lanes;
139
+ }
140
+
141
+ /**
142
+ * One carve envelope as a lane on this bed's clock.
143
+ *
144
+ * No shifting: the voices were summed onto the bed's clock before the analysis
145
+ * ran, so what comes back is already in the bed's own time. A lane does hold
146
+ * its first value backwards to the start of its clip, so an envelope that
147
+ * begins later needs an explicit "no cut" at zero or the bed starts out ducked.
148
+ */
149
+ function carveLaneFor(id: string, points: { t: number; v: number }[]): HfAutomationLane[] {
150
+ const timed = points.map((p) => ({ t: Number(p.t.toFixed(3)), v: p.v })).filter((p) => p.t >= 0);
151
+ if ((timed[0]?.t ?? 0) > 0) timed.unshift({ t: 0, v: 0 });
152
+ return timed.length > 1 ? [{ target: fxAutomationTarget(id, "gain"), points: timed }] : [];
153
+ }
@@ -23,7 +23,7 @@ import {
23
23
  } from "./propertyPanelAutomation";
24
24
  import { trackLeveller } from "./audioFxTelemetry.js";
25
25
  import type { DomEditSelection } from "./domEditingTypes";
26
- import { usePlayerStore } from "../../player";
26
+ import { useAuditionTransport } from "./useAuditionTransport.js";
27
27
 
28
28
  /**
29
29
  * Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -125,39 +125,10 @@ export function useFxLevelling(
125
125
  }
126
126
  };
127
127
 
128
- /**
129
- * Where the playhead was when an audition started the transport, so leaving
130
- * can put it back. Null means this audition did not start playback — the
131
- * transport was already running and must be left alone.
132
- */
133
- const auditionReturn = useRef<number | null>(null);
134
-
135
- /**
136
- * Start playback for an audition, and stop it again on the way out.
137
- *
138
- * An audition writes the preset to the running graph, which is silent while
139
- * the transport is paused — so a paused author hovering a preset heard
140
- * nothing at all, and the whole affordance only worked mid-playback. Hovering
141
- * now plays from the playhead, and leaving stops and rewinds to exactly where
142
- * it started: browsing the shelf must not cost the author their place.
143
- *
144
- * Already playing, this does nothing in either direction. The author started
145
- * that, and stopping their transport because they passed over a preset would
146
- * be the panel taking a decision that was not offered to it.
147
- */
148
- const auditionTransport = (on: boolean): void => {
149
- const store = usePlayerStore.getState();
150
- if (on) {
151
- if (store.isPlaying || auditionReturn.current !== null) return;
152
- auditionReturn.current = store.currentTime;
153
- store.requestPlayback(true);
154
- return;
155
- }
156
- const returnTo = auditionReturn.current;
157
- if (returnTo === null) return;
158
- auditionReturn.current = null;
159
- store.requestPlayback(false, returnTo);
160
- };
128
+ // Hovering plays from the playhead and leaving rewinds to exactly where it
129
+ // started: browsing the shelf must not cost the author their place. Shared
130
+ // with the timeline's FX popover see `useAuditionTransport`.
131
+ const auditionTransport = useAuditionTransport();
161
132
 
162
133
  const [auditioningLevel, setAuditioningLevel] = useState(false);
163
134
  /**
@@ -1,12 +1,4 @@
1
- import {
2
- createContext,
3
- useContext,
4
- useState,
5
- useCallback,
6
- useRef,
7
- useEffect,
8
- type ReactNode,
9
- } from "react";
1
+ import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
10
2
  import { useTimelinePlayer, usePlayerStore } from "../../player";
11
3
  import type { TimelineElement } from "../../player";
12
4
  import type { CompositionLevel } from "./CompositionBreadcrumb";
@@ -16,6 +8,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
16
8
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
17
9
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
18
10
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
11
+ import { createStableContext } from "../../utils/hmrStableContext";
19
12
 
20
13
  // Timeline gets a generous default height so the preview isn't oversized and the
21
14
  // tracks have room to breathe (CapCut-style). Users can still drag the divider.
@@ -55,7 +48,7 @@ export interface NLEContextValue {
55
48
  setPreviewCompositionSize: (size: { width: number; height: number } | null) => void;
56
49
  }
57
50
 
58
- const NLEContext = createContext<NLEContextValue | null>(null);
51
+ const NLEContext = createStableContext<NLEContextValue | null>("NLEContext", null);
59
52
 
60
53
  export function useNLEContext(): NLEContextValue {
61
54
  const ctx = useContext(NLEContext);
@@ -156,7 +156,6 @@ export function PreviewPane({
156
156
  disabled={timelineDisabled}
157
157
  isFullscreen={isFullscreen}
158
158
  onToggleFullscreen={toggleFullscreen}
159
- previewIframeRef={iframeRef}
160
159
  />
161
160
  </div>
162
161
  </div>
@@ -47,7 +47,11 @@ export interface TimelineEditCallbackDeps {
47
47
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
48
48
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
49
49
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
50
- handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
50
+ handleGroupClips?: (
51
+ clipIds: readonly string[],
52
+ groupId: string,
53
+ groupLabel?: string,
54
+ ) => Promise<void>;
51
55
  /** C1's single-clip FX write, addressed by the clip itself. */
52
56
  setElementFxAttribute?: {
53
57
  setLive: (element: TimelineElement, attr: string, value: string | null) => void;
@@ -1,4 +1,5 @@
1
- import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
1
+ import { useCallback, useContext, useMemo, type ReactNode } from "react";
2
+ import { createStableContext } from "../utils/hmrStableContext";
2
3
  import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking";
3
4
 
4
5
  // Carries which inspector UI and which section the currently-rendered design-panel
@@ -11,10 +12,10 @@ interface DesignPanelInputContextValue {
11
12
  section: string;
12
13
  }
13
14
 
14
- const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({
15
- ui: "classic",
16
- section: "unknown",
17
- });
15
+ const DesignPanelInputContext = createStableContext<DesignPanelInputContextValue>(
16
+ "DesignPanelInputContext",
17
+ { ui: "classic", section: "unknown" },
18
+ );
18
19
 
19
20
  export function DesignPanelInputProvider({
20
21
  ui,
@@ -1,6 +1,7 @@
1
1
  // fallow-ignore-file code-duplication
2
- import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
3
2
  import type { useDomEditSession } from "../hooks/useDomEditSession";
3
+ import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
4
+ import { createStableContext } from "../utils/hmrStableContext";
4
5
 
5
6
  type DomEditValue = ReturnType<typeof useDomEditSession>;
6
7
 
@@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick<
93
94
  | "agentPromptSelectionContext"
94
95
  > {}
95
96
 
96
- const DomEditActionsContext = createContext<DomEditActionsValue | null>(null);
97
- const DomEditSelectionContext = createContext<DomEditSelectionValue | null>(null);
97
+ const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
98
+ "DomEditActionsContext",
99
+ null,
100
+ );
101
+ const DomEditSelectionContext = createStableContext<DomEditSelectionValue | null>(
102
+ "DomEditSelectionContext",
103
+ null,
104
+ );
98
105
 
99
106
  export function useDomEditActionsContext(): DomEditActionsValue {
100
107
  const ctx = useContext(DomEditActionsContext);
@@ -1,9 +1,10 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react";
2
1
  import type { useFileManager } from "../hooks/useFileManager";
2
+ import { useContext, useMemo, type ReactNode } from "react";
3
+ import { createStableContext } from "../utils/hmrStableContext";
3
4
 
4
5
  type FileManagerValue = ReturnType<typeof useFileManager>;
5
6
 
6
- const FileManagerContext = createContext<FileManagerValue | null>(null);
7
+ const FileManagerContext = createStableContext<FileManagerValue | null>("FileManagerContext", null);
7
8
 
8
9
  export function useFileManagerContext(): FileManagerValue {
9
10
  const ctx = useContext(FileManagerContext);
@@ -1,9 +1,10 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react";
2
1
  import type { usePanelLayout } from "../hooks/usePanelLayout";
2
+ import { useContext, useMemo, type ReactNode } from "react";
3
+ import { createStableContext } from "../utils/hmrStableContext";
3
4
 
4
5
  type PanelLayoutValue = ReturnType<typeof usePanelLayout>;
5
6
 
6
- const PanelLayoutContext = createContext<PanelLayoutValue | null>(null);
7
+ const PanelLayoutContext = createStableContext<PanelLayoutValue | null>("PanelLayoutContext", null);
7
8
 
8
9
  export function usePanelLayoutContext(): PanelLayoutValue {
9
10
  const ctx = useContext(PanelLayoutContext);
@@ -1,7 +1,8 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react";
2
1
  import type { TimelineElement } from "../player";
3
2
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
3
  import type { FfmpegStatus } from "../components/renders/useFfmpegStatus";
4
+ import { useContext, useMemo, type ReactNode } from "react";
5
+ import { createStableContext } from "../utils/hmrStableContext";
5
6
 
6
7
  export interface StudioShellValue {
7
8
  projectId: string;
@@ -52,8 +53,11 @@ export interface StudioPlaybackValue {
52
53
 
53
54
  export type StudioContextValue = StudioShellValue & StudioPlaybackValue;
54
55
 
55
- const StudioShellContext = createContext<StudioShellValue | null>(null);
56
- const StudioPlaybackContext = createContext<StudioPlaybackValue | null>(null);
56
+ const StudioShellContext = createStableContext<StudioShellValue | null>("StudioShellContext", null);
57
+ const StudioPlaybackContext = createStableContext<StudioPlaybackValue | null>(
58
+ "StudioPlaybackContext",
59
+ null,
60
+ );
57
61
 
58
62
  export function useStudioShellContext(): StudioShellValue {
59
63
  const ctx = useContext(StudioShellContext);
@@ -1,7 +1,11 @@
1
- import { createContext, useContext, useMemo, type ReactNode } from "react";
1
+ import { useContext, useMemo, type ReactNode } from "react";
2
+ import { createStableContext } from "../utils/hmrStableContext";
2
3
  import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
4
 
4
- const TimelineEditContext = createContext<TimelineEditCallbacks | null>(null);
5
+ const TimelineEditContext = createStableContext<TimelineEditCallbacks | null>(
6
+ "TimelineEditContext",
7
+ null,
8
+ );
5
9
 
6
10
  export function useTimelineEditContext(): TimelineEditCallbacks {
7
11
  const ctx = useContext(TimelineEditContext);
@@ -7,7 +7,6 @@
7
7
  * and callbacks to promote or to edit the bound variable's default in place.
8
8
  */
9
9
 
10
- import { createContext, useContext, useEffect, useMemo, useState } from "react";
11
10
  import type { Composition, CompositionVariable } from "@hyperframes/sdk";
12
11
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
12
  import {
@@ -22,6 +21,8 @@ import {
22
21
  uniqueId,
23
22
  type PromoteChannel,
24
23
  } from "./variablePromoteHelpers";
24
+ import { useContext, useEffect, useMemo, useState } from "react";
25
+ import { createStableContext } from "../utils/hmrStableContext";
25
26
 
26
27
  export type { PromoteChannel };
27
28
 
@@ -47,7 +48,10 @@ interface VariablePromoteContextValue {
47
48
  onPersistError: (error: unknown) => void;
48
49
  }
49
50
 
50
- const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
51
+ const VariablePromoteContext = createStableContext<VariablePromoteContextValue | null>(
52
+ "VariablePromoteContext",
53
+ null,
54
+ );
51
55
 
52
56
  function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
53
57
  const snapshot = session.getElement(hfId);
@@ -1,5 +1,4 @@
1
1
  import {
2
- createContext,
3
2
  useCallback,
4
3
  useContext,
5
4
  useEffect,
@@ -8,6 +7,7 @@ import {
8
7
  useState,
9
8
  type ReactNode,
10
9
  } from "react";
10
+ import { createStableContext } from "../utils/hmrStableContext";
11
11
 
12
12
  /**
13
13
  * Top-level Studio view mode.
@@ -123,7 +123,7 @@ export function useViewModeState(): ViewModeValue {
123
123
  );
124
124
  }
125
125
 
126
- const ViewModeContext = createContext<ViewModeValue | null>(null);
126
+ const ViewModeContext = createStableContext<ViewModeValue | null>("ViewModeContext", null);
127
127
 
128
128
  export function useViewMode(): ViewModeValue {
129
129
  const ctx = useContext(ViewModeContext);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which elements a delete acts on.
3
+ *
4
+ * Its own module so `useDomEditSession.ts` stays under the studio's 600-line
5
+ * cap; it reads only its arguments.
6
+ */
7
+
8
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
+ import type { EditHistoryKind } from "../utils/editHistory";
10
+
11
+ /** One entry in the studio's edit history, as `useDomEditSession`'s caller
12
+ * supplies it. */
13
+ export interface RecordEditInput {
14
+ label: string;
15
+ kind: EditHistoryKind;
16
+ coalesceKey?: string;
17
+ files: Record<string, { before: string; after: string }>;
18
+ }
19
+
20
+ /**
21
+ * Which elements a delete acts on. `expandGroup` widens the primary to the
22
+ * whole marquee group, which is what the Delete key means.
23
+ *
24
+ * The caller chooses rather than the delete deciding for everyone: Cut copies
25
+ * the primary alone, so expanding for it put one element on the clipboard and
26
+ * removed every other member of the group with it.
27
+ */
28
+ export function membersForDelete(
29
+ selection: DomEditSelection,
30
+ group: DomEditSelection[],
31
+ options?: { expandGroup?: boolean },
32
+ ): DomEditSelection[] {
33
+ return options?.expandGroup && group.length > 0 ? group : [selection];
34
+ }