@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
@@ -0,0 +1,67 @@
1
+ import { memo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
4
+ import { useClipToolState } from "./useClipToolState";
5
+
6
+ function SpeakerGlyph({ muted }: { muted: boolean }) {
7
+ return (
8
+ <svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
9
+ <path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
10
+ {muted ? (
11
+ <path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
12
+ ) : (
13
+ <path
14
+ d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
15
+ stroke="currentColor"
16
+ fill="none"
17
+ strokeWidth="1.4"
18
+ />
19
+ )}
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ function BadgeContent({ badge }: { badge: ClipBadge }) {
25
+ if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
26
+ if (badge.kind === "volume") {
27
+ const muted = badge.label === "Muted";
28
+ return (
29
+ <>
30
+ <SpeakerGlyph muted={muted} />
31
+ {muted ? null : <span>{badge.label}</span>}
32
+ </>
33
+ );
34
+ }
35
+ return <span>{badge.label}</span>;
36
+ }
37
+
38
+ const BADGE_CLASS =
39
+ "inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
40
+
41
+ /** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
42
+ export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
43
+ const state = useClipToolState(el);
44
+ const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
45
+ if (visible.length === 0) return null;
46
+ return (
47
+ <span
48
+ className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
49
+ data-testid="clip-badges"
50
+ >
51
+ {visible.map((badge) => (
52
+ <span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
53
+ <BadgeContent badge={badge} />
54
+ </span>
55
+ ))}
56
+ {hidden.length > 0 && (
57
+ <span
58
+ className={`${BADGE_CLASS} pointer-events-auto`}
59
+ title={hidden.map((badge) => badge.label).join(", ")}
60
+ data-badge="more"
61
+ >
62
+ +{hidden.length}
63
+ </span>
64
+ )}
65
+ </span>
66
+ );
67
+ });
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ClipContextMenu } from "./ClipContextMenu";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
8
+
9
+ const iframe = document.createElement("iframe");
10
+ vi.mock("../../contexts/StudioContext", () => ({
11
+ useStudioShellContextOptional: () => ({
12
+ projectId: "p1",
13
+ showToast: vi.fn(),
14
+ previewIframeRef: { current: iframe },
15
+ }),
16
+ }));
17
+
18
+ const harness = createHappyDomRootHarness();
19
+
20
+ const talk: TimelineElement = {
21
+ id: "talk",
22
+ domId: "talk",
23
+ tag: "video",
24
+ src: "talk.mp4",
25
+ start: 0,
26
+ duration: 6,
27
+ track: 0,
28
+ hasAudio: true,
29
+ link: "lk-1",
30
+ };
31
+ const talkAudio: TimelineElement = {
32
+ ...talk,
33
+ id: "talk-a",
34
+ domId: "talk-a",
35
+ tag: "audio",
36
+ track: 1,
37
+ };
38
+
39
+ function renderMenu(element: TimelineElement) {
40
+ usePlayerStore.getState().setElements([talk, talkAudio]);
41
+ document.body.appendChild(iframe);
42
+ const node = iframe.contentDocument?.createElement("video");
43
+ if (node) {
44
+ node.id = "talk";
45
+ node.setAttribute("data-has-audio", "true");
46
+ iframe.contentDocument?.body.appendChild(node);
47
+ }
48
+ const host = document.createElement("div");
49
+ document.body.appendChild(host);
50
+ const noop = vi.fn();
51
+ act(() =>
52
+ harness.mount(host).render(
53
+ <TimelineEditProvider
54
+ value={{
55
+ onSetElementAttributeQuiet: vi.fn(async () => undefined),
56
+ onFreezeFrame: noop,
57
+ onLinkEdit: noop,
58
+ onDeleteElementOnly: noop,
59
+ }}
60
+ >
61
+ <ClipContextMenu
62
+ x={10}
63
+ y={10}
64
+ element={element}
65
+ currentTime={2}
66
+ onClose={noop}
67
+ onSplit={noop}
68
+ onDelete={noop}
69
+ />
70
+ </TimelineEditProvider>,
71
+ ),
72
+ );
73
+ }
74
+
75
+ function menuLabels(): string[] {
76
+ return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map(
77
+ (item) => item.querySelector("span")?.textContent ?? "",
78
+ );
79
+ }
80
+
81
+ describe("ClipContextMenu order", () => {
82
+ it("groups time, sound, picture, then delete for a linked video with sound", () => {
83
+ renderMenu(talk);
84
+ const expected = [
85
+ "Split at 2.00s",
86
+ "Freeze frame",
87
+ "Normalize loudness",
88
+ "Voice",
89
+ "Detach audio",
90
+ "Unlink from audio",
91
+ "Look",
92
+ "Crop",
93
+ "Delete",
94
+ "Delete this clip only",
95
+ ];
96
+ expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected);
97
+ const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) =>
98
+ group.getAttribute("aria-label"),
99
+ );
100
+ expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]);
101
+ });
102
+ });
@@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => {
60
60
  unmount();
61
61
  });
62
62
 
63
- it("counts host rows and their divider when keeping the menu inside the window", () => {
63
+ it("keeps the menu, host rows included, inside the window by its measured height", () => {
64
64
  const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
65
65
  Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
66
+ const offsetHeight = vi
67
+ .spyOn(HTMLElement.prototype, "offsetHeight", "get")
68
+ .mockImplementation(function (this: HTMLElement) {
69
+ return this.getAttribute("role") === "menu" ? 150 : 0;
70
+ });
66
71
  try {
67
72
  const host = document.createElement("div");
68
73
  document.body.appendChild(host);
@@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => {
86
91
  />,
87
92
  ),
88
93
  );
89
- // Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
90
- expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
94
+ // 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge.
95
+ expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("442px");
91
96
  act(() => root.unmount());
92
97
  } finally {
98
+ offsetHeight.mockRestore();
93
99
  if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
94
100
  }
95
101
  });
@@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => {
106
112
  it("shows only Studio's items when the host adds none", () => {
107
113
  const { items, unmount } = renderMenu([]);
108
114
  expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
109
- expect(document.body.querySelectorAll(".border-t")).toHaveLength(1);
115
+ expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
110
116
  unmount();
111
117
  });
112
118
  });
@@ -1,10 +1,31 @@
1
- import { memo } from "react";
1
+ import { memo, useLayoutEffect, useState, type RefObject } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
7
7
  import type { TimelineClipMenuItem } from "./TimelineTypes";
8
+ import { ClipMenuToolItems } from "./clipMenuToolItems";
9
+ import { ClipMenuAudioItems } from "./clipMenuAudioItems";
10
+ import { ClipMenuLinkItems } from "./clipMenuLinkItems";
11
+
12
+ const MENU_MARGIN = 8;
13
+ // Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
14
+ const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
15
+
16
+ function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
17
+ const [height, setHeight] = useState(0);
18
+ useLayoutEffect(() => {
19
+ const node = ref.current;
20
+ if (!node) return;
21
+ setHeight(node.offsetHeight);
22
+ if (typeof ResizeObserver === "undefined") return;
23
+ const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
24
+ observer.observe(node);
25
+ return () => observer.disconnect();
26
+ }, [ref, anchorKey]);
27
+ return height;
28
+ }
8
29
 
9
30
  interface ClipContextMenuProps {
10
31
  x: number;
@@ -60,7 +81,6 @@ function HostItems({
60
81
  )}
61
82
  </button>
62
83
  ))}
63
- <div className="my-1 border-t border-neutral-700/60" />
64
84
  </>
65
85
  );
66
86
  }
@@ -103,16 +123,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
103
123
  ? `Split at ${currentTime.toFixed(2)}s`
104
124
  : "Split (move playhead inside clip)";
105
125
 
106
- const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
107
- const hostRows = hostItems.length;
108
- const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
109
- const dividerCount =
110
- (hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
111
126
  const menuWidth = 200;
112
- const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
113
- const overflowY = y + menuHeight - window.innerHeight;
114
127
  const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
115
- const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
128
+ const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`);
129
+ const overflowY = y + menuHeight - window.innerHeight;
130
+ const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y;
116
131
 
117
132
  return createPortal(
118
133
  <div
@@ -122,9 +137,13 @@ export const ClipContextMenu = memo(function ClipContextMenu({
122
137
  className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
123
138
  style={{ left: adjustedX, top: adjustedY }}
124
139
  >
125
- {hostRows > 0 && <HostItems items={hostItems} onClose={onClose} />}
126
- {splitLabel && (
127
- <>
140
+ {hostItems.length > 0 && (
141
+ <div role="group" aria-label="Host" className={GROUP_CLASS}>
142
+ <HostItems items={hostItems} onClose={onClose} />
143
+ </div>
144
+ )}
145
+ <div role="group" aria-label="Time" className={GROUP_CLASS}>
146
+ {splitLabel && (
128
147
  <button
129
148
  type="button"
130
149
  role="menuitem"
@@ -144,72 +163,100 @@ export const ClipContextMenu = memo(function ClipContextMenu({
144
163
  <span>{splitLabel}</span>
145
164
  <span className="text-neutral-500 text-[10px] ml-3">S</span>
146
165
  </button>
147
- <div className="my-1 border-t border-neutral-700/60" />
148
- </>
149
- )}
166
+ )}
167
+ {splitLabel && (
168
+ <ClipMenuToolItems
169
+ group="time"
170
+ element={element}
171
+ currentTime={currentTime}
172
+ onClose={onClose}
173
+ />
174
+ )}
175
+ </div>
150
176
 
151
- {(onCopy || onPaste || onDuplicate) && (
152
- <>
153
- {onCopy && (
154
- <button
155
- type="button"
156
- role="menuitem"
157
- className={itemClass(true)}
158
- onClick={() => {
159
- onCopy();
160
- onClose();
161
- }}
162
- >
163
- <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
164
- <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
165
- </button>
166
- )}
167
- {onPaste && (
168
- <button
169
- type="button"
170
- role="menuitem"
171
- className={itemClass(!!canPaste)}
172
- disabled={!canPaste}
173
- onClick={() => {
174
- if (!canPaste) return;
175
- void onPaste();
176
- onClose();
177
- }}
178
- >
179
- <span>Paste</span>
180
- <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
181
- </button>
182
- )}
183
- {onDuplicate && (
184
- <button
185
- type="button"
186
- role="menuitem"
187
- className={itemClass(true)}
188
- onClick={() => {
189
- void onDuplicate();
190
- onClose();
191
- }}
192
- >
193
- <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
194
- <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
195
- </button>
196
- )}
197
- <div className="my-1 border-t border-neutral-700/60" />
198
- </>
199
- )}
177
+ <div role="group" aria-label="Sound" className={GROUP_CLASS}>
178
+ <ClipMenuAudioItems part="normalize" element={element} onClose={onClose} />
179
+ <ClipMenuToolItems
180
+ group="sound"
181
+ element={element}
182
+ currentTime={currentTime}
183
+ onClose={onClose}
184
+ />
185
+ <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
186
+ <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
187
+ </div>
188
+
189
+ <div role="group" aria-label="Picture" className={GROUP_CLASS}>
190
+ <ClipMenuToolItems
191
+ group="picture"
192
+ element={element}
193
+ currentTime={currentTime}
194
+ onClose={onClose}
195
+ />
196
+ </div>
200
197
 
201
- <button
202
- type="button"
203
- role="menuitem"
204
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
205
- onClick={() => {
206
- onDelete(element);
207
- onClose();
208
- }}
209
- >
210
- <span>Delete</span>
211
- <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
212
- </button>
198
+ <div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
199
+ {onCopy && (
200
+ <button
201
+ type="button"
202
+ role="menuitem"
203
+ className={itemClass(true)}
204
+ onClick={() => {
205
+ onCopy();
206
+ onClose();
207
+ }}
208
+ >
209
+ <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
210
+ <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
211
+ </button>
212
+ )}
213
+ {onPaste && (
214
+ <button
215
+ type="button"
216
+ role="menuitem"
217
+ className={itemClass(!!canPaste)}
218
+ disabled={!canPaste}
219
+ onClick={() => {
220
+ if (!canPaste) return;
221
+ void onPaste();
222
+ onClose();
223
+ }}
224
+ >
225
+ <span>Paste</span>
226
+ <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
227
+ </button>
228
+ )}
229
+ {onDuplicate && (
230
+ <button
231
+ type="button"
232
+ role="menuitem"
233
+ className={itemClass(true)}
234
+ onClick={() => {
235
+ void onDuplicate();
236
+ onClose();
237
+ }}
238
+ >
239
+ <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
240
+ <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
241
+ </button>
242
+ )}
243
+ </div>
244
+
245
+ <div role="group" aria-label="Delete">
246
+ <button
247
+ type="button"
248
+ role="menuitem"
249
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
250
+ onClick={() => {
251
+ onDelete(element);
252
+ onClose();
253
+ }}
254
+ >
255
+ <span>Delete</span>
256
+ <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
257
+ </button>
258
+ <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
259
+ </div>
213
260
  </div>,
214
261
  document.body,
215
262
  );
@@ -0,0 +1,41 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ClipPeakMarks } from "./ClipPeakMarks";
6
+
7
+ const harness = createHappyDomRootHarness();
8
+
9
+ async function render(url: string, bins: number[], gain: number) {
10
+ vi.stubGlobal(
11
+ "fetch",
12
+ vi.fn(async () => Response.json({ binSeconds: 1, bins })),
13
+ );
14
+ const host = document.createElement("div");
15
+ document.body.appendChild(host);
16
+ await act(async () => {
17
+ harness.mount(host).render(
18
+ <ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={gain}>
19
+ <span>wave</span>
20
+ </ClipPeakMarks>,
21
+ );
22
+ });
23
+ await act(async () => {});
24
+ vi.unstubAllGlobals();
25
+ return host;
26
+ }
27
+
28
+ describe("ClipPeakMarks", () => {
29
+ it("paints red marks and the peak on a clip that redlines", async () => {
30
+ const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
31
+ expect(host.textContent).toContain("wave");
32
+ expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
33
+ "▲ peaks −0.2 dBFS",
34
+ );
35
+ });
36
+
37
+ it("paints nothing on a quiet clip", async () => {
38
+ const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
39
+ expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
40
+ });
41
+ });
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { clipPeakRuns, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
3
+
4
+ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
5
+
6
+ function loadPeakMap(url: string): Promise<PeakMap | null> {
7
+ const pending = peakMapRequests.get(url);
8
+ if (pending) return pending;
9
+ const request = fetch(url)
10
+ .then((res) => (res.ok ? res.json() : null))
11
+ .then((body: unknown) => (isPeakMap(body) ? body : null))
12
+ .catch(() => null);
13
+ peakMapRequests.set(url, request);
14
+ return request;
15
+ }
16
+
17
+ const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
18
+
19
+ /** A clip's waveform with red marks where it, at its own volume, reaches the export ceiling. */
20
+ export function ClipPeakMarks({
21
+ peaksUrl,
22
+ sourceWindow,
23
+ gain,
24
+ children,
25
+ }: {
26
+ peaksUrl: string | undefined;
27
+ sourceWindow: ClipSourceWindow;
28
+ gain: number;
29
+ children?: ReactNode;
30
+ }) {
31
+ const [map, setMap] = useState<PeakMap | null>(null);
32
+ useEffect(() => {
33
+ if (!peaksUrl) return;
34
+ let live = true;
35
+ void loadPeakMap(peaksUrl).then((loaded) => {
36
+ if (live) setMap(loaded);
37
+ });
38
+ return () => {
39
+ live = false;
40
+ };
41
+ }, [peaksUrl]);
42
+ const { runs, peakDbfs } = map
43
+ ? clipPeakRuns(map, sourceWindow, gain)
44
+ : { runs: [], peakDbfs: null };
45
+ return (
46
+ <div className="relative h-full w-full">
47
+ {children}
48
+ {runs.length > 0 && peakDbfs !== null && (
49
+ <div
50
+ className="pointer-events-none absolute inset-0"
51
+ data-testid="clip-peak-marks"
52
+ title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
53
+ >
54
+ {runs.map((run) => (
55
+ <div
56
+ key={run.from}
57
+ className="absolute inset-y-0 bg-red-500/80"
58
+ style={{
59
+ left: `${run.from * 100}%`,
60
+ width: `max(1px, ${(run.to - run.from) * 100}%)`,
61
+ }}
62
+ />
63
+ ))}
64
+ <span className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400">
65
+ ▲ peaks {dbText(peakDbfs)}
66
+ </span>
67
+ </div>
68
+ )}
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,91 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
5
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { OutOfSyncBadge } from "./OutOfSyncBadge";
8
+ import { createHappyDomRootHarness } from "./testRootHarness";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ const harness = createHappyDomRootHarness();
13
+ const clip = (id: string, tag: string, start: number): TimelineElement => ({
14
+ id,
15
+ domId: id,
16
+ tag,
17
+ start,
18
+ duration: 4,
19
+ track: 0,
20
+ playbackStart: 0,
21
+ syncOrigin: "lk-1",
22
+ });
23
+ const video = clip("talk", "video", 1);
24
+ const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
25
+
26
+ afterEach(() =>
27
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
28
+ );
29
+
30
+ function render(el: TimelineElement, onLinkEdit = vi.fn()) {
31
+ usePlayerStore.getState().setElements([video, lateAudio]);
32
+ const host = document.body.appendChild(document.createElement("div"));
33
+ act(() =>
34
+ harness.mount(host).render(
35
+ <TimelineEditProvider value={{ onLinkEdit }}>
36
+ <OutOfSyncBadge el={el} />
37
+ </TimelineEditProvider>,
38
+ ),
39
+ );
40
+ const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
41
+ return { badge, onLinkEdit };
42
+ }
43
+
44
+ describe("OutOfSyncBadge", () => {
45
+ it("shows the signed offset on both halves", () => {
46
+ useLinkedClipPreferences.setState({ compositionFps: 30 });
47
+ expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
48
+ document.body.innerHTML = "";
49
+ expect(render(video).badge()?.textContent).toBe("-1:21");
50
+ });
51
+
52
+ it("hides when the preference is off", () => {
53
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
54
+ expect(render(lateAudio).badge()).toBeNull();
55
+ });
56
+
57
+ it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
58
+ const { badge, onLinkEdit } = render(lateAudio);
59
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
60
+ const items = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
61
+ expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
62
+ act(() => items[0]?.click());
63
+ expect(onLinkEdit).toHaveBeenCalledWith(
64
+ expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
65
+ );
66
+ });
67
+
68
+ it("slips from a right-click, and the click never reaches the clip", () => {
69
+ const clipClick = vi.fn();
70
+ document.body.addEventListener("contextmenu", clipClick);
71
+ const { badge, onLinkEdit } = render(lateAudio);
72
+ act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
73
+ expect(clipClick).not.toHaveBeenCalled();
74
+ const slip = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
75
+ act(() => slip?.click());
76
+ const edit = onLinkEdit.mock.calls[0]?.[0];
77
+ expect(edit.kind).toBe("slip-into-sync");
78
+ expect(edit.mediaStart).toBeCloseTo(51 / 30);
79
+ document.body.removeEventListener("contextmenu", clipClick);
80
+ });
81
+
82
+ it("flips the menu above the pointer near the bottom edge", () => {
83
+ const { badge } = render(lateAudio);
84
+ const y = window.innerHeight - 10;
85
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
86
+ const menu = document.querySelector<HTMLElement>('[role="menu"]');
87
+ const top = Number.parseFloat(menu?.style.top ?? "");
88
+ expect(top).toBeLessThan(y);
89
+ expect(top).toBeGreaterThanOrEqual(0);
90
+ });
91
+ });