@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.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
/**
|
|
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
|
|
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) || !
|
|
40
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
<button
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
|
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
|
});
|