@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -29,11 +29,11 @@ import {
29
29
  resolveNextCarveSettings,
30
30
  } from "./useFxCarveGrouping.js";
31
31
  import { type HfAutomation } from "@hyperframes/core/audio-automation";
32
- import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR } from "./propertyPanelAutomation";
33
32
  import { trackCarveChanged } from "./audioFxTelemetry.js";
34
33
  import type { DomEditSelection } from "./domEditingTypes";
35
34
  import { usePlayerStore } from "../../player";
36
- import { carveLanes, measureCarve, mintCarveNodes, type CarveClip } from "./useFxCarveNodes.js";
35
+ import { dropCarveOutput, resolveCarveVoices, withoutCarveLanes } from "./carveOutput.js";
36
+ import { carveLanes, measureCarve, mintCarveNodes } from "./useFxCarveNodes.js";
37
37
  import { readClipClock } from "./clipAudioClock.js";
38
38
  import { spanOf } from "./propertyPanelAudioFxGroupUtils.js";
39
39
  import type { AudioTrackOption } from "./propertyPanelFxCarveModule.js";
@@ -55,16 +55,6 @@ function carveAction(
55
55
  return "strength";
56
56
  }
57
57
 
58
- /** Lanes belonging to nodes the carve generated, which a re-run replaces. */
59
- function withoutCarveLanes(automation: HfAutomation, chain: HfAudioFxChain): HfAutomation {
60
- const prefixes = chain.nodes.filter((n) => n.fromCarve && n.id).map((n) => `fx.${n.id}.`);
61
- if (prefixes.length === 0) return automation;
62
- return {
63
- version: automation.version,
64
- lanes: automation.lanes.filter((lane) => !prefixes.some((p) => lane.target.startsWith(p))),
65
- };
66
- }
67
-
68
58
  /**
69
59
  * Every setting here describes the filters, so changing one rebuilds them.
70
60
  * There is no apply button: a carve naming a voice with no filters behind it
@@ -86,54 +76,6 @@ function carveNeedsReanalysis(
86
76
  );
87
77
  }
88
78
 
89
- /**
90
- * Every named voice that is actually there with something to decode. A source
91
- * naming a deleted track is skipped rather than failing the whole analysis.
92
- *
93
- * Read out to plain values here rather than carrying elements around: it is
94
- * what lets the src and the start be non-null by construction downstream
95
- * instead of by assertion.
96
- */
97
- function resolveCarveVoices(doc: Document, sources: readonly string[]): CarveClip[] {
98
- const voices: CarveClip[] = [];
99
- for (const id of sources) {
100
- const el = doc.getElementById(id);
101
- // By tag name, not `instanceof HTMLAudioElement`: these elements belong to
102
- // the composition's iframe document, so the constructor they were made
103
- // from is not this realm's and the instanceof is false for every one.
104
- if (el?.tagName !== "AUDIO") continue;
105
- const src = el.getAttribute("src");
106
- if (!src) continue;
107
- const clock = readClipClock((name) => el.getAttribute(name));
108
- voices.push({ src, start: el.getAttribute("data-start"), clock });
109
- }
110
- return voices;
111
- }
112
-
113
- /**
114
- * What the carve generated is only justified by the voices it was measured
115
- * from: switched off, or left naming none — every source deleted, say — there
116
- * is nothing those filters are making room for. Left behind they keep dipping
117
- * the bed with nothing in the panel to explain them.
118
- */
119
- async function dropCarveOutput(
120
- chain: HfAudioFxChain,
121
- automation: HfAutomation,
122
- onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
123
- ): Promise<void> {
124
- const carriedOver = withoutCarveLanes(automation, chain);
125
- if (carriedOver.lanes.length !== automation.lanes.length) {
126
- await onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(carriedOver) || null);
127
- }
128
- const kept = chain.nodes.filter((n) => !n.fromCarve);
129
- if (kept.length !== chain.nodes.length) {
130
- await onSetAttributeQuiet(
131
- HF_AUDIO_FX_ATTR,
132
- kept.length ? serializeAudioFxChain({ version: 1, nodes: kept }) : null,
133
- );
134
- }
135
- }
136
-
137
79
  export function useFxCarve(
138
80
  element: DomEditSelection,
139
81
  chain: HfAudioFxChain,
@@ -179,7 +121,7 @@ export function useFxCarve(
179
121
  } => {
180
122
  const doc = element.element?.ownerDocument;
181
123
  if (!doc || !couldBeBed) return { sourceOptions: [], autoSourceIds: [] };
182
- const others = Array.from(doc.querySelectorAll<HTMLAudioElement>("audio[id]")).filter(
124
+ const others = Array.from(doc.querySelectorAll("audio[id], video[id]")).filter(
183
125
  (a) => a.id !== element.id,
184
126
  );
185
127
  // Only tracks that are actually playing while this bed is. A voice somewhere
@@ -54,6 +54,19 @@ describe("collectCarveCandidates", () => {
54
54
  });
55
55
  });
56
56
 
57
+ describe("collectCarveCandidates with video", () => {
58
+ it("offers an ungrouped audible video, not a muted one", () => {
59
+ const doc = previewDoc(`
60
+ <video id="a-roll"></video>
61
+ <video id="b-roll" muted></video>
62
+ <audio id="bed"></audio>
63
+ `);
64
+ const others = Array.from(doc.querySelectorAll("video[id]"));
65
+ const ids = collectCarveCandidates(doc, others, () => true, "bed").map((c) => c.id);
66
+ expect(ids).toEqual(["a-roll"]);
67
+ });
68
+ });
69
+
57
70
  describe("carverAgainst", () => {
58
71
  // The far-end guard has to see through a GROUP source. A plural carve names a
59
72
  // group — that is what the lint rule pushes authors toward — so matching raw
@@ -20,6 +20,11 @@ import {
20
20
  resolveAudioGroups,
21
21
  resolveCarveSourceIds,
22
22
  } from "@hyperframes/core/audio-groups";
23
+ import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
24
+
25
+ export function isCarveVoiceElement(el: Element | null): el is Element {
26
+ return el !== null && (el.tagName === "AUDIO" || isAudibleVideoNode(el));
27
+ }
23
28
 
24
29
  /**
25
30
  * An id for a new voiceover group, de-duped against every id already in the
@@ -130,7 +135,7 @@ function excludedFor(groupByMember: Map<string, { id: string }>, bedId?: string)
130
135
 
131
136
  export function collectCarveCandidates(
132
137
  doc: Document,
133
- others: readonly HTMLAudioElement[],
138
+ others: readonly Element[],
134
139
  overlapsBed: (a: Element) => boolean,
135
140
  /** The bed's own id, so neither it nor the group it belongs to is offered. */
136
141
  bedId?: string,
@@ -141,7 +146,7 @@ export function collectCarveCandidates(
141
146
  const excluded = excludedFor(groupByMember, bedId);
142
147
  const offeredGroupIds = new Set<string>();
143
148
  const described: CarveCandidate[] = [];
144
- for (const a of others) {
149
+ for (const a of others.filter(isCarveVoiceElement)) {
145
150
  const group = groupByMember.get(a.id);
146
151
  if (excluded.has(a.id) || (group && excluded.has(group.id))) continue;
147
152
  if (!group) {
@@ -161,7 +166,7 @@ export function collectCarveCandidates(
161
166
  // iframe document, so the constructor is a different realm's and the
162
167
  // instanceof is false for every one (mirrors resolveCarveVoices in
163
168
  // useFxCarve.ts).
164
- .filter((el): el is HTMLElement => el?.tagName === "AUDIO");
169
+ .filter((el): el is HTMLElement => isCarveVoiceElement(el));
165
170
  if (!members.some(overlapsBed)) continue;
166
171
  offeredGroupIds.add(group.id);
167
172
  described.push({
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
+ import { InlineTextCaret } from "./InlineTextCaret";
2
3
  import { InlineTextToolbar } from "./InlineTextToolbar";
3
4
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
5
  import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
@@ -110,10 +111,16 @@ export function useInlineTextEditing(
110
111
  return {
111
112
  editing: inlineText.session !== null,
112
113
  toolbar: (
113
- <InlineTextToolbar
114
- session={inlineText.session}
115
- iframe={actions?.previewIframeRef?.current ?? null}
116
- />
114
+ <>
115
+ <InlineTextToolbar
116
+ session={inlineText.session}
117
+ iframe={actions?.previewIframeRef?.current ?? null}
118
+ />
119
+ <InlineTextCaret
120
+ session={inlineText.session}
121
+ iframe={actions?.previewIframeRef?.current ?? null}
122
+ />
123
+ </>
117
124
  ),
118
125
  handleKeyDown,
119
126
  startFromPress: (event) => {
@@ -26,6 +26,8 @@ import {
26
26
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
27
27
  import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
28
28
  import { clampNumber } from "../../utils/studioHelpers";
29
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
30
+ import type { LinkedSpeedCommit } from "./linkedSpeedEdits";
29
31
 
30
32
  export interface LaneBinding {
31
33
  automated: boolean;
@@ -59,6 +61,7 @@ export function useVolumeAutomation(
59
61
  element: DomEditSelection,
60
62
  currentTime: number,
61
63
  onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
64
+ linkedSpeed: LinkedSpeedCommit | null = null,
62
65
  ): VolumeAutomationBinding {
63
66
  // The chain is not needed to resolve a volume or rate lane: both are always valid
64
67
  // targets, so this deliberately does not parse it.
@@ -77,7 +80,22 @@ export function useVolumeAutomation(
77
80
  // playing track, while the same click on an effect parameter did not.
78
81
  void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
79
82
  };
80
- const laneBinding = (target: string, seed: number): LaneBinding => {
83
+ type Edit = (current: HfAutomation) => HfAutomation;
84
+ const writeOwn = (edit: Edit) => write(edit(automation));
85
+ // Speed moves every data-link member together, so a linked clip's rate lane edits fan out.
86
+ const writeRate = (edit: Edit) => {
87
+ if (!linkedSpeed) return writeOwn(edit);
88
+ void linkedSpeed.commitAttribute(
89
+ HF_AUDIO_AUTOMATION_ATTR,
90
+ (raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null,
91
+ "Edit speed",
92
+ );
93
+ };
94
+ const laneBinding = (
95
+ target: string,
96
+ seed: number,
97
+ writeLane: (edit: Edit) => void,
98
+ ): LaneBinding => {
81
99
  const lane = automation.lanes.find((l) => l.target === target);
82
100
  return {
83
101
  automated: lane !== undefined,
@@ -85,9 +103,9 @@ export function useVolumeAutomation(
85
103
  ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
86
104
  : undefined,
87
105
  // Seeded at the level the control already shows, so automating does not change it.
88
- onAutomate: () => write(withSeededLane(automation, target, seed)),
89
- onRemoveAutomation: () => write(withoutLane(automation, target)),
90
- onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)),
106
+ onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)),
107
+ onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)),
108
+ onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
91
109
  };
92
110
  };
93
111
  // `??` alone would let an empty `data-volume` through as Number("") === 0, so
@@ -96,9 +114,13 @@ export function useVolumeAutomation(
96
114
  const raw = element.dataAttributes?.["volume"];
97
115
  const parsed = raw ? Number(raw) : 1;
98
116
  const current = Number.isFinite(parsed) ? parsed : 1;
99
- const volume = laneBinding(VOLUME_TARGET, current);
117
+ const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
100
118
  const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
101
- const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1);
119
+ const rate = laneBinding(
120
+ RATE_TARGET,
121
+ Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1,
122
+ writeRate,
123
+ );
102
124
  return {
103
125
  volumeAutomated: volume.automated,
104
126
  automatedVolumeValue: volume.automatedValue,
@@ -109,7 +131,7 @@ export function useVolumeAutomation(
109
131
  ...rate,
110
132
  canApplyPreset: elDuration > 0,
111
133
  onApplyPreset: (id) => {
112
- if (elDuration > 0) write(withLane(automation, speedPresetLane(id, elDuration)));
134
+ if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration)));
113
135
  },
114
136
  },
115
137
  };
@@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
14
  type AudioMeterStripProps,
15
+ CLIP_LATCH_PEAK,
16
+ stepClipLatch,
15
17
  evictGoneMeterState,
16
18
  followMeterHook,
17
19
  stepAndPaintStrips,
@@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => {
416
418
  expect(state.has("loud")).toBe(true);
417
419
  });
418
420
  });
421
+
422
+ describe("CLIP light", () => {
423
+ const hookAt = (peak: number) => ({
424
+ start: vi.fn(),
425
+ stop: vi.fn(),
426
+ read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })),
427
+ });
428
+
429
+ it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => {
430
+ usePlayerStore.setState({ elements: [clip({})] });
431
+ const hook = hookAt(1);
432
+ setHook(hook);
433
+ const { host } = mount();
434
+ const light = host.querySelector<HTMLElement>("[data-testid=meter-clip-light]");
435
+ expect(light?.dataset.lit).toBe("false");
436
+ tick();
437
+ expect(light?.dataset.lit).toBe("true");
438
+ hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} });
439
+ tick();
440
+ expect(light?.dataset.lit).toBe("true");
441
+ act(() => light?.click());
442
+ expect(light?.dataset.lit).toBe("false");
443
+ tick();
444
+ expect(light?.dataset.lit).toBe("false");
445
+ });
446
+
447
+ it("stays dark in the red band just under the ceiling", () => {
448
+ usePlayerStore.setState({ elements: [clip({})] });
449
+ setHook(hookAt(10 ** (-1.2 / 20)));
450
+ const { host } = mount();
451
+ tick();
452
+ expect(host.querySelector<HTMLElement>("[data-testid=meter-clip-light]")?.dataset.lit).toBe(
453
+ "false",
454
+ );
455
+ });
456
+ });
457
+
458
+ describe("stepClipLatch", () => {
459
+ it("lights exactly at -1 dBFS on either channel, not below", () => {
460
+ expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6);
461
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true);
462
+ expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true);
463
+ expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false);
464
+ expect(stepClipLatch(false, undefined)).toBe(false);
465
+ });
466
+
467
+ it("holds once lit, whatever comes next", () => {
468
+ expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true);
469
+ expect(stepClipLatch(true, undefined)).toBe(true);
470
+ });
471
+ });
@@ -5,6 +5,7 @@ import {
5
5
  useMemo,
6
6
  useRef,
7
7
  type PointerEvent as ReactPointerEvent,
8
+ type ReactNode,
8
9
  type Ref,
9
10
  type RefObject,
10
11
  } from "react";
@@ -20,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
20
21
  import { usePlayerStore } from "../../player";
21
22
  import { clampNumber } from "../../utils/studioHelpers";
22
23
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
24
+ import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
23
25
  import { useStudioShellContextOptional } from "../../contexts/StudioContext";
24
26
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
27
  import {
@@ -49,6 +51,24 @@ type StripBars = [Bars, Bars];
49
51
 
50
52
  const MONITOR_LABEL = "Monitor";
51
53
 
54
+ /** The CLIP light latches at the export ceiling, stricter than RED_AT. */
55
+ export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING;
56
+
57
+ export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean {
58
+ if (latched || !master) return latched;
59
+ return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK;
60
+ }
61
+
62
+ function paintClipLight(el: HTMLElement | null, lit: boolean): void {
63
+ if (!el) return;
64
+ el.dataset.lit = String(lit);
65
+ el.style.setProperty(
66
+ "background-color",
67
+ lit ? "var(--color-red-500)" : "var(--color-neutral-800)",
68
+ );
69
+ el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)");
70
+ }
71
+
52
72
  /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
53
73
  const AMBER_AT = markFraction(-6);
54
74
  const RED_AT = markFraction(-3);
@@ -178,10 +198,16 @@ export function stepAndPaintStrips(
178
198
  }
179
199
 
180
200
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
201
+ interface ClipLatch {
202
+ lit: boolean;
203
+ light: HTMLElement | null;
204
+ }
205
+
181
206
  function useMeterLoop(
182
207
  strips: Strip[],
183
208
  bars: RefObject<Map<string | null, StripBars>>,
184
209
  iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
210
+ clip: RefObject<ClipLatch>,
185
211
  ) {
186
212
  const shell = useStudioShellContextOptional();
187
213
  const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
@@ -200,6 +226,11 @@ function useMeterLoop(
200
226
  last = now;
201
227
  evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
202
228
  stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
229
+ const lit = stepClipLatch(clip.current.lit, levels?.master);
230
+ if (lit !== clip.current.lit) {
231
+ clip.current.lit = lit;
232
+ paintClipLight(clip.current.light, lit);
233
+ }
203
234
  };
204
235
  raf = requestAnimationFrame(tick);
205
236
  return () => {
@@ -210,7 +241,29 @@ function useMeterLoop(
210
241
  // Preview iframe already gone.
211
242
  }
212
243
  };
213
- }, [previewIframeRef, bars]);
244
+ }, [previewIframeRef, bars, clip]);
245
+ }
246
+
247
+ function ClipLight({ clip }: { clip: RefObject<ClipLatch> }) {
248
+ return (
249
+ <button
250
+ type="button"
251
+ data-testid="meter-clip-light"
252
+ data-lit="false"
253
+ title="Lights when the preview reaches −1 dBFS, the export ceiling. Click to reset."
254
+ ref={(el) => {
255
+ clip.current.light = el;
256
+ paintClipLight(el, clip.current.lit);
257
+ }}
258
+ onClick={() => {
259
+ clip.current.lit = false;
260
+ paintClipLight(clip.current.light, false);
261
+ }}
262
+ className="rounded-[2px] px-1 font-mono text-[9px] leading-[14px] font-bold tracking-wide"
263
+ >
264
+ CLIP
265
+ </button>
266
+ );
214
267
  }
215
268
 
216
269
  function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
@@ -341,11 +394,13 @@ function MeterStrip({
341
394
  register,
342
395
  onLive,
343
396
  onCommit,
397
+ clipLight,
344
398
  }: {
345
399
  strip: Strip;
346
400
  register: (id: string | null, bars: StripBars | null) => void;
347
401
  onLive: (id: string | null, volume: number) => void;
348
402
  onCommit: (id: string | null, volume: number) => void;
403
+ clipLight?: ReactNode;
349
404
  }) {
350
405
  const refs = [
351
406
  useRef<HTMLDivElement>(null),
@@ -364,6 +419,7 @@ function MeterStrip({
364
419
  }, [strip.id, register]);
365
420
  return (
366
421
  <div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
422
+ {clipLight}
367
423
  <div
368
424
  className="flex min-h-0 flex-1 items-stretch gap-1.5"
369
425
  aria-label={`${strip.label} level`}
@@ -421,7 +477,8 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
421
477
  else bars.current.delete(id);
422
478
  }).current;
423
479
  const { onLive, onCommit } = useVolumeHandlers();
424
- useMeterLoop(strips, bars, previewIframeRef);
480
+ const clip = useRef<ClipLatch>({ lit: false, light: null });
481
+ useMeterLoop(strips, bars, previewIframeRef, clip);
425
482
  return (
426
483
  <div
427
484
  data-testid="audio-meter-strip"
@@ -434,6 +491,7 @@ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
434
491
  register={register}
435
492
  onLive={onLive}
436
493
  onCommit={onCommit}
494
+ clipLight={strip.id === null ? <ClipLight clip={clip} /> : undefined}
437
495
  />
438
496
  ))}
439
497
  </div>
@@ -53,6 +53,9 @@ export interface TimelineEditCallbackDeps {
53
53
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
54
54
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
55
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56
+ handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
57
+ handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
58
+ handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
56
59
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
57
60
  handleGroupClips?: (
58
61
  clipIds: readonly string[],
@@ -132,6 +135,9 @@ export function useTimelineEditCallbacks({
132
135
  handleTimelineElementSplit,
133
136
  handleRazorSplit,
134
137
  handleRazorSplitAll,
138
+ handleFreezeFrame,
139
+ handleLinkEdit,
140
+ handleTimelineElementDeleteOnly,
135
141
  handleGroupClips,
136
142
  setElementFxAttribute,
137
143
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -227,6 +233,9 @@ export function useTimelineEditCallbacks({
227
233
  onSplitElement: handleTimelineElementSplit,
228
234
  onRazorSplit: handleRazorSplit,
229
235
  onRazorSplitAll: handleRazorSplitAll,
236
+ onFreezeFrame: handleFreezeFrame,
237
+ onLinkEdit: handleLinkEdit,
238
+ onDeleteElementOnly: handleTimelineElementDeleteOnly,
230
239
  onDeleteAllKeyframes: (element, animationId) => {
231
240
  // Hold the element where it is (collapse keyframes to a static set) rather
232
241
  // than deleting the whole animation — deleting strands a stale GSAP base
@@ -423,6 +432,9 @@ export function useTimelineEditCallbacks({
423
432
  handleTimelineElementSplit,
424
433
  handleRazorSplit,
425
434
  handleRazorSplitAll,
435
+ handleFreezeFrame,
436
+ handleLinkEdit,
437
+ handleTimelineElementDeleteOnly,
426
438
  handleGsapRemoveAllKeyframes,
427
439
  resolveElementAnimations,
428
440
  resolveKeyframeTarget,
@@ -3,7 +3,6 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import React, { act, useState } from "react";
6
- import { createRoot, type Root } from "react-dom/client";
7
6
  import { compile } from "tailwindcss";
8
7
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
8
  import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
@@ -11,27 +10,11 @@ import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparat
11
10
  import { Popover } from "./Popover";
12
11
  import { isTypingTarget } from "../../utils/typingTarget";
13
12
  import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
13
+ import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
14
14
 
15
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
16
 
17
- let mounted: { root: Root; host: HTMLElement } | null = null;
18
-
19
- function render(element: React.ReactElement): HTMLElement {
20
- const host = document.createElement("div");
21
- document.body.append(host);
22
- const root = createRoot(host);
23
- mounted = { root, host };
24
- act(() => root.render(element));
25
- return host;
26
- }
27
-
28
- afterEach(() => {
29
- if (!mounted) return;
30
- const { root, host } = mounted;
31
- mounted = null;
32
- act(() => root.unmount());
33
- host.remove();
34
- });
17
+ afterEach(cleanupMounted);
35
18
 
36
19
  /** Base UI moves focus into the popup one task after open, not synchronously. */
37
20
  async function settle(): Promise<void> {
@@ -310,9 +293,7 @@ describe("Popover", () => {
310
293
  renderOpen(false);
311
294
  await settle();
312
295
  expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
313
- act(() => mounted?.root.unmount());
314
- mounted?.host.remove();
315
- mounted = null;
296
+ cleanupMounted();
316
297
 
317
298
  renderOpen(true);
318
299
  await settle();
@@ -52,8 +52,8 @@ export const popupSurface = cn(
52
52
  "data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
53
53
  );
54
54
 
55
- /** The popup's own layer: menus sit above panel chrome and below a modal. */
56
- const POPUP_LAYER = "z-200";
55
+ /** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
56
+ export const POPUP_LAYER = "z-200";
57
57
 
58
58
  const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
59
59
 
@@ -6,7 +6,7 @@
6
6
  import { Popover as BasePopover } from "@base-ui/react/popover";
7
7
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
8
8
  import { cn } from "./cn";
9
- import { popupSurface, type PopupPreviewState } from "./Menu";
9
+ import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
10
10
 
11
11
  type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
12
12
 
@@ -70,7 +70,7 @@ export function Popover({
70
70
  sideOffset={sideOffset}
71
71
  collisionPadding={VIEWPORT_MARGIN}
72
72
  arrowPadding={ARROW_CORNER_CLEARANCE}
73
- className="z-200"
73
+ className={POPUP_LAYER}
74
74
  >
75
75
  <BasePopover.Popup
76
76
  aria-label={ariaLabel}
@@ -6,7 +6,7 @@
6
6
  import { Select as BaseSelect } from "@base-ui/react/select";
7
7
  import { cn } from "./cn";
8
8
  import { fieldBase } from "./Input";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
  import type { PreviewState } from "./Button";
11
11
 
12
12
  export interface SelectOption {
@@ -77,7 +77,7 @@ export function Select({
77
77
  </BaseSelect.Trigger>
78
78
 
79
79
  <BaseSelect.Portal>
80
- <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
80
+ <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
81
81
  <BaseSelect.Popup
82
82
  className={cn(
83
83
  "min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
@@ -6,7 +6,7 @@
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
7
  import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
8
8
  import { cn } from "./cn";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
 
11
11
  interface TooltipProps {
12
12
  label: string;
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
39
39
  side={side}
40
40
  sideOffset={SIDE_OFFSET}
41
41
  collisionPadding={VIEWPORT_MARGIN}
42
- className="z-200"
42
+ className={POPUP_LAYER}
43
43
  >
44
44
  <BaseTooltip.Popup
45
45
  id={tooltipId}
@@ -4,6 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
5
5
 
6
6
  let mounted: { root: Root; host: HTMLElement } | null = null;
7
+ const tracked: Root[] = [];
8
+
9
+ export function trackedRoot(host: HTMLElement): Root {
10
+ const root = createRoot(host);
11
+ tracked.push(root);
12
+ return root;
13
+ }
7
14
 
8
15
  /** Renders into a fresh host attached to the body; `cleanupMounted` tears it down. */
9
16
  export function mountHost(element: ReactElement): HTMLElement {
@@ -16,6 +23,7 @@ export function mountHost(element: ReactElement): HTMLElement {
16
23
  }
17
24
 
18
25
  export function cleanupMounted(): void {
26
+ act(() => tracked.splice(0).forEach((root) => root.unmount()));
19
27
  if (!mounted) return;
20
28
  const { root, host } = mounted;
21
29
  mounted = null;