@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * An open effect's knobs, with whatever automation surface applies to them.
3
4
  *
@@ -69,6 +70,7 @@ export function FxNodeParams({
69
70
  index: number;
70
71
  disabled: boolean;
71
72
  }) {
73
+ const trackInput = useTrackDesignInput();
72
74
  const nodeId = node.id;
73
75
  // Lanes address a node by id; the controls know their own parameter keys. This
74
76
  // is the one place that translation belongs.
@@ -97,14 +99,25 @@ export function FxNodeParams({
97
99
  if (before[key] === value) continue;
98
100
  if (typeof value !== "number" && typeof value !== "string") continue;
99
101
  trackParamCommitted(node.type, key, value, "details", { trackKind });
102
+ trackInput(typeof value === "number" ? "slider" : "select", key);
100
103
  }
101
104
  onUpdate(index, { params: next });
102
105
  }}
103
106
  automatedKeys={automatedKeysOf(node, def.params, automatedTargets)}
104
- onAutomate={nodeId && onAutomateParam ? (key) => onAutomateParam(nodeId, key) : undefined}
107
+ onAutomate={
108
+ nodeId && onAutomateParam
109
+ ? (key) => {
110
+ onAutomateParam(nodeId, key);
111
+ trackInput("button", "automate-param");
112
+ }
113
+ : undefined
114
+ }
105
115
  onRemoveAutomation={
106
116
  nodeId && onRemoveParamAutomation
107
- ? (key) => onRemoveParamAutomation(nodeId, key)
117
+ ? (key) => {
118
+ onRemoveParamAutomation(nodeId, key);
119
+ trackInput("button", "remove-param-automation");
120
+ }
108
121
  : undefined
109
122
  }
110
123
  />
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * One effect in the FX rack: its header controls, and its knobs when open.
3
4
  *
@@ -246,6 +247,7 @@ export function FxNodeRow({
246
247
  onPreview,
247
248
  trackKind,
248
249
  }: FxNodeRowProps) {
250
+ const trackInput = useTrackDesignInput();
249
251
  const registryDef = getAudioFxDef(node.type);
250
252
  const def = useMemo(() => (registryDef ? plainDef(registryDef) : null), [registryDef]);
251
253
  const primary = registryDef ? primaryParamOf(registryDef) : null;
@@ -306,6 +308,7 @@ export function FxNodeRow({
306
308
  onToggleBypass={() => {
307
309
  trackNodeBypassed(node.type, !bypassed, { trackKind });
308
310
  onUpdate(index, { enabled: bypassed });
311
+ trackInput("toggle", "effect-bypass");
309
312
  }}
310
313
  onMove={(delta) => onMove(index, delta)}
311
314
  onRemove={() => onRemove(index)}
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * One preset's bracket in the rack: its own header, on/off switch, amount lane
3
4
  * and the rows it wraps — or, for a run with no preset, just the rows.
@@ -93,6 +94,7 @@ export function FxPresetRun({
93
94
  onAutomate,
94
95
  onRemoveAutomation,
95
96
  }: FxPresetRunProps) {
97
+ const trackInput = useTrackDesignInput();
96
98
  const rows = run.items.map(({ node, i }) => (
97
99
  <FxNodeRow
98
100
  // Keyed by id, as the carve module's list above already is. On
@@ -183,7 +185,10 @@ export function FxPresetRun({
183
185
  aria-pressed={runOn}
184
186
  title={runOn ? `Switch ${preset.label} off` : `Switch ${preset.label} back on`}
185
187
  disabled={disabled}
186
- onClick={() => onSetAmount(runOn ? 0 : 1)}
188
+ onClick={() => {
189
+ onSetAmount(runOn ? 0 : 1);
190
+ trackInput("toggle", "preset-amount");
191
+ }}
187
192
  >
188
193
  {runOn ? "On" : "Off"}
189
194
  </button>
@@ -205,7 +210,10 @@ export function FxPresetRun({
205
210
  disabled={disabled || automated}
206
211
  automated={automated}
207
212
  onChange={(_k, v) => onSetAmount(Number(v), false)}
208
- onCommit={(_k, v) => onSetAmount(Number(v))}
213
+ onCommit={(_k, v) => {
214
+ onSetAmount(Number(v));
215
+ trackInput("slider", "preset-amount");
216
+ }}
209
217
  onAutomate={onAutomate}
210
218
  onRemoveAutomation={onRemoveAutomation}
211
219
  />
@@ -1787,3 +1787,59 @@ describe("voiceover carve visibility", () => {
1787
1787
  expect(carveBlock(host)).toBeTruthy();
1788
1788
  });
1789
1789
  });
1790
+
1791
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
1792
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
1793
+ import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
1794
+ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
1795
+ function renderTrackedFx(props: Partial<Parameters<typeof FxSection>[0]> = {}) {
1796
+ vi.mocked(trackStudioEvent).mockClear();
1797
+ __resetDesignInputThrottle();
1798
+ return renderInto(
1799
+ <DesignPanelInputProvider ui="flat" section="media">
1800
+ <FxSection
1801
+ chain={chainOf("lowpass", "gain")}
1802
+ onChainChange={vi.fn()}
1803
+ carve={null}
1804
+ onCarveChange={vi.fn()}
1805
+ sourceOptions={[]}
1806
+ {...props}
1807
+ />
1808
+ </DesignPanelInputProvider>,
1809
+ );
1810
+ }
1811
+
1812
+ describe("custom FX inspector input usage", () => {
1813
+ it.each([
1814
+ [".hf-fx-bypass", "toggle", "effect-bypass"],
1815
+ ['.hf-fx-move[title="Move down"]', "button", "move-effect"],
1816
+ [".hf-fx-remove", "button", "remove-effect"],
1817
+ ])("tracks %s through design_input once", (selector, control, name) => {
1818
+ const onChainChange = vi.fn();
1819
+ const { host, root } = renderTrackedFx({ onChainChange });
1820
+ click(host.querySelector(selector));
1821
+ expect(onChainChange).toHaveBeenCalledTimes(1);
1822
+ expect(
1823
+ vi.mocked(trackStudioEvent).mock.calls.filter(([event]) => event === "design_input"),
1824
+ ).toEqual([["design_input", { ui: "flat", section: "media", control, name }]]);
1825
+ expect(vi.mocked(trackStudioEvent).mock.calls.some(([event]) => event === "feature_used")).toBe(
1826
+ false,
1827
+ );
1828
+ act(() => root.unmount());
1829
+ });
1830
+ it.each([false, true])("tracks a levelling button once (already levelled: %s)", (levelled) => {
1831
+ const action = vi.fn();
1832
+ const { host, root } = renderTrackedFx({ levelled, onLevel: action, onRemoveLevel: action });
1833
+ click(byText(host, "button", "+ effect"));
1834
+ vi.mocked(trackStudioEvent).mockClear();
1835
+ click(byText(host, "button", levelled ? "Remove levelling" : "Even Out Levels"));
1836
+ expect(action).toHaveBeenCalledTimes(1);
1837
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("design_input", {
1838
+ ui: "flat",
1839
+ section: "media",
1840
+ control: "button",
1841
+ name: levelled ? "remove-levelling" : "add-levelling",
1842
+ });
1843
+ act(() => root.unmount());
1844
+ });
1845
+ });
@@ -1,3 +1,4 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
1
2
  /**
2
3
  * The FX section for an audio element: the chain, plus the voiceover carve.
3
4
  *
@@ -120,13 +121,17 @@ export function FxSection({
120
121
  // could only name a source it must not.
121
122
  const showCarve = !carvedAgainstBy && (sourceOptions.length > 0 || carve !== null);
122
123
 
124
+ const trackInput = useTrackDesignInput();
123
125
  const [adding, setAdding] = useState(false);
124
126
  const [picking, setPicking] = useState(false);
125
127
  const [openNode, setOpenNode] = useState<number | null>(0);
126
128
 
127
129
  const mutate = useCallback(
128
- (nodes: HfAudioFxNode[]) => onChainChange({ ...chain, nodes }),
129
- [chain, onChainChange],
130
+ (nodes: HfAudioFxNode[], input?: string, control = "button") => {
131
+ onChainChange({ ...chain, nodes });
132
+ if (input) trackInput(control, input);
133
+ },
134
+ [chain, onChainChange, trackInput],
130
135
  );
131
136
 
132
137
  // Dragging a knob previews without persisting; releasing it commits once.
@@ -156,7 +161,7 @@ export function FxSection({
156
161
  // old chain back over the write that just landed is a race the author
157
162
  // hears as the preset arriving and then leaving again.
158
163
  clearAudition();
159
- mutate(next.nodes);
164
+ mutate(next.nodes, "apply-preset");
160
165
  // Land on the first node the preset wrote, so the author can hear what
161
166
  // arrived and immediately see what it is made of.
162
167
  setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
@@ -169,7 +174,7 @@ export function FxSection({
169
174
  (job: HfAudioFxJob) => {
170
175
  clearAudition();
171
176
  trackNodeAdded(job.type, "job", job.id, { trackKind });
172
- mutate(withJob(chain, job).nodes);
177
+ mutate(withJob(chain, job).nodes, "add-job");
173
178
  setOpenNode(chain.nodes.length);
174
179
  setAdding(false);
175
180
  },
@@ -180,7 +185,7 @@ export function FxSection({
180
185
  (type: string) => {
181
186
  clearAudition();
182
187
  trackNodeAdded(type, "effect", null, { trackKind });
183
- mutate(withEffect(chain, type).nodes);
188
+ mutate(withEffect(chain, type).nodes, "add-effect");
184
189
  setOpenNode(chain.nodes.length);
185
190
  setAdding(false);
186
191
  },
@@ -218,26 +223,18 @@ export function FxSection({
218
223
  [chain, mutate, onChainPreview],
219
224
  );
220
225
 
221
- /**
222
- * Take a preset back out whole, lanes and all.
223
- *
224
- * Same contract as removing one node — an orphaned lane keeps driving a
225
- * parameter that is no longer in the graph, and with ids minted lowest-free
226
- * the next effect added inherits it.
227
- */
226
+ // Remove the run and its lanes together so new nodes cannot inherit orphaned automation.
228
227
  const removeRun = useCallback(
229
228
  (items: { node: HfAudioFxNode; i: number }[], presetId?: string) => {
230
- // One call, not a loop: every write is computed from the same snapshot and
231
- // replaces the whole attribute, so a loop kept only its last write and left
232
- // the other nodes' lanes behind as orphans. The preset id goes with it —
233
- // the `fx.preset.<id>` amount lane belongs to the preset, not to any node,
234
- // so nothing else would ever collect it, and re-applying the preset later
235
- // resurrected the old ramp.
229
+ // Remove node and preset automation together before replacing the chain.
236
230
  const ids = items.map(({ node }) => node.id).filter((id): id is string => Boolean(id));
237
231
  if (ids.length > 0 || presetId) onRemoveNodesAutomation?.(ids, presetId);
238
232
  if (presetId) trackPresetRemoved(presetId, { trackKind });
239
233
  const slots = new Set(items.map((item) => item.i));
240
- mutate(chain.nodes.filter((_, i) => !slots.has(i)));
234
+ mutate(
235
+ chain.nodes.filter((_, i) => !slots.has(i)),
236
+ "remove-preset",
237
+ );
241
238
  setOpenNode(null);
242
239
  },
243
240
  [chain.nodes, mutate, onRemoveNodesAutomation, trackKind],
@@ -254,7 +251,10 @@ export function FxSection({
254
251
  const removedId = removed?.id;
255
252
  if (removedId) onRemoveNodeAutomation?.(removedId);
256
253
  if (removed) trackNodeRemoved(removed.type, nodeOrigin(removed), { trackKind });
257
- mutate(chain.nodes.filter((_, i) => i !== index));
254
+ mutate(
255
+ chain.nodes.filter((_, i) => i !== index),
256
+ "remove-effect",
257
+ );
258
258
  setOpenNode(null);
259
259
  },
260
260
  [chain.nodes, mutate, onRemoveNodeAutomation, trackKind],
@@ -372,7 +372,7 @@ export function FxSection({
372
372
  clearAudition();
373
373
  const { chain: next, eqId } = addAudioEq(chain);
374
374
  trackNodeAdded("eq", "eq", null, { trackKind });
375
- mutate(next.nodes);
375
+ mutate(next.nodes, "add-eq");
376
376
  setOpenEq(eqId);
377
377
  setAdding(false);
378
378
  }, [chain, mutate, clearAudition, trackKind]);
@@ -386,20 +386,18 @@ export function FxSection({
386
386
  );
387
387
  const commitEqBand = useCallback(
388
388
  (eqId: string, band: string, gain: number) =>
389
- mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes),
389
+ mutate(setAudioEqBandGain(chain, eqId, band, gain).nodes, "eq-gain", "slider"),
390
390
  [chain, mutate],
391
391
  );
392
392
  const removeEq = useCallback(
393
393
  (eqId: string) => {
394
- // Batched for the same reason as `removeRun` — this loop had the identical
395
- // last-write-wins bug and was only unreachable because an EQ band row
396
- // offers no automation toggle today.
394
+ // Remove the EQ automation in one write.
397
395
  const ids = chain.nodes
398
396
  .filter((node) => node.fromEq === eqId)
399
397
  .map((node) => node.id)
400
398
  .filter((id): id is string => Boolean(id));
401
399
  if (ids.length > 0) onRemoveNodesAutomation?.(ids);
402
- mutate(removeAudioEq(chain, eqId).nodes);
400
+ mutate(removeAudioEq(chain, eqId).nodes, "remove-eq");
403
401
  },
404
402
  [chain, mutate, onRemoveNodesAutomation],
405
403
  );
@@ -412,7 +410,7 @@ export function FxSection({
412
410
  const [moved] = next.splice(index, 1);
413
411
  next.splice(target, 0, moved!);
414
412
  if (moved) trackNodeMoved(moved.type, delta < 0 ? "up" : "down", { trackKind });
415
- mutate(next);
413
+ mutate(next, "move-effect");
416
414
  setOpenNode(target);
417
415
  },
418
416
  [chain.nodes, mutate, trackKind],
@@ -448,6 +446,7 @@ export function FxSection({
448
446
  return () => {
449
447
  trackPresetAutomated(presetId, true, { trackKind });
450
448
  onAutomatePreset(presetId, amount);
449
+ trackInput("button", "automate-preset");
451
450
  };
452
451
  };
453
452
 
@@ -456,7 +455,10 @@ export function FxSection({
456
455
  presetId: string | undefined,
457
456
  ): (() => void) | undefined => {
458
457
  if (!presetId || !onRemovePresetAutomation || !presetAutomated.has(presetId)) return undefined;
459
- return () => onRemovePresetAutomation(presetId);
458
+ return () => {
459
+ onRemovePresetAutomation(presetId);
460
+ trackInput("button", "remove-preset-automation");
461
+ };
460
462
  };
461
463
 
462
464
  return (
@@ -82,12 +82,15 @@ export interface PropertyPanelProps {
82
82
  element: DomEditSelection,
83
83
  next: { x: number; y: number },
84
84
  route?: { plainTranslate: boolean },
85
- ) => void | Promise<void>;
85
+ ) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
86
86
  onSetManualSize: (
87
87
  element: DomEditSelection,
88
88
  next: { width: number; height: number },
89
- ) => void | Promise<void>;
90
- onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
89
+ ) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
90
+ onSetManualRotation: (
91
+ element: DomEditSelection,
92
+ next: { angle: number },
93
+ ) => void | Promise<void | import("../../utils/previewFeatureUsage").GeometryCommitResult>;
91
94
  onSetText: (value: string, fieldKey?: string) => void;
92
95
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
93
96
  onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -2,6 +2,8 @@
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
4
4
  import {
5
+ STUDIO_OFFSET_X_PROP,
6
+ STUDIO_OFFSET_Y_PROP,
5
7
  STUDIO_PATH_OFFSET_ATTR,
6
8
  STUDIO_ROTATION_ATTR,
7
9
  STUDIO_ROTATION_PROP,
@@ -14,6 +16,29 @@ describe("reapplyPositionEditsAfterSeek", () => {
14
16
  vi.restoreAllMocks();
15
17
  });
16
18
 
19
+ it("keeps an old offset off an element GSAP moves by object-of-arrays keyframes, not off its neighbour", () => {
20
+ const offset = `${STUDIO_OFFSET_X_PROP}: 300px; ${STUDIO_OFFSET_Y_PROP}: 100px; translate: none`;
21
+ document.body.innerHTML = ["moved", "still"]
22
+ .map((id) => `<div id="${id}" ${STUDIO_PATH_OFFSET_ATTR}="true" style="${offset}"></div>`)
23
+ .join("");
24
+ const moved = document.getElementById("moved") as HTMLElement;
25
+ const tween = { targets: () => [moved], vars: { keyframes: { x: [0, 200] } } };
26
+ const win = window as unknown as { __timelines?: unknown; gsap?: unknown };
27
+ const gsap = { set: vi.fn(), getProperty: () => 0 };
28
+ Object.assign(win, { __timelines: { main: { getChildren: () => [tween] } }, gsap });
29
+ try {
30
+ reapplyPositionEditsAfterSeek(document);
31
+ } finally {
32
+ delete win.__timelines;
33
+ delete win.gsap;
34
+ }
35
+
36
+ expect(moved.style.getPropertyValue("translate")).toBe("none");
37
+ expect(gsap.set).not.toHaveBeenCalled();
38
+ const still = document.getElementById("still") as HTMLElement;
39
+ expect(still.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
40
+ });
41
+
17
42
  it("does no per-edit work on a film Studio never edited", () => {
18
43
  document.body.innerHTML = '<div id="a"></div><div id="b"></div>';
19
44
  const queryAll = vi.spyOn(document, "querySelectorAll");
@@ -1,3 +1,4 @@
1
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
1
2
  // @vitest-environment happy-dom
2
3
  import React, { act } from "react";
3
4
  import { createRoot } from "react-dom/client";
@@ -423,3 +424,39 @@ describe("useDomEditNudge — a commit that throws", () => {
423
424
  act(() => root.unmount());
424
425
  });
425
426
  });
427
+
428
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
429
+ describe("nudge usage", () => {
430
+ beforeEach(() => {
431
+ vi.clearAllMocks();
432
+ vi.useFakeTimers();
433
+ __resetForTests();
434
+ });
435
+ afterEach(() => vi.useRealTimers());
436
+ it.each([true, false])(
437
+ "counts a whole key burst only when the writer changed source (%s)",
438
+ async (changed) => {
439
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
440
+ const element = document.body.appendChild(document.createElement("div"));
441
+ const commit = vi.fn().mockResolvedValue({ ok: true, changed });
442
+ act(() =>
443
+ root.render(
444
+ <Harness selection={makeSelection("Dot", element)} onPathOffsetCommit={commit} />,
445
+ ),
446
+ );
447
+ act(() => {
448
+ dispatchArrowRight();
449
+ dispatchArrowRight();
450
+ });
451
+ await act(async () => vi.advanceTimersByTimeAsync(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
452
+ expect(commit).toHaveBeenCalledTimes(1);
453
+ expect(vi.mocked(trackStudioEvent).mock.calls).toEqual(
454
+ changed
455
+ ? [["feature_used", { feature: "nudge", surface: "preview", method: "keyboard" }]]
456
+ : [],
457
+ );
458
+ act(() => root.unmount());
459
+ element.remove();
460
+ },
461
+ );
462
+ });
@@ -1,3 +1,4 @@
1
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
1
2
  /**
2
3
  * Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
3
4
  * element(s) 1 composition px, Shift = 10. Each keypress previews through the
@@ -173,6 +174,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
173
174
  for (const member of session.members)
174
175
  restoreStudioPathOffset(member.element, member.initialPathOffset);
175
176
  saved = Promise.resolve(commit)
177
+ .then((result) => trackPreviewEditResult("nudge", "keyboard", result))
176
178
  .catch(() => {
177
179
  for (const member of session.members) {
178
180
  if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
@@ -1,3 +1,4 @@
1
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
1
2
  // fallow-ignore-file code-duplication
2
3
  /**
3
4
  * Gesture handling for DomEditOverlay.
@@ -353,6 +354,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
353
354
  const groupSaved = Promise.resolve(
354
355
  groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
355
356
  )
357
+ .then((result) => trackPreviewEditResult("move", "drag", result))
356
358
  .catch(() => {
357
359
  for (const member of groupG.members) {
358
360
  if (
@@ -442,6 +444,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
442
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
443
445
  const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
444
446
  void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
447
+ .then((result) => trackPreviewEditResult("rotate", "drag", result))
445
448
  .catch((error) => {
446
449
  logGestureCommitFailure("rotate commit failed", error);
447
450
  if (
@@ -488,6 +491,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
488
491
  }),
489
492
  ),
490
493
  )
494
+ .then((result) => trackPreviewEditResult("move", "drag", result))
491
495
  .catch(() => {
492
496
  if (
493
497
  g.pathOffsetMember?.gestureToken &&
@@ -534,6 +538,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
534
538
  void Promise.resolve(
535
539
  opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
536
540
  )
541
+ .then((result) => trackPreviewEditResult("resize", "drag", result))
537
542
  .catch((error) => {
538
543
  logGestureCommitFailure("resize commit failed", error);
539
544
  })
@@ -1,3 +1,4 @@
1
+ import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
1
2
  import { useCallback, useMemo } from "react";
2
3
  import { useDomEditActionsContext } from "../../contexts/DomEditContext";
3
4
  import { readHfId, type DomEditSelection } from "./domEditing";
@@ -156,8 +157,13 @@ export function useDomEditZOrder(): DomEditZOrder {
156
157
  // One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
157
158
  // no second gesture interleaves (see runZLaneGesture). A failed z commit has already
158
159
  // toasted and rolled back, so the catch only keeps its rejection from going unhandled.
160
+ let changed = false;
159
161
  runZLaneGesture({
160
- commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
162
+ commitZ: async () => {
163
+ const result = await handleDomZIndexReorderCommit(entries, coalesceKey, action);
164
+ changed = result?.durable === true && result.changed;
165
+ return result;
166
+ },
161
167
  mirror: () =>
162
168
  mirrorZOrderToTimeline({
163
169
  selectionKey: entries.find((e) => e.element === sel.element)?.key,
@@ -166,7 +172,11 @@ export function useDomEditZOrder(): DomEditZOrder {
166
172
  sourceFile: sel.sourceFile,
167
173
  coalesceKey,
168
174
  }),
169
- }).catch(() => undefined);
175
+ })
176
+ .then(() => {
177
+ if (changed) trackPreviewFeatureUsed("z_order", "button");
178
+ })
179
+ .catch(() => undefined);
170
180
  return true;
171
181
  },
172
182
  [activeCompPath, handleDomZIndexReorderCommit, mirrorZOrderToTimeline, readOnly],
@@ -409,6 +409,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
409
409
  expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
410
410
  scaleAnimation.id,
411
411
  circleSelection,
412
+ false,
412
413
  );
413
414
  view.unmount();
414
415
  });
@@ -1,4 +1,5 @@
1
1
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
2
+ import { trackKeyframeUsage } from "../../utils/keyframeUsage";
2
3
  import { useCallback, useMemo } from "react";
3
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
5
  import type { TimelineElement } from "../../player";
@@ -259,7 +260,12 @@ export function useTimelineEditCallbacks({
259
260
  if (!selection) return;
260
261
  // Serial: each removal rewrites the same source file, so dispatching
261
262
  // them together would have the later writes read a pre-edit document.
262
- for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
263
+ let changed = false;
264
+ for (const anim of anims) {
265
+ const result = await handleGsapRemoveAllKeyframes(anim.id, selection, false);
266
+ changed = result || changed;
267
+ }
268
+ if (changed) trackKeyframeUsage("remove_all");
263
269
  });
264
270
  },
265
271
  onDeleteKeyframe: (elId, keyframe) => {
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { filename } from "./assetHelpers";
3
+ import { menuClasses } from "../ui/menuStyle";
3
4
 
4
5
  /** Reject names that would escape the asset directory or break paths. */
5
6
  function isValidAssetName(name: string): boolean {
@@ -94,8 +95,7 @@ export function ContextMenu({
94
95
  onClose();
95
96
  }, [renameDraft, asset, onRename, onClose]);
96
97
 
97
- const itemCls =
98
- "w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
98
+ const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
99
99
 
100
100
  return (
101
101
  <div
@@ -110,7 +110,7 @@ export function ContextMenu({
110
110
  ref={menuRef}
111
111
  role="menu"
112
112
  aria-label={`Actions for ${filename(asset)}`}
113
- className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[160px] text-xs"
113
+ className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
114
114
  style={{ left: pos.x, top: pos.y }}
115
115
  onClick={(e) => e.stopPropagation()}
116
116
  >
@@ -147,7 +147,7 @@ export function ContextMenu({
147
147
  <button
148
148
  role="menuitem"
149
149
  onClick={() => setMode("confirm-delete")}
150
- className={`${itemCls} text-danger-ink`}
150
+ className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
151
151
  >
152
152
  Delete
153
153
  </button>
@@ -0,0 +1,61 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { menuClasses } from "./menuStyle";
5
+
6
+ const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
7
+ const root = join(__dirname, "../..");
8
+
9
+ /** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
10
+ const NOT_ROW_MENUS = new Set([
11
+ "player/components/AudioGainDialog.tsx",
12
+ "components/editor/CropPresetBar.tsx",
13
+ "components/editor/EaseCurveSection.tsx",
14
+ "player/components/SpeedMenu.tsx",
15
+ ]);
16
+
17
+ function sources(dir: string): string[] {
18
+ return readdirSync(dir).flatMap((name) => {
19
+ const path = join(dir, name);
20
+ if (statSync(path).isDirectory()) return sources(path);
21
+ return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
22
+ });
23
+ }
24
+
25
+ const files = sources(root).map((path) => ({
26
+ name: relative(root, path).replaceAll("\\", "/"),
27
+ text: readFileSync(path, "utf8"),
28
+ }));
29
+
30
+ describe("menu style", () => {
31
+ it("adds no padding or margin around a menu's rows", () => {
32
+ for (const key of ["panel", "group", "divider"] as const)
33
+ expect(menuClasses[key], key).not.toMatch(spacing);
34
+ });
35
+
36
+ it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
37
+ expect(menuClasses.panel).toContain("overflow-hidden");
38
+ expect(menuClasses.panel).toMatch(/(^| )rounded-/);
39
+ });
40
+
41
+ it("is the only place a menu panel's fill, border and shadow are written", () => {
42
+ const handBuilt = files
43
+ .filter(({ name }) => !NOT_ROW_MENUS.has(name))
44
+ .filter(({ text }) =>
45
+ [...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
46
+ ([, literal]) =>
47
+ /shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
48
+ ),
49
+ )
50
+ .map(({ name }) => name);
51
+ expect(handBuilt).toEqual([]);
52
+ });
53
+
54
+ it("is the panel of every role=menu, wherever its element is written", () => {
55
+ const unowned = files
56
+ .filter(({ text }) => text.includes('role="menu"'))
57
+ .filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
58
+ .map(({ name }) => name);
59
+ expect(unowned).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,10 @@
1
+ /** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
2
+ export const menuClasses = {
3
+ panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
4
+ group: "empty:hidden border-b border-neutral-700/60",
5
+ divider: "border-t border-neutral-700/60",
6
+ row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
7
+ rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
8
+ rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
9
+ rowDisabled: "text-neutral-600 cursor-not-allowed",
10
+ } as const;
@@ -251,7 +251,7 @@ export function DomEditProvider({
251
251
  commitMutationRef.current = commitMutation;
252
252
 
253
253
  const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
254
- (mutation, options) => commitMutationRef.current(mutation, options),
254
+ (mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
255
255
  [],
256
256
  );
257
257
 
@@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
46
46
  onRazorSplit: true,
47
47
  onRazorSplitAll: true,
48
48
  onFreezeFrame: true,
49
+ clipMenuTools: true,
49
50
  onNotice: true,
50
51
  onDeleteKeyframe: true,
51
52
  onDeleteAllKeyframes: true,