@hyperframes/studio 0.8.108 → 0.8.110

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -1,17 +1,11 @@
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
- import {
6
- clipHasSound,
7
- normalizeToastText,
8
- requestNormalizePlan,
9
- throwUnlessSaved,
10
- TimelineSaveError,
11
- VOLUME_LANE_REFUSAL,
12
- volumeLaneOwnsGain,
13
- } from "./clipMenuNormalize";
14
- import { isDuckableBed, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
4
+ import { usePlayerStore } from "../store/playerStore";
5
+ import { useLivePreviewIframe } from "../store/previewIframeStore";
6
+ import { clipHasSound, throwUnlessSaved, TimelineSaveError } from "./clipMenuNormalize";
7
+ import { openAudioGainDialog } from "./audioGainDialogStore";
8
+ import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
15
9
 
16
10
  const ITEM_CLASS =
17
11
  "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer";
@@ -23,43 +17,28 @@ const DUCK_TOAST: Record<DuckOutcome, string> = {
23
17
  aborted: "Could not group the voices to duck under.",
24
18
  };
25
19
 
26
- /** Sound group of the clip menu: Normalize loudness (one-shot) and Duck under voice (toggle). */
20
+ /** Audio Gain… (opens the G dialog) or Duck under voice (toggle), placed separately in the sound group. */
27
21
  export function ClipMenuAudioItems({
22
+ part,
28
23
  element,
29
24
  onClose,
30
25
  }: {
26
+ part: "gain" | "duck";
31
27
  element: TimelineElement;
32
28
  onClose: () => void;
33
29
  }) {
34
30
  const shell = useStudioShellContextOptional();
35
- const { onSetElementAttributeQuiet, onGroupClips } = useTimelineEditContextOptional();
36
- const doc = shell?.previewIframeRef.current?.contentDocument ?? null;
31
+ const { onSetElementAttributeQuiet, onGroupClips, onNotice } = useTimelineEditContextOptional();
32
+ const liveIframe = useLivePreviewIframe();
33
+ const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
34
+ const projectId = shell?.projectId ?? sessionProjectId;
35
+ const showToast = shell?.showToast ?? onNotice;
36
+ const doc = (shell?.previewIframeRef.current ?? liveIframe)?.contentDocument ?? null;
37
37
  const bed = doc?.getElementById(element.domId ?? element.id) ?? null;
38
38
  const ducked = bed ? readBedCarve(bed)?.enabled === true : false;
39
- if (!clipHasSound(element) || !shell || !onSetElementAttributeQuiet) return null;
40
- const { showToast, projectId } = shell;
41
-
42
- const normalize = async () => {
43
- onClose();
44
- if (volumeLaneOwnsGain(element)) {
45
- showToast(VOLUME_LANE_REFUSAL, "error");
46
- return;
47
- }
48
- try {
49
- const plan = await requestNormalizePlan(projectId, element);
50
- throwUnlessSaved(
51
- await onSetElementAttributeQuiet(
52
- element,
53
- "data-volume",
54
- formatAudioGain(plan.volume),
55
- "Normalize loudness",
56
- ),
57
- );
58
- showToast(normalizeToastText(plan), "info");
59
- } catch (error) {
60
- showToast(error instanceof Error ? error.message : String(error), "error");
61
- }
62
- };
39
+ if (!clipHasSound(element) || !projectId || !showToast || !onSetElementAttributeQuiet) {
40
+ return null;
41
+ }
63
42
 
64
43
  const toggleDuck = async () => {
65
44
  onClose();
@@ -84,25 +63,33 @@ export function ClipMenuAudioItems({
84
63
  showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
85
64
  };
86
65
 
87
- return (
88
- <>
89
- <button type="button" role="menuitem" className={ITEM_CLASS} onClick={() => void normalize()}>
90
- <span>Normalize loudness</span>
66
+ if (part === "gain") {
67
+ return (
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>
91
79
  </button>
92
- {isDuckableBed(bed) && (
93
- <button
94
- type="button"
95
- role="menuitemcheckbox"
96
- aria-checked={ducked}
97
- className={ITEM_CLASS}
98
- onClick={() => void toggleDuck()}
99
- >
100
- <span>
101
- <span className="inline-block w-3">{ducked ? "✓" : ""}</span>Duck under voice
102
- </span>
103
- </button>
104
- )}
105
- <div className="my-1 border-t border-neutral-700/60" />
106
- </>
80
+ );
81
+ }
82
+ if (!offersDuck(doc, bed)) return null;
83
+ return (
84
+ <button
85
+ type="button"
86
+ role="menuitemcheckbox"
87
+ aria-checked={ducked}
88
+ className={ITEM_CLASS}
89
+ onClick={() => void toggleDuck()}
90
+ >
91
+ <span>Duck under voice</span>
92
+ <span aria-hidden="true">{ducked ? "✓" : ""}</span>
93
+ </button>
107
94
  );
108
95
  }
@@ -83,6 +83,11 @@ export function duckVoiceSources(doc: Document, bed: Element): string[] {
83
83
  .map((candidate) => candidate.id);
84
84
  }
85
85
 
86
+ export function offersDuck(doc: Document | null, bed: Element | null): boolean {
87
+ if (!doc || !isDuckableBed(bed)) return false;
88
+ return readBedCarve(bed)?.enabled === true || duckVoiceSources(doc, bed).length > 0;
89
+ }
90
+
86
91
  async function writeMeasuredCarve(
87
92
  doc: Document,
88
93
  bed: Element,
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { resolveLinkMenuItems } from "./clipMenuLinkItems";
4
+
5
+ const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
6
+ id,
7
+ tag,
8
+ src: "talk.mp4",
9
+ start: 0,
10
+ duration: 4,
11
+ track: 0,
12
+ ...extra,
13
+ });
14
+
15
+ function labels(element: TimelineElement, elements: TimelineElement[], selected: string[] = []) {
16
+ return resolveLinkMenuItems({
17
+ element,
18
+ elements,
19
+ selectedKeys: new Set(selected),
20
+ onLinkEdit: vi.fn(),
21
+ onDeleteElementOnly: vi.fn(),
22
+ }).map((item) => item.label);
23
+ }
24
+
25
+ describe("resolveLinkMenuItems", () => {
26
+ it("offers Detach audio on a video with sound (frame 4)", () => {
27
+ const talk = clip("talk", "video", { hasAudio: true });
28
+ expect(labels(talk, [talk])).toEqual(["Detach audio"]);
29
+ });
30
+
31
+ it("offers Unlink, Merge back and Delete this clip only on a linked pair (frame 6)", () => {
32
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
33
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
34
+ expect(labels(audio, [video, audio])).toEqual([
35
+ "Unlink from video",
36
+ "Merge audio back into video",
37
+ "Delete this clip only",
38
+ ]);
39
+ });
40
+
41
+ it("offers Link instead of Unlink for a selected unlinked pair", () => {
42
+ const video = clip("talk", "video", { muted: true });
43
+ const audio = clip("talk-audio", "audio");
44
+ expect(labels(video, [video, audio], ["talk", "talk-audio"])).toEqual([
45
+ "Link to audio",
46
+ "Merge audio back into video",
47
+ ]);
48
+ expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video");
49
+ });
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
+
96
+ it("names the audio partner when unlinking from the video side", () => {
97
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
98
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
99
+ expect(labels(video, [video, audio])[0]).toBe("Unlink from audio");
100
+ });
101
+
102
+ it("falls back to plain Unlink for groups of three or same-kind partners", () => {
103
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
104
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
105
+ const second = clip("talk-audio2", "audio", { link: "lk-1" });
106
+ expect(labels(video, [video, audio, second])[0]).toBe("Unlink");
107
+ const twin = clip("talk2", "video", { muted: true, link: "lk-2" });
108
+ const twin2 = clip("talk3", "video", { muted: true, link: "lk-2" });
109
+ expect(labels(twin, [twin, twin2])[0]).toBe("Unlink");
110
+ });
111
+
112
+ it("offers nothing for a silent video or without a link handler", () => {
113
+ const broll = clip("broll", "video", { muted: true, src: "b.mp4" });
114
+ expect(labels(broll, [broll])).toEqual([]);
115
+ expect(
116
+ resolveLinkMenuItems({
117
+ element: clip("t", "video", { hasAudio: true }),
118
+ elements: [],
119
+ selectedKeys: new Set(),
120
+ }),
121
+ ).toEqual([]);
122
+ });
123
+
124
+ it("each item dispatches its link edit", () => {
125
+ const onLinkEdit = vi.fn();
126
+ const talk = clip("talk", "video", { hasAudio: true });
127
+ resolveLinkMenuItems({
128
+ element: talk,
129
+ elements: [talk],
130
+ selectedKeys: new Set(),
131
+ onLinkEdit,
132
+ })[0]?.run();
133
+ expect(onLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
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
+ });
174
+ });
@@ -0,0 +1,166 @@
1
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
3
+ import type { TimelineLinkEdit } from "./timelineCallbacks";
4
+ import { linkedMembersOf } from "./audioClipLink";
5
+ import {
6
+ canDetachAudio,
7
+ canLinkPair,
8
+ findMergePair,
9
+ isPairInSync,
10
+ sharesSourceFile,
11
+ } from "../../components/editor/mediaLinkEdits";
12
+
13
+ interface LinkMenuItem {
14
+ label: string;
15
+ shortcut?: string;
16
+ destructive?: boolean;
17
+ disabledReason?: string;
18
+ run: () => void;
19
+ }
20
+
21
+ const keyOf = (el: TimelineElement) => el.key ?? el.id;
22
+
23
+ const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase();
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
+
41
+ function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string {
42
+ const partners = others.filter((el) => keyOf(el) !== keyOf(element));
43
+ const [partner] = partners;
44
+ if (partners.length !== 1 || !partner) return "";
45
+ const pair = [tagOf(element), tagOf(partner)].sort().join("+");
46
+ return pair === "audio+video" ? ` ${tagOf(partner)}` : "";
47
+ }
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
+
69
+ /** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */
70
+ export function resolveLinkMenuItems(input: {
71
+ element: TimelineElement;
72
+ elements: readonly TimelineElement[];
73
+ selectedKeys: ReadonlySet<string>;
74
+ onLinkEdit?: (edit: TimelineLinkEdit) => unknown;
75
+ onDeleteElementOnly?: (element: TimelineElement) => unknown;
76
+ }): LinkMenuItem[] {
77
+ const { element, elements, selectedKeys, onLinkEdit, onDeleteElementOnly } = input;
78
+ const items: LinkMenuItem[] = [];
79
+ if (!onLinkEdit) return items;
80
+ const members = linkedMembersOf(element, elements);
81
+ const linked = members.length > 1;
82
+ if (canDetachAudio(element)) {
83
+ items.push({
84
+ label: "Detach audio",
85
+ shortcut: "⌥⇧D",
86
+ run: () => onLinkEdit({ kind: "detach", element }),
87
+ });
88
+ }
89
+ const selected = elements.filter((el) => selectedKeys.has(keyOf(el)));
90
+ if (linked) {
91
+ items.push({
92
+ label: `Unlink${partnerSuffix(element, members) && ` from${partnerSuffix(element, members)}`}`,
93
+ shortcut: "⌘L",
94
+ run: () => onLinkEdit({ kind: "unlink", elements: members }),
95
+ });
96
+ } else {
97
+ const link = linkItem(element, elements, selected, selectedKeys, onLinkEdit);
98
+ if (link) items.push(link);
99
+ }
100
+ const pair = findMergePair(element, elements);
101
+ if (pair) {
102
+ items.push({
103
+ label: "Merge audio back into video",
104
+ disabledReason: isPairInSync(pair.video, pair.audio) ? undefined : "Move into Sync first",
105
+ run: () => onLinkEdit({ kind: "merge", ...pair }),
106
+ });
107
+ }
108
+ if (linked && onDeleteElementOnly) {
109
+ items.push({
110
+ label: "Delete this clip only",
111
+ shortcut: "⌥⌫",
112
+ destructive: true,
113
+ run: () => onDeleteElementOnly(element),
114
+ });
115
+ }
116
+ return items;
117
+ }
118
+
119
+ export function ClipMenuLinkItems({
120
+ part,
121
+ element,
122
+ onClose,
123
+ }: {
124
+ part: "link" | "delete";
125
+ element: TimelineElement;
126
+ onClose: () => void;
127
+ }) {
128
+ const { onLinkEdit, onDeleteElementOnly } = useTimelineEditContextOptional();
129
+ const elements = usePlayerStore((s) => s.elements);
130
+ const selectedKeys = usePlayerStore((s) => s.selectedElementIds);
131
+ const items = resolveLinkMenuItems({
132
+ element,
133
+ elements,
134
+ selectedKeys,
135
+ onLinkEdit,
136
+ onDeleteElementOnly,
137
+ }).filter((item) => (item.destructive === true) === (part === "delete"));
138
+ if (items.length === 0) return null;
139
+ return (
140
+ <>
141
+ {items.map((item) => (
142
+ <button
143
+ key={item.label}
144
+ type="button"
145
+ role="menuitem"
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"}`
152
+ }`}
153
+ onClick={() => {
154
+ item.run();
155
+ onClose();
156
+ }}
157
+ >
158
+ <span>{item.label}</span>
159
+ {item.shortcut && (
160
+ <span className="text-neutral-500 text-[10px] ml-3">{item.shortcut}</span>
161
+ )}
162
+ </button>
163
+ ))}
164
+ </>
165
+ );
166
+ }
@@ -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
  });