@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.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/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.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 +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
2
3
  import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons";
3
4
  import type { DomEditSelection } from "./domEditing";
4
5
  import {
@@ -22,23 +23,29 @@ import {
22
23
  audioGainToFaderPosition,
23
24
  audioGainToText,
24
25
  } from "@hyperframes/core/audio-gain";
26
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
27
+ import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
25
28
 
26
29
  // fallow-ignore-next-line complexity
27
30
  export function MediaSection({
31
+ projectId = null,
28
32
  projectDir,
29
33
  element,
30
34
  styles,
31
35
  onSetStyle,
32
36
  onSetAttribute,
33
37
  onSetHtmlAttribute,
38
+ onSetAttributeBatch,
34
39
  onRemoveBackground,
35
40
  }: {
41
+ projectId?: string | null;
36
42
  projectDir: string | null;
37
43
  element: DomEditSelection;
38
44
  styles: Record<string, string>;
39
45
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
40
46
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
41
47
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
48
+ onSetAttributeBatch: CommitDomAttributeBatch;
42
49
  onRemoveBackground?: (
43
50
  inputPath: string,
44
51
  options: {
@@ -62,7 +69,11 @@ export function MediaSection({
62
69
 
63
70
  const hasLoop = el.hasAttribute("loop");
64
71
  const hasMuted = el.hasAttribute("muted");
65
- const hasAudio = element.dataAttributes["has-audio"] === "true";
72
+ const hasAudio = isVideoAudible({
73
+ tag: el.tagName,
74
+ hasAudioAttr: element.dataAttributes["has-audio"],
75
+ muted: el.hasAttribute("muted"),
76
+ });
66
77
 
67
78
  const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
68
79
 
@@ -96,13 +107,7 @@ export function MediaSection({
96
107
  setCreatePlate(false);
97
108
  }, [srcAttr]);
98
109
 
99
- const applyCutoutResult = async (result: BackgroundRemovalResult) => {
100
- await onSetHtmlAttribute("src", result.outputPath);
101
- if (isVideo) {
102
- await onSetAttribute("has-audio", "");
103
- await onSetHtmlAttribute("muted", "true");
104
- }
105
- };
110
+ const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
106
111
 
107
112
  const runBackgroundRemoval = async () => {
108
113
  if (!onRemoveBackground || !projectSrc || removeBusy) return;
@@ -115,13 +120,8 @@ export function MediaSection({
115
120
  quality,
116
121
  onProgress: setRemoveProgress,
117
122
  });
118
- await applyCutoutResult(result);
119
- setRemoveProgress({
120
- status: "complete",
121
- progress: 100,
122
- stage: "Applied cutout",
123
- ...result,
124
- });
123
+ const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
124
+ setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
125
125
  } catch (error) {
126
126
  setRemoveProgress({
127
127
  status: "failed",
@@ -320,9 +320,7 @@ export function MediaSection({
320
320
  <SegmentedControl
321
321
  trackName="Muted"
322
322
  value={hasMuted ? "on" : "off"}
323
- onChange={(next) => {
324
- void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
325
- }}
323
+ onChange={(next) => void commitMutedToggle(mediaEdit, next === "on")}
326
324
  options={[
327
325
  { label: "On", value: "on" },
328
326
  { label: "Off", value: "off" },
@@ -337,15 +335,7 @@ export function MediaSection({
337
335
  <SegmentedControl
338
336
  trackName="Has audio track"
339
337
  value={hasAudio ? "yes" : "no"}
340
- onChange={(next) => {
341
- if (next === "yes") {
342
- void onSetAttribute("has-audio", "true");
343
- void onSetHtmlAttribute("muted", null);
344
- } else {
345
- void onSetAttribute("has-audio", "");
346
- void onSetHtmlAttribute("muted", "true");
347
- }
348
- }}
338
+ onChange={(next) => void commitHasAudioToggle(mediaEdit, next === "yes")}
349
339
  options={[
350
340
  { label: "Yes", value: "yes" },
351
341
  { label: "No", value: "no" },
@@ -3,6 +3,7 @@ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-pa
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
5
5
  import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
6
7
 
7
8
  export interface BackgroundRemovalProgress {
8
9
  status: "processing" | "complete" | "failed";
@@ -68,6 +69,7 @@ export interface PropertyPanelProps {
68
69
  value: string | null,
69
70
  ) => Promise<{ changedFiles: number; changedElements: number }>;
70
71
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
72
+ onSetAttributeBatch: CommitDomAttributeBatch;
71
73
  onRemoveBackground?: (
72
74
  inputPath: string,
73
75
  options: {
@@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
6
6
  import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
7
7
  import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
8
8
  import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
9
10
  import type { DomEditSelection } from "./domEditing";
10
11
  import type { OverlayRect } from "./domEditOverlayGeometry";
11
12
 
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
27
28
  // Stable across renders on purpose: the test targets the `selection` identity
28
29
  // key specifically, so `groupSelections` must not itself be a source of churn.
29
30
  const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
31
+ let flushNudge = () => {};
30
32
 
31
33
  function Harness({
32
34
  selection,
@@ -35,7 +37,7 @@ function Harness({
35
37
  selection: DomEditSelection | null;
36
38
  onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
37
39
  }) {
38
- useDomEditNudge({
40
+ flushNudge = useDomEditNudge({
39
41
  selection,
40
42
  groupSelections: EMPTY_GROUP_SELECTIONS,
41
43
  allowCanvasMovement: true,
@@ -49,7 +51,7 @@ function Harness({
49
51
  onBlockedMoveRef: makeRef(() => {}),
50
52
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
51
53
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
52
- });
54
+ }).flushNudge;
53
55
  return null;
54
56
  }
55
57
 
@@ -259,6 +261,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
259
261
  });
260
262
  });
261
263
 
264
+ describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
265
+ beforeEach(() => {
266
+ vi.useFakeTimers();
267
+ __resetForTests();
268
+ });
269
+ let root: ReturnType<typeof createRoot> | null = null;
270
+ afterEach(() => {
271
+ act(() => root?.unmount());
272
+ root = null;
273
+ document.body.innerHTML = "";
274
+ vi.useRealTimers();
275
+ });
276
+
277
+ it.each(["video", "audio"])(
278
+ "does not nudge the selection while a <%s controls> has focus",
279
+ (tag) => {
280
+ root = createRoot(document.body.appendChild(document.createElement("div")));
281
+ const element = document.body.appendChild(document.createElement("div"));
282
+ element.id = "dot";
283
+ const player = document.body.appendChild(document.createElement(tag));
284
+ player.setAttribute("controls", "");
285
+ const commit = vi.fn();
286
+ act(() => {
287
+ root?.render(
288
+ React.createElement(Harness, {
289
+ selection: makeSelection("Dot", element),
290
+ onPathOffsetCommit: commit,
291
+ }),
292
+ );
293
+ });
294
+ const event = new KeyboardEvent("keydown", {
295
+ key: "ArrowRight",
296
+ bubbles: true,
297
+ cancelable: true,
298
+ });
299
+ act(() => {
300
+ player.dispatchEvent(event);
301
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
302
+ });
303
+ expect(event.defaultPrevented).toBe(false);
304
+ expect(commit).not.toHaveBeenCalled();
305
+ },
306
+ );
307
+ });
308
+
262
309
  describe("useDomEditNudge carries the route its press chose", () => {
263
310
  beforeEach(() => {
264
311
  vi.useFakeTimers();
@@ -291,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
291
338
  element.remove();
292
339
  });
293
340
  });
341
+
342
+ describe("useDomEditNudge — undo right after a burst", () => {
343
+ it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
344
+ __resetForTests();
345
+ vi.useFakeTimers();
346
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
347
+ const element = document.body.appendChild(document.createElement("div"));
348
+ element.id = "dot-undo";
349
+ let saved!: () => void;
350
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
351
+ act(() => {
352
+ root.render(
353
+ React.createElement(Harness, {
354
+ selection: makeSelection("Dot", element),
355
+ onPathOffsetCommit: commit,
356
+ }),
357
+ );
358
+ });
359
+ act(() => dispatchArrowRight());
360
+
361
+ let drained = false;
362
+ const drain = flushStudioPendingEdits().then(() => (drained = true));
363
+ expect(commit).toHaveBeenCalledTimes(1);
364
+ vi.useRealTimers();
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ expect(drained).toBe(false);
367
+ saved();
368
+ await drain;
369
+ expect(drained).toBe(true);
370
+ act(() => root.unmount());
371
+ });
372
+
373
+ it("counts the burst as a pending edit from its first key until its save lands", async () => {
374
+ __resetForTests();
375
+ vi.useFakeTimers();
376
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
377
+ const element = document.body.appendChild(document.createElement("div"));
378
+ element.id = "dot-pending";
379
+ let saved!: () => void;
380
+ const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
381
+ act(() => {
382
+ root.render(
383
+ React.createElement(Harness, {
384
+ selection: makeSelection("Dot", element),
385
+ onPathOffsetCommit: commit,
386
+ }),
387
+ );
388
+ });
389
+ expect(hasStudioPendingEdits()).toBe(false);
390
+ act(() => dispatchArrowRight());
391
+ expect(hasStudioPendingEdits()).toBe(true);
392
+ act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
393
+ expect(commit).toHaveBeenCalledTimes(1);
394
+ expect(hasStudioPendingEdits()).toBe(true);
395
+ vi.useRealTimers();
396
+ saved();
397
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
398
+ act(() => root.unmount());
399
+ });
400
+ });
401
+
402
+ describe("useDomEditNudge — a commit that throws", () => {
403
+ it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
404
+ __resetForTests();
405
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
406
+ const element = document.body.appendChild(document.createElement("div"));
407
+ element.id = "dot-throws";
408
+ const commit = vi.fn(() => {
409
+ throw new Error("The commit threw.");
410
+ });
411
+ act(() => {
412
+ root.render(
413
+ React.createElement(Harness, {
414
+ selection: makeSelection("Dot", element),
415
+ onPathOffsetCommit: commit,
416
+ }),
417
+ );
418
+ });
419
+ act(() => dispatchArrowRight());
420
+ expect(hasStudioPendingEdits()).toBe(true);
421
+ expect(() => flushNudge()).toThrow("The commit threw.");
422
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
423
+ act(() => root.unmount());
424
+ });
425
+ });
@@ -9,6 +9,10 @@ import { useCallback, useEffect, useRef, type RefObject } from "react";
9
9
  import { useMountEffect } from "../../hooks/useMountEffect";
10
10
  import { ownsPlainKeys } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
+ import {
13
+ addStudioPendingEditFlushListener,
14
+ beginStudioPendingEdit,
15
+ } from "../../utils/studioPendingEdits";
12
16
  import type { DomEditSelection } from "./domEditing";
13
17
  import {
14
18
  type GroupOverlayItem,
@@ -27,6 +31,7 @@ import {
27
31
  applyManualOffsetNudgeDraft,
28
32
  createManualOffsetDragMember,
29
33
  endManualOffsetDragMembers,
34
+ manualOffsetMoveRevert,
30
35
  restoreManualOffsetDragMembers,
31
36
  type ManualOffsetDragMember,
32
37
  } from "./manualOffsetDrag";
@@ -43,6 +48,7 @@ interface NudgeSession {
43
48
  /** Accumulated delta of the burst, in composition px. */
44
49
  accum: { x: number; y: number };
45
50
  timer: ReturnType<typeof setTimeout> | null;
51
+ edit: ReturnType<typeof beginStudioPendingEdit>;
46
52
  }
47
53
 
48
54
  export interface UseDomEditNudgeParams {
@@ -127,7 +133,7 @@ function resolveSingleNudgeTarget(
127
133
 
128
134
  /**
129
135
  * True when a keydown must not start/extend a nudge: canvas movement disabled,
130
- * a pointer gesture already owns the element, or the user is typing in a field.
136
+ * a pointer gesture already owns the element, or focus is in a field or a native player.
131
137
  */
132
138
  function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
133
139
  if (!p.allowCanvasMovement || event.defaultPrevented) return true;
@@ -143,31 +149,42 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
143
149
  // Commit the pending burst: one source write per burst = one undo entry.
144
150
  // Mirrors the drag's onPointerUp — same commit callbacks, same failure
145
151
  // restore, same member teardown.
146
- const commitSession = () => {
152
+ const commitSession = (): Promise<unknown> | undefined => {
147
153
  const session = sessionRef.current;
148
- if (!session) return;
154
+ if (!session) return undefined;
149
155
  sessionRef.current = null;
150
156
  if (session.timer) clearTimeout(session.timer);
151
- const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
152
- selection: member.selection,
153
- next: applyManualOffsetNudgeCommit(member, session.accum),
154
- plainTranslate: member.plainTranslate,
155
- }));
156
157
  const p = paramsRef.current;
157
- const commit = session.isGroup
158
- ? p.onGroupPathOffsetCommitRef.current(updates)
159
- : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
160
- plainTranslate: updates[0].plainTranslate,
161
- });
162
- void Promise.resolve(commit)
163
- .catch(() => {
164
- for (const member of session.members) {
165
- if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
166
- restoreStudioPathOffset(member.element, member.initialPathOffset);
158
+ let saved: Promise<unknown> | undefined;
159
+ try {
160
+ const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
161
+ selection: member.selection,
162
+ next: applyManualOffsetNudgeCommit(member, session.accum),
163
+ plainTranslate: member.plainTranslate,
164
+ }));
165
+ const commit = session.edit.adopt(() =>
166
+ session.isGroup
167
+ ? p.onGroupPathOffsetCommitRef.current(updates)
168
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
169
+ plainTranslate: updates[0].plainTranslate,
170
+ }),
171
+ );
172
+ if (session.edit.reverted())
173
+ for (const member of session.members)
174
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
175
+ saved = Promise.resolve(commit)
176
+ .catch(() => {
177
+ for (const member of session.members) {
178
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
179
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
180
+ }
167
181
  }
168
- }
169
- })
170
- .finally(() => endManualOffsetDragMembers(session.members));
182
+ })
183
+ .finally(() => endManualOffsetDragMembers(session.members));
184
+ return saved;
185
+ } finally {
186
+ session.edit.settle(saved);
187
+ }
171
188
  };
172
189
  const commitSessionRef = useRef(commitSession);
173
190
  commitSessionRef.current = commitSession;
@@ -179,6 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
179
196
  if (session.timer) clearTimeout(session.timer);
180
197
  restoreManualOffsetDragMembers(session.members);
181
198
  endManualOffsetDragMembers(session.members);
199
+ session.edit.settle();
182
200
  }, [params.allowCanvasMovement]);
183
201
 
184
202
  // Build drag members for the current target set — the same member snapshot a
@@ -205,7 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
205
223
  if (members.length === 0) return null;
206
224
  // Same side effect a drag start has (pauses preview playback).
207
225
  p.onManualDragStartRef.current?.();
208
- return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
226
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
227
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
209
228
  };
210
229
 
211
230
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -231,8 +250,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
231
250
  // Capture, like the other app-level key handlers, so a focused panel
232
251
  // can't swallow the nudge before it reaches us.
233
252
  window.addEventListener("keydown", listener, true);
253
+ const stopCommitOnUndoDrain = addStudioPendingEditFlushListener(() =>
254
+ commitSessionRef.current(),
255
+ );
234
256
  return () => {
235
257
  window.removeEventListener("keydown", listener, true);
258
+ stopCommitOnUndoDrain();
236
259
  commitSessionRef.current();
237
260
  };
238
261
  });
@@ -245,7 +268,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
245
268
  const selectionKey = selectionIdentityKey(params.selection);
246
269
  const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
247
270
  // eslint-disable-next-line no-restricted-syntax
248
- useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
271
+ useEffect(() => () => void commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
249
272
 
250
273
  // Claim the arrow keys from the playback frame-step while the selection is
251
274
  // nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
@@ -11,6 +11,7 @@ import {
11
11
  applyManualOffsetDragDraft,
12
12
  endManualOffsetDragMembers,
13
13
  restoreManualOffsetDragMembers,
14
+ manualOffsetMoveRevert,
14
15
  } from "./manualOffsetDrag";
15
16
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
16
17
  import {
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
57
58
  import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
58
59
  import { createGroupDragMover } from "./groupDragMove";
59
60
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
61
+ import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
60
62
 
61
63
  function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
62
64
  return (
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
344
346
  ),
345
347
  at: readDragPositions(groupG.members),
346
348
  });
347
- void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
349
+ const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
350
+ const groupSaved = Promise.resolve(
351
+ groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
352
+ )
348
353
  .catch(() => {
349
354
  for (const member of groupG.members) {
350
355
  if (
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
363
368
  // so this is where a snap-back would show.
364
369
  logDragSettle("settle", groupG.members);
365
370
  });
371
+ groupEdit.settle(groupSaved);
366
372
  return;
367
373
  }
368
374
 
@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
469
475
  box.style.left = `${nextBoxLeft}px`;
470
476
  box.style.top = `${nextBoxTop}px`;
471
477
  }
472
- void Promise.resolve(
473
- opts.onPathOffsetCommitRef.current(sel, finalOffset, {
474
- altKey: e.altKey,
475
- plainTranslate: g.pathOffsetMember.plainTranslate,
476
- }),
478
+ const member = g.pathOffsetMember;
479
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
480
+ const saved = Promise.resolve(
481
+ edit.adopt(() =>
482
+ opts.onPathOffsetCommitRef.current(sel, finalOffset, {
483
+ altKey: e.altKey,
484
+ plainTranslate: member.plainTranslate,
485
+ }),
486
+ ),
477
487
  )
478
488
  .catch(() => {
479
489
  if (
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
485
495
  .finally(() => {
486
496
  if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
487
497
  });
498
+ edit.settle(saved);
488
499
  } else {
489
500
  opts.suppressNextBoxClickRef.current = true;
490
501
  const finalSize = readStudioBoxSize(sel.element);
@@ -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({