@hyperframes/studio 0.8.109 → 0.8.111

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
  2. package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
  3. package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
  6. package/dist/index.d.ts +22 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5352 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +5 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/studioPendingEdits.test.ts +18 -0
  92. package/src/utils/typingTarget.test.ts +8 -0
  93. package/src/utils/typingTarget.ts +1 -0
@@ -1,18 +1,10 @@
1
- import { formatAudioGain } from "@hyperframes/core/audio-gain";
2
1
  import type { TimelineElement } from "../store/timelineElement";
3
2
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
4
3
  import { useStudioShellContextOptional } from "../../contexts/StudioContext";
5
4
  import { usePlayerStore } from "../store/playerStore";
6
5
  import { useLivePreviewIframe } from "../store/previewIframeStore";
7
- import {
8
- clipHasSound,
9
- normalizeToastText,
10
- requestNormalizePlan,
11
- throwUnlessSaved,
12
- TimelineSaveError,
13
- VOLUME_LANE_REFUSAL,
14
- volumeLaneOwnsGain,
15
- } from "./clipMenuNormalize";
6
+ import { clipHasSound, throwUnlessSaved, TimelineSaveError } from "./clipMenuNormalize";
7
+ import { openAudioGainDialog } from "./audioGainDialogStore";
16
8
  import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
17
9
 
18
10
  const ITEM_CLASS =
@@ -25,13 +17,13 @@ const DUCK_TOAST: Record<DuckOutcome, string> = {
25
17
  aborted: "Could not group the voices to duck under.",
26
18
  };
27
19
 
28
- /** Normalize loudness (one-shot) or Duck under voice (toggle), placed separately in the sound group. */
20
+ /** Audio Gain… (opens the G dialog) or Duck under voice (toggle), placed separately in the sound group. */
29
21
  export function ClipMenuAudioItems({
30
22
  part,
31
23
  element,
32
24
  onClose,
33
25
  }: {
34
- part: "normalize" | "duck";
26
+ part: "gain" | "duck";
35
27
  element: TimelineElement;
36
28
  onClose: () => void;
37
29
  }) {
@@ -48,28 +40,6 @@ export function ClipMenuAudioItems({
48
40
  return null;
49
41
  }
50
42
 
51
- const normalize = async () => {
52
- onClose();
53
- if (volumeLaneOwnsGain(element)) {
54
- showToast(VOLUME_LANE_REFUSAL, "error");
55
- return;
56
- }
57
- try {
58
- const plan = await requestNormalizePlan(projectId, element);
59
- throwUnlessSaved(
60
- await onSetElementAttributeQuiet(
61
- element,
62
- "data-volume",
63
- formatAudioGain(plan.volume),
64
- "Normalize loudness",
65
- ),
66
- );
67
- showToast(normalizeToastText(plan), "info");
68
- } catch (error) {
69
- showToast(error instanceof Error ? error.message : String(error), "error");
70
- }
71
- };
72
-
73
43
  const toggleDuck = async () => {
74
44
  onClose();
75
45
  if (!doc || !bed) return;
@@ -93,10 +63,19 @@ export function ClipMenuAudioItems({
93
63
  showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
94
64
  };
95
65
 
96
- if (part === "normalize") {
66
+ if (part === "gain") {
97
67
  return (
98
- <button type="button" role="menuitem" className={ITEM_CLASS} onClick={() => void normalize()}>
99
- <span>Normalize loudness</span>
68
+ <button
69
+ type="button"
70
+ role="menuitem"
71
+ className={ITEM_CLASS}
72
+ onClick={() => {
73
+ onClose();
74
+ openAudioGainDialog(element);
75
+ }}
76
+ >
77
+ <span>Audio Gain…</span>
78
+ <span className="text-neutral-500 text-[10px] ml-3">G</span>
100
79
  </button>
101
80
  );
102
81
  }
@@ -48,6 +48,51 @@ describe("resolveLinkMenuItems", () => {
48
48
  expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video");
49
49
  });
50
50
 
51
+ it("offers Link to the lone same-file partner on another track when one clip is right-clicked", () => {
52
+ const video = clip("talk", "video", { track: 0 });
53
+ const audio = clip("talk-audio", "audio", { track: 1, start: 2 });
54
+ expect(labels(audio, [video, audio], ["talk-audio"])[0]).toBe("Link to video");
55
+ expect(labels(video, [video, audio])[0]).toBe("Link to audio");
56
+ });
57
+
58
+ it("dispatches the convenience link with the clip and its partner", () => {
59
+ const onLinkEdit = vi.fn();
60
+ const video = clip("talk", "video", { track: 0 });
61
+ const audio = clip("talk-audio", "audio", { track: 1 });
62
+ const [link] = resolveLinkMenuItems({
63
+ element: audio,
64
+ elements: [video, audio],
65
+ selectedKeys: new Set(["talk-audio"]),
66
+ onLinkEdit,
67
+ });
68
+ link?.run();
69
+ expect(onLinkEdit).toHaveBeenCalledWith({ kind: "link", elements: [audio, video] });
70
+ });
71
+
72
+ it("offers no convenience Link when the partner is ambiguous, linked, another file or the same track", () => {
73
+ const audio = clip("talk-audio", "audio", { track: 1 });
74
+ const video = clip("talk", "video", { track: 0 });
75
+ const twin = clip("talk-2", "video", { track: 2 });
76
+ expect(labels(audio, [video, twin, audio], ["talk-audio"])).not.toContain("Link to video");
77
+ const linked = clip("talk", "video", { track: 0, link: "lk-9" });
78
+ expect(labels(audio, [linked, audio], ["talk-audio"])).not.toContain("Link to video");
79
+ const other = clip("other", "video", { track: 0, src: "other.mp4" });
80
+ expect(labels(audio, [other, audio], ["talk-audio"])).not.toContain("Link to video");
81
+ const sameTrack = clip("talk", "video", { track: 1 });
82
+ expect(labels(audio, [sameTrack, audio], ["talk-audio"])).not.toContain("Link to video");
83
+ });
84
+
85
+ it("offers no convenience Link while other clips are also selected", () => {
86
+ const audio = clip("talk-audio", "audio", { track: 1 });
87
+ const video = clip("talk", "video", { track: 0 });
88
+ const title = clip("title", "div", { track: 2 });
89
+ expect(
90
+ labels(audio, [video, audio, title], ["talk-audio", "title"]).some((l) =>
91
+ l.startsWith("Link"),
92
+ ),
93
+ ).toBe(false);
94
+ });
95
+
51
96
  it("names the audio partner when unlinking from the video side", () => {
52
97
  const video = clip("talk", "video", { muted: true, link: "lk-1" });
53
98
  const audio = clip("talk-audio", "audio", { link: "lk-1" });
@@ -87,4 +132,43 @@ describe("resolveLinkMenuItems", () => {
87
132
  })[0]?.run();
88
133
  expect(onLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
89
134
  });
135
+
136
+ it("offers Link for a trimmed audio and its video (timing does not matter)", () => {
137
+ const video = clip("talk", "video", { muted: true });
138
+ const audio = clip("talk-audio", "audio", { start: 1, duration: 2, playbackStart: 1 });
139
+ expect(labels(video, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to audio");
140
+ });
141
+
142
+ it("offers Link for a pair of different files", () => {
143
+ const video = clip("talk", "video", { muted: true });
144
+ const music = clip("bgm", "audio", { src: "bgm.mp3" });
145
+ expect(labels(music, [video, music], ["talk", "bgm"])).toEqual(["Link to video"]);
146
+ });
147
+
148
+ it("offers no Link when one selected clip is already linked elsewhere", () => {
149
+ const video = clip("talk", "video", { muted: true, link: "lk-9" });
150
+ const audio = clip("bgm", "audio", { src: "bgm.mp3" });
151
+ const partner = clip("talk-audio", "audio", { link: "lk-9" });
152
+ expect(labels(audio, [video, audio, partner], ["talk", "bgm"])).toEqual([]);
153
+ });
154
+
155
+ it("disables Merge back with a tooltip while the pair is out of sync", () => {
156
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
157
+ const audio = clip("talk-audio", "audio", { link: "lk-1", start: 0.5 });
158
+ const merge = resolveLinkMenuItems({
159
+ element: audio,
160
+ elements: [video, audio],
161
+ selectedKeys: new Set(),
162
+ onLinkEdit: vi.fn(),
163
+ }).find((item) => item.label === "Merge audio back into video");
164
+ expect(merge?.disabledReason).toBe("Move into Sync first");
165
+ const synced = clip("talk-audio", "audio", { link: "lk-1" });
166
+ const enabled = resolveLinkMenuItems({
167
+ element: synced,
168
+ elements: [video, synced],
169
+ selectedKeys: new Set(),
170
+ onLinkEdit: vi.fn(),
171
+ }).find((item) => item.label === "Merge audio back into video");
172
+ expect(enabled?.disabledReason).toBeUndefined();
173
+ });
90
174
  });
@@ -2,12 +2,19 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
2
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
3
3
  import type { TimelineLinkEdit } from "./timelineCallbacks";
4
4
  import { linkedMembersOf } from "./audioClipLink";
5
- import { canDetachAudio, canLinkPair, findMergePair } from "../../components/editor/mediaLinkEdits";
5
+ import {
6
+ canDetachAudio,
7
+ canLinkPair,
8
+ findMergePair,
9
+ isPairInSync,
10
+ sharesSourceFile,
11
+ } from "../../components/editor/mediaLinkEdits";
6
12
 
7
13
  interface LinkMenuItem {
8
14
  label: string;
9
15
  shortcut?: string;
10
16
  destructive?: boolean;
17
+ disabledReason?: string;
11
18
  run: () => void;
12
19
  }
13
20
 
@@ -15,6 +22,22 @@ const keyOf = (el: TimelineElement) => el.key ?? el.id;
15
22
 
16
23
  const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase();
17
24
 
25
+ function loneSameFilePartner(
26
+ element: TimelineElement,
27
+ elements: readonly TimelineElement[],
28
+ ): TimelineElement | null {
29
+ const opposite = { video: "audio", audio: "video" }[tagOf(element)];
30
+ if (!opposite || element.link) return null;
31
+ const candidates = elements.filter(
32
+ (el) =>
33
+ !el.link &&
34
+ tagOf(el) === opposite &&
35
+ el.track !== element.track &&
36
+ sharesSourceFile([element, el]),
37
+ );
38
+ return candidates.length === 1 ? (candidates[0] ?? null) : null;
39
+ }
40
+
18
41
  function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string {
19
42
  const partners = others.filter((el) => keyOf(el) !== keyOf(element));
20
43
  const [partner] = partners;
@@ -23,6 +46,26 @@ function partnerSuffix(element: TimelineElement, others: readonly TimelineElemen
23
46
  return pair === "audio+video" ? ` ${tagOf(partner)}` : "";
24
47
  }
25
48
 
49
+ function linkItem(
50
+ element: TimelineElement,
51
+ elements: readonly TimelineElement[],
52
+ selected: TimelineElement[],
53
+ selectedKeys: ReadonlySet<string>,
54
+ onLinkEdit: (edit: TimelineLinkEdit) => unknown,
55
+ ): LinkMenuItem | null {
56
+ const selectedPair = canLinkPair(selected) && selectedKeys.has(keyOf(element));
57
+ const soleSelection = selected.every((el) => keyOf(el) === keyOf(element));
58
+ const partner = !selectedPair && soleSelection ? loneSameFilePartner(element, elements) : null;
59
+ const linkPair = selectedPair ? selected : partner ? [element, partner] : null;
60
+ if (!linkPair) return null;
61
+ const suffix = partnerSuffix(element, linkPair);
62
+ return {
63
+ label: `Link${suffix && ` to${suffix}`}`,
64
+ ...(selectedPair ? { shortcut: "⌘L" } : {}),
65
+ run: () => onLinkEdit({ kind: "link", elements: linkPair }),
66
+ };
67
+ }
68
+
26
69
  /** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */
27
70
  export function resolveLinkMenuItems(input: {
28
71
  element: TimelineElement;
@@ -50,17 +93,15 @@ export function resolveLinkMenuItems(input: {
50
93
  shortcut: "⌘L",
51
94
  run: () => onLinkEdit({ kind: "unlink", elements: members }),
52
95
  });
53
- } else if (canLinkPair(selected) && selectedKeys.has(keyOf(element))) {
54
- items.push({
55
- label: `Link${partnerSuffix(element, selected) && ` to${partnerSuffix(element, selected)}`}`,
56
- shortcut: "⌘L",
57
- run: () => onLinkEdit({ kind: "link", elements: selected }),
58
- });
96
+ } else {
97
+ const link = linkItem(element, elements, selected, selectedKeys, onLinkEdit);
98
+ if (link) items.push(link);
59
99
  }
60
100
  const pair = findMergePair(element, elements);
61
101
  if (pair) {
62
102
  items.push({
63
103
  label: "Merge audio back into video",
104
+ disabledReason: isPairInSync(pair.video, pair.audio) ? undefined : "Move into Sync first",
64
105
  run: () => onLinkEdit({ kind: "merge", ...pair }),
65
106
  });
66
107
  }
@@ -102,8 +143,12 @@ export function ClipMenuLinkItems({
102
143
  key={item.label}
103
144
  type="button"
104
145
  role="menuitem"
105
- className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${
106
- item.destructive ? "text-red-400" : "text-neutral-300"
146
+ disabled={item.disabledReason !== undefined}
147
+ title={item.disabledReason}
148
+ className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden ${
149
+ item.disabledReason !== undefined
150
+ ? "text-neutral-600 cursor-not-allowed"
151
+ : `cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${item.destructive ? "text-red-400" : "text-neutral-300"}`
107
152
  }`}
108
153
  onClick={() => {
109
154
  item.run();
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
8
8
  import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
@@ -15,7 +15,10 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
15
15
 
16
16
  let root: Root | null = null;
17
17
 
18
+ beforeEach(() => vi.useFakeTimers());
19
+
18
20
  afterEach(() => {
21
+ vi.useRealTimers();
19
22
  act(() => root?.unmount());
20
23
  root = null;
21
24
  document.body.innerHTML = "";
@@ -31,7 +34,14 @@ const video: TimelineElement = {
31
34
  hasAudio: true,
32
35
  };
33
36
 
34
- function renderItems(group: ClipMenuToolGroup, element: TimelineElement, currentTime = 2) {
37
+ function renderItems(
38
+ group: ClipMenuToolGroup,
39
+ element: TimelineElement,
40
+ currentTime = 2,
41
+ withLive = true,
42
+ ) {
43
+ const setLive = vi.fn((_el: TimelineElement, _attr: string, _value: string | null) => undefined);
44
+ const revertLive = vi.fn((_el: TimelineElement, _attr: string) => undefined);
35
45
  const setQuiet = vi.fn(
36
46
  async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined,
37
47
  );
@@ -42,7 +52,15 @@ function renderItems(group: ClipMenuToolGroup, element: TimelineElement, current
42
52
  root = createRoot(host);
43
53
  act(() => {
44
54
  root?.render(
45
- <TimelineEditProvider value={{ onSetElementAttributeQuiet: setQuiet, onFreezeFrame: freeze }}>
55
+ <TimelineEditProvider
56
+ value={{
57
+ onSetElementAttributeQuiet: setQuiet,
58
+ onFreezeFrame: freeze,
59
+ ...(withLive
60
+ ? { onSetElementAttributeLive: setLive, onRevertElementAttributeLive: revertLive }
61
+ : {}),
62
+ }}
63
+ >
46
64
  <ClipMenuToolItems
47
65
  group={group}
48
66
  element={element}
@@ -52,7 +70,7 @@ function renderItems(group: ClipMenuToolGroup, element: TimelineElement, current
52
70
  </TimelineEditProvider>,
53
71
  );
54
72
  });
55
- return { setQuiet, onClose, freeze };
73
+ return { setQuiet, onClose, freeze, setLive, revertLive };
56
74
  }
57
75
 
58
76
  function openSubmenu(label: string) {
@@ -136,4 +154,112 @@ describe("ClipMenuToolItems", () => {
136
154
  renderItems("time", { ...video, tag: "img" });
137
155
  expect(document.body.textContent).toBe("");
138
156
  });
157
+
158
+ describe("Look hover preview", () => {
159
+ function option(text: string) {
160
+ const found = Array.from(
161
+ document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
162
+ ).find((button) => button.textContent?.endsWith(text));
163
+ if (!found) throw new Error(`no option ${text}`);
164
+ return found;
165
+ }
166
+ const enter = (text: string) =>
167
+ option(text).dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
168
+ const hover = (text: string) => {
169
+ act(() => {
170
+ enter(text);
171
+ vi.advanceTimersByTime(100);
172
+ });
173
+ };
174
+ const leave = () => {
175
+ const wrapper = document.querySelector('[role="menu"]')?.parentElement;
176
+ act(() => {
177
+ wrapper?.dispatchEvent(
178
+ new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }),
179
+ );
180
+ });
181
+ };
182
+
183
+ it("hover applies the look live after the debounce, without saving", () => {
184
+ const { setLive, setQuiet } = renderItems("picture", video);
185
+ openSubmenu("Look");
186
+ act(() => {
187
+ enter("Warm daylight");
188
+ vi.advanceTimersByTime(40);
189
+ });
190
+ expect(setLive).not.toHaveBeenCalled();
191
+ act(() => vi.advanceTimersByTime(60));
192
+ expect(setLive).toHaveBeenCalledWith(
193
+ video,
194
+ "data-color-grading",
195
+ '{"preset":"warm-daylight","intensity":1}',
196
+ );
197
+ expect(setQuiet).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it("opening the submenu by click does not preview the auto-focused first option", () => {
201
+ const { setLive } = renderItems("picture", video);
202
+ openSubmenu("Look");
203
+ act(() => vi.advanceTimersByTime(200));
204
+ expect(setLive).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("keyboard focus previews and None previews no look", () => {
208
+ const { setLive } = renderItems("picture", video);
209
+ openSubmenu("Look");
210
+ act(() => {
211
+ option("None").dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
212
+ vi.advanceTimersByTime(100);
213
+ });
214
+ expect(setLive).toHaveBeenCalledWith(video, "data-color-grading", null);
215
+ });
216
+
217
+ it("leaving the submenu reverts to the saved look", () => {
218
+ const { revertLive } = renderItems("picture", video);
219
+ openSubmenu("Look");
220
+ hover("Warm daylight");
221
+ leave();
222
+ expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading");
223
+ });
224
+
225
+ it("leaving before the debounce fires never previews or reverts", () => {
226
+ const { setLive, revertLive } = renderItems("picture", video);
227
+ openSubmenu("Look");
228
+ act(() => enter("Warm daylight"));
229
+ leave();
230
+ act(() => vi.advanceTimersByTime(200));
231
+ expect(setLive).not.toHaveBeenCalled();
232
+ expect(revertLive).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it("clicking commits once and does not revert afterwards", () => {
236
+ const { setQuiet, revertLive } = renderItems("picture", video);
237
+ openSubmenu("Look");
238
+ hover("Warm daylight");
239
+ act(() => option("Warm daylight").click());
240
+ act(() => root?.unmount());
241
+ root = null;
242
+ expect(setQuiet).toHaveBeenCalledTimes(1);
243
+ expect(revertLive).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it("closing the menu while previewing reverts", () => {
247
+ const { revertLive } = renderItems("picture", video);
248
+ openSubmenu("Look");
249
+ hover("Warm daylight");
250
+ act(() => root?.unmount());
251
+ root = null;
252
+ expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading");
253
+ });
254
+
255
+ it("does nothing when the host has no live callbacks", () => {
256
+ const { setQuiet } = renderItems("picture", video, 2, false);
257
+ openSubmenu("Look");
258
+ expect(() => {
259
+ hover("Warm daylight");
260
+ leave();
261
+ }).not.toThrow();
262
+ expect(setQuiet).not.toHaveBeenCalled();
263
+ });
264
+ });
139
265
  });
@@ -1,4 +1,4 @@
1
- import { useRef, useState, type KeyboardEvent } from "react";
1
+ import { useEffect, useRef, useState, type KeyboardEvent } from "react";
2
2
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
3
  import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
4
4
  import type { TimelineElement } from "../store/playerStore";
@@ -30,6 +30,47 @@ const ROW_CLASS =
30
30
  const DISABLED_ROW_CLASS =
31
31
  "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
32
32
  const SUBMENU_WIDTH = 170;
33
+ const HOVER_PREVIEW_DELAY_MS = 80;
34
+
35
+ interface HoverPreview {
36
+ apply: (id: string | null) => void;
37
+ clear: () => void;
38
+ }
39
+
40
+ function useHoverPreview(preview: HoverPreview | undefined) {
41
+ const previewRef = useRef(preview);
42
+ previewRef.current = preview;
43
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+ const activeRef = useRef(false);
45
+
46
+ const cancelTimer = () => {
47
+ if (timerRef.current === null) return;
48
+ clearTimeout(timerRef.current);
49
+ timerRef.current = null;
50
+ };
51
+ const end = () => {
52
+ cancelTimer();
53
+ if (!activeRef.current) return;
54
+ activeRef.current = false;
55
+ previewRef.current?.clear();
56
+ };
57
+ const settle = () => {
58
+ cancelTimer();
59
+ activeRef.current = false;
60
+ };
61
+ const start = (id: string | null) => {
62
+ if (!previewRef.current) return;
63
+ cancelTimer();
64
+ timerRef.current = setTimeout(() => {
65
+ timerRef.current = null;
66
+ activeRef.current = true;
67
+ previewRef.current?.apply(id);
68
+ }, HOVER_PREVIEW_DELAY_MS);
69
+ };
70
+
71
+ useEffect(() => end, []);
72
+ return { start, end, settle };
73
+ }
33
74
 
34
75
  interface ChoiceSection {
35
76
  heading?: string;
@@ -48,22 +89,36 @@ function ChoiceSubmenu({
48
89
  sections,
49
90
  activeId,
50
91
  onPick,
92
+ preview,
51
93
  }: {
52
94
  label: string;
53
95
  sections: readonly ChoiceSection[];
54
96
  activeId: string | null;
55
97
  onPick: (id: string | null) => void;
98
+ preview?: HoverPreview;
56
99
  }) {
57
100
  const [open, setOpen] = useState(false);
58
101
  const [flipLeft, setFlipLeft] = useState(false);
59
102
  const rowRef = useRef<HTMLButtonElement | null>(null);
60
103
  const submenuRef = useRef<HTMLDivElement | null>(null);
104
+ const hover = useHoverPreview(preview);
105
+ const openingFocusRef = useRef(false);
106
+
107
+ const hideSubmenu = () => {
108
+ hover.end();
109
+ setOpen(false);
110
+ };
61
111
 
62
112
  const show = (focusFirst: boolean) => {
63
113
  const rect = rowRef.current?.getBoundingClientRect();
64
114
  setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
65
115
  setOpen(true);
66
- if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1));
116
+ if (!focusFirst) return;
117
+ openingFocusRef.current = true;
118
+ requestAnimationFrame(() => {
119
+ focusSibling(submenuRef.current, 1);
120
+ openingFocusRef.current = false;
121
+ });
67
122
  };
68
123
 
69
124
  const onSubmenuKeyDown = (event: KeyboardEvent) => {
@@ -74,7 +129,7 @@ function ChoiceSubmenu({
74
129
  } else if (event.key === "ArrowLeft") {
75
130
  event.preventDefault();
76
131
  event.stopPropagation();
77
- setOpen(false);
132
+ hideSubmenu();
78
133
  rowRef.current?.focus();
79
134
  }
80
135
  };
@@ -86,7 +141,15 @@ function ChoiceSubmenu({
86
141
  role="menuitemradio"
87
142
  aria-checked={activeId === id}
88
143
  className={ROW_CLASS}
89
- onClick={() => onPick(id)}
144
+ onClick={() => {
145
+ hover.settle();
146
+ onPick(id);
147
+ }}
148
+ onMouseEnter={() => hover.start(id)}
149
+ onFocus={() => {
150
+ if (openingFocusRef.current) openingFocusRef.current = false;
151
+ else hover.start(id);
152
+ }}
90
153
  >
91
154
  <span className="flex items-center gap-1.5">
92
155
  <span aria-hidden="true" className="w-3 text-center">
@@ -98,7 +161,7 @@ function ChoiceSubmenu({
98
161
  );
99
162
 
100
163
  return (
101
- <div className="relative" onMouseEnter={() => show(false)} onMouseLeave={() => setOpen(false)}>
164
+ <div className="relative" onMouseEnter={() => show(false)} onMouseLeave={hideSubmenu}>
102
165
  <button
103
166
  ref={rowRef}
104
167
  type="button"
@@ -182,7 +245,8 @@ export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
182
245
  }
183
246
 
184
247
  function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
185
- const { onSetElementAttributeQuiet } = useTimelineEditContextOptional();
248
+ const { onSetElementAttributeQuiet, onSetElementAttributeLive, onRevertElementAttributeLive } =
249
+ useTimelineEditContextOptional();
186
250
  const state = useClipToolState(element);
187
251
  const openCropBar = useCropPresetBarStore((s) => s.open);
188
252
  if (!onSetElementAttributeQuiet) return null;
@@ -209,6 +273,14 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
209
273
  }
210
274
 
211
275
  if (!isPictureClip(state.tag)) return null;
276
+ const lookPreview =
277
+ onSetElementAttributeLive && onRevertElementAttributeLive
278
+ ? {
279
+ apply: (id: string | null) =>
280
+ onSetElementAttributeLive(element, HF_COLOR_GRADING_ATTR, lookAttrValue(id)),
281
+ clear: () => onRevertElementAttributeLive(element, HF_COLOR_GRADING_ATTR),
282
+ }
283
+ : undefined;
212
284
  return (
213
285
  <>
214
286
  <ChoiceSubmenu
@@ -216,6 +288,7 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
216
288
  sections={LOOK_SECTIONS}
217
289
  activeId={activeLook(state.colorGrading)}
218
290
  onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
291
+ preview={lookPreview}
219
292
  />
220
293
  <button
221
294
  type="button"
@@ -0,0 +1,46 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
+ import type { TimelineElement } from "../store/timelineElement";
4
+ import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
5
+ import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
6
+
7
+ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
8
+
9
+ /** One fetch per peak-map URL per session; a failed or malformed answer reads as null. */
10
+ export function loadPeakMap(url: string): Promise<PeakMap | null> {
11
+ const pending = peakMapRequests.get(url);
12
+ if (pending) return pending;
13
+ const request = fetch(url)
14
+ .then((res) => (res.ok ? res.json() : null))
15
+ .then((body: unknown) => (isPeakMap(body) ? body : null))
16
+ .catch(() => null);
17
+ peakMapRequests.set(url, request);
18
+ return request;
19
+ }
20
+
21
+ /** The studio-server `/peaks/*` URL for a clip's project media, or undefined for outside media. */
22
+ export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
23
+ const origin = window.location.origin;
24
+ const relative = resolvePreviewRelative(
25
+ resolveMediaPreviewUrl(src ?? "", projectId, origin),
26
+ projectId,
27
+ origin,
28
+ );
29
+ return relative
30
+ ? buildProjectApiPath(projectId, `/peaks/${encodePreviewPath(relative)}`)
31
+ : undefined;
32
+ }
33
+
34
+ export function clipSourceWindow(el: TimelineElement): ClipSourceWindow {
35
+ return { mediaStart: el.playbackStart ?? 0, sourceSpan: el.duration * (el.playbackRate ?? 1) };
36
+ }
37
+
38
+ /** The clip's source peak, linear, over the part it plays; null when it cannot be measured. */
39
+ export async function measureClipSourcePeak(
40
+ el: TimelineElement,
41
+ projectId: string,
42
+ ): Promise<number | null> {
43
+ const url = clipPeaksUrl(el.src, projectId);
44
+ const map = url ? await loadPeakMap(url) : null;
45
+ return map ? clipSourcePeak(map, clipSourceWindow(el)) : null;
46
+ }