@hyperframes/studio 0.8.107 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -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.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -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 +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -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 +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- 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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- 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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useRef, useState, type KeyboardEvent } from "react";
|
|
2
|
+
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
|
+
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
|
|
4
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
5
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
6
|
+
import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
|
|
7
|
+
import {
|
|
8
|
+
CHARACTER_CHOICES,
|
|
9
|
+
LOOK_CHOICES,
|
|
10
|
+
VOICE_CHOICES,
|
|
11
|
+
activeLook,
|
|
12
|
+
activeVoicePreset,
|
|
13
|
+
chainWithVoicePreset,
|
|
14
|
+
lookAttrValue,
|
|
15
|
+
type ClipToolChoice,
|
|
16
|
+
} from "./clipToolAttrs";
|
|
17
|
+
import { useClipToolState } from "./useClipToolState";
|
|
18
|
+
|
|
19
|
+
export type ClipMenuToolGroup = "time" | "sound" | "picture";
|
|
20
|
+
|
|
21
|
+
interface ClipMenuToolItemsProps {
|
|
22
|
+
group: ClipMenuToolGroup;
|
|
23
|
+
element: TimelineElement;
|
|
24
|
+
currentTime: number;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ROW_CLASS =
|
|
29
|
+
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
|
|
30
|
+
const DISABLED_ROW_CLASS =
|
|
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
|
+
const SUBMENU_WIDTH = 170;
|
|
33
|
+
|
|
34
|
+
interface ChoiceSection {
|
|
35
|
+
heading?: string;
|
|
36
|
+
choices: readonly ClipToolChoice[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function focusSibling(menu: HTMLElement | null, step: number): void {
|
|
40
|
+
if (!menu) return;
|
|
41
|
+
const items = Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'));
|
|
42
|
+
const index = items.findIndex((item) => item === document.activeElement);
|
|
43
|
+
items[(index + step + items.length) % items.length]?.focus();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ChoiceSubmenu({
|
|
47
|
+
label,
|
|
48
|
+
sections,
|
|
49
|
+
activeId,
|
|
50
|
+
onPick,
|
|
51
|
+
}: {
|
|
52
|
+
label: string;
|
|
53
|
+
sections: readonly ChoiceSection[];
|
|
54
|
+
activeId: string | null;
|
|
55
|
+
onPick: (id: string | null) => void;
|
|
56
|
+
}) {
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const [flipLeft, setFlipLeft] = useState(false);
|
|
59
|
+
const rowRef = useRef<HTMLButtonElement | null>(null);
|
|
60
|
+
const submenuRef = useRef<HTMLDivElement | null>(null);
|
|
61
|
+
|
|
62
|
+
const show = (focusFirst: boolean) => {
|
|
63
|
+
const rect = rowRef.current?.getBoundingClientRect();
|
|
64
|
+
setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
|
|
65
|
+
setOpen(true);
|
|
66
|
+
if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const onSubmenuKeyDown = (event: KeyboardEvent) => {
|
|
70
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopPropagation();
|
|
73
|
+
focusSibling(submenuRef.current, event.key === "ArrowDown" ? 1 : -1);
|
|
74
|
+
} else if (event.key === "ArrowLeft") {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
event.stopPropagation();
|
|
77
|
+
setOpen(false);
|
|
78
|
+
rowRef.current?.focus();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const choiceRow = (id: string | null, text: string) => (
|
|
83
|
+
<button
|
|
84
|
+
key={id ?? "none"}
|
|
85
|
+
type="button"
|
|
86
|
+
role="menuitemradio"
|
|
87
|
+
aria-checked={activeId === id}
|
|
88
|
+
className={ROW_CLASS}
|
|
89
|
+
onClick={() => onPick(id)}
|
|
90
|
+
>
|
|
91
|
+
<span className="flex items-center gap-1.5">
|
|
92
|
+
<span aria-hidden="true" className="w-3 text-center">
|
|
93
|
+
{activeId === id ? "✓" : ""}
|
|
94
|
+
</span>
|
|
95
|
+
{text}
|
|
96
|
+
</span>
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={() => setOpen(false)}>
|
|
102
|
+
<button
|
|
103
|
+
ref={rowRef}
|
|
104
|
+
type="button"
|
|
105
|
+
role="menuitem"
|
|
106
|
+
aria-haspopup="menu"
|
|
107
|
+
aria-expanded={open}
|
|
108
|
+
className={ROW_CLASS}
|
|
109
|
+
onClick={() => show(true)}
|
|
110
|
+
onKeyDown={(event) => {
|
|
111
|
+
if (event.key !== "ArrowRight") return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
show(true);
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<span>{label}</span>
|
|
117
|
+
<span className="text-neutral-500 text-[10px] ml-3">▸</span>
|
|
118
|
+
</button>
|
|
119
|
+
{open && (
|
|
120
|
+
<div
|
|
121
|
+
ref={submenuRef}
|
|
122
|
+
role="menu"
|
|
123
|
+
aria-label={label}
|
|
124
|
+
className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
|
|
125
|
+
style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
|
|
126
|
+
onKeyDown={onSubmenuKeyDown}
|
|
127
|
+
>
|
|
128
|
+
{choiceRow(null, "None")}
|
|
129
|
+
{sections.map((section, index) => (
|
|
130
|
+
<div key={section.heading ?? index}>
|
|
131
|
+
{section.heading && (
|
|
132
|
+
<>
|
|
133
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
134
|
+
<div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
|
|
135
|
+
{section.heading}
|
|
136
|
+
</div>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
{section.choices.map((choice) => choiceRow(choice.id, choice.label))}
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const VOICE_SECTIONS: readonly ChoiceSection[] = [
|
|
149
|
+
{ choices: VOICE_CHOICES },
|
|
150
|
+
{ heading: "Character", choices: CHARACTER_CHOICES },
|
|
151
|
+
];
|
|
152
|
+
const LOOK_SECTIONS: readonly ChoiceSection[] = [{ choices: LOOK_CHOICES }];
|
|
153
|
+
|
|
154
|
+
function isPictureClip(tag: string): boolean {
|
|
155
|
+
return tag === "video" || tag === "img";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function FreezeFrameItem({ element, currentTime, onClose }: Omit<ClipMenuToolItemsProps, "group">) {
|
|
159
|
+
const { onFreezeFrame } = useTimelineEditContextOptional();
|
|
160
|
+
if (!onFreezeFrame || element.tag.trim().toLowerCase() !== "video") return null;
|
|
161
|
+
const inside = currentTime > element.start && currentTime < element.start + element.duration;
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
role="menuitem"
|
|
166
|
+
className={inside ? ROW_CLASS : DISABLED_ROW_CLASS}
|
|
167
|
+
disabled={!inside}
|
|
168
|
+
onClick={() => {
|
|
169
|
+
void onFreezeFrame(element, currentTime);
|
|
170
|
+
onClose();
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<span>{inside ? "Freeze frame" : "Freeze frame (move playhead inside clip)"}</span>
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Freeze (time), Voice (sound), Look and Crop (picture). */
|
|
179
|
+
export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
|
|
180
|
+
if (props.group === "time") return <FreezeFrameItem {...props} />;
|
|
181
|
+
return <ClipMenuAttributeItems {...props} />;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
|
|
185
|
+
const { onSetElementAttributeQuiet } = useTimelineEditContextOptional();
|
|
186
|
+
const state = useClipToolState(element);
|
|
187
|
+
const openCropBar = useCropPresetBarStore((s) => s.open);
|
|
188
|
+
if (!onSetElementAttributeQuiet) return null;
|
|
189
|
+
|
|
190
|
+
const write = (attr: string, value: string | null, label: string) => {
|
|
191
|
+
void onSetElementAttributeQuiet(element, attr, value, label);
|
|
192
|
+
onClose();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (group === "sound") {
|
|
196
|
+
if (!state.hasSound) return null;
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<ChoiceSubmenu
|
|
200
|
+
label="Voice"
|
|
201
|
+
sections={VOICE_SECTIONS}
|
|
202
|
+
activeId={activeVoicePreset(state.fxChain)}
|
|
203
|
+
onPick={(id) =>
|
|
204
|
+
write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
|
|
205
|
+
}
|
|
206
|
+
/>
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!isPictureClip(state.tag)) return null;
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<ChoiceSubmenu
|
|
215
|
+
label="Look"
|
|
216
|
+
sections={LOOK_SECTIONS}
|
|
217
|
+
activeId={activeLook(state.colorGrading)}
|
|
218
|
+
onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
|
|
219
|
+
/>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
role="menuitem"
|
|
223
|
+
className={ROW_CLASS}
|
|
224
|
+
onClick={() => {
|
|
225
|
+
openCropBar({ hfId: element.hfId, id: element.domId ?? element.id });
|
|
226
|
+
onClose();
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<span>Crop</span>
|
|
230
|
+
</button>
|
|
231
|
+
</>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clipPeakRuns } from "./clipPeakRuns";
|
|
3
|
+
|
|
4
|
+
const map = (bins: number[]) => ({ binSeconds: 1, bins });
|
|
5
|
+
|
|
6
|
+
describe("clipPeakRuns", () => {
|
|
7
|
+
it("marks bins at or above -1 dBFS after the clip's gain and names the loudest", () => {
|
|
8
|
+
const { runs, peakDbfs } = clipPeakRuns(
|
|
9
|
+
map([0.1, 0.95, 0.99, 0.2, 0.9]),
|
|
10
|
+
{ mediaStart: 0, sourceSpan: 5 },
|
|
11
|
+
1,
|
|
12
|
+
);
|
|
13
|
+
expect(runs).toEqual([
|
|
14
|
+
{ from: 0.2, to: 0.6 },
|
|
15
|
+
{ from: 0.8, to: 1 },
|
|
16
|
+
]);
|
|
17
|
+
expect(peakDbfs).toBeCloseTo(20 * Math.log10(0.99), 6);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("puts nothing on a -12 dBFS clip, and marks it once boosted by +12 dB", () => {
|
|
21
|
+
const quiet = map([0.25, 0.25]);
|
|
22
|
+
expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 1)).toEqual({
|
|
23
|
+
runs: [],
|
|
24
|
+
peakDbfs: null,
|
|
25
|
+
});
|
|
26
|
+
expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 4).runs).toHaveLength(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("only reads the trimmed window, positioned within the clip", () => {
|
|
30
|
+
const { runs } = clipPeakRuns(map([1, 0, 0, 1, 0]), { mediaStart: 2, sourceSpan: 2 }, 1);
|
|
31
|
+
expect(runs).toEqual([{ from: 0.5, to: 1 }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("is empty for a muted clip or an empty window", () => {
|
|
35
|
+
expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 1 }, 0).runs).toEqual([]);
|
|
36
|
+
expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
|
|
2
|
+
|
|
3
|
+
export interface PeakMap {
|
|
4
|
+
binSeconds: number;
|
|
5
|
+
bins: number[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ClipSourceWindow {
|
|
9
|
+
mediaStart: number;
|
|
10
|
+
sourceSpan: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClipPeakMarkRuns {
|
|
14
|
+
runs: Array<{ from: number; to: number }>;
|
|
15
|
+
peakDbfs: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where, as fractions of the clip, its source at the clip's own gain reaches the
|
|
20
|
+
* export ceiling, and the loudest point when it does. Adjacent hot bins merge.
|
|
21
|
+
*/
|
|
22
|
+
export function clipPeakRuns(
|
|
23
|
+
map: PeakMap,
|
|
24
|
+
window: ClipSourceWindow,
|
|
25
|
+
gain: number,
|
|
26
|
+
): ClipPeakMarkRuns {
|
|
27
|
+
const runs: ClipPeakMarkRuns["runs"] = [];
|
|
28
|
+
if (!(window.sourceSpan > 0) || !(map.binSeconds > 0) || !(gain > 0))
|
|
29
|
+
return { runs, peakDbfs: null };
|
|
30
|
+
const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds));
|
|
31
|
+
const last = Math.min(
|
|
32
|
+
map.bins.length,
|
|
33
|
+
Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds),
|
|
34
|
+
);
|
|
35
|
+
const toFraction = (bin: number) =>
|
|
36
|
+
Math.min(1, Math.max(0, (bin * map.binSeconds - window.mediaStart) / window.sourceSpan));
|
|
37
|
+
let loudest = 0;
|
|
38
|
+
for (let bin = first; bin < last; bin++) {
|
|
39
|
+
const level = (map.bins[bin] ?? 0) * gain;
|
|
40
|
+
if (level < EXPORT_PEAK_CEILING) continue;
|
|
41
|
+
loudest = Math.max(loudest, level);
|
|
42
|
+
const previous = runs.at(-1);
|
|
43
|
+
const from = toFraction(bin);
|
|
44
|
+
const to = toFraction(bin + 1);
|
|
45
|
+
if (previous && previous.to >= from) previous.to = to;
|
|
46
|
+
else runs.push({ from, to });
|
|
47
|
+
}
|
|
48
|
+
return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isPeakMap(value: unknown): value is PeakMap {
|
|
52
|
+
if (typeof value !== "object" || value === null) return false;
|
|
53
|
+
const bins: unknown = Reflect.get(value, "bins");
|
|
54
|
+
return (
|
|
55
|
+
typeof Reflect.get(value, "binSeconds") === "number" &&
|
|
56
|
+
Array.isArray(bins) &&
|
|
57
|
+
bins.every((bin) => typeof bin === "number")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { clipSyncState, syncPartnerOf } from "./clipSync";
|
|
3
|
+
|
|
4
|
+
const clip = (id: string, tag: string, start: number, extra: Record<string, unknown> = {}) => ({
|
|
5
|
+
id,
|
|
6
|
+
tag,
|
|
7
|
+
start,
|
|
8
|
+
duration: 4,
|
|
9
|
+
track: 0,
|
|
10
|
+
syncOrigin: "lk-1",
|
|
11
|
+
...extra,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("syncPartnerOf", () => {
|
|
15
|
+
it("pairs a video with the audio sharing its sync origin, not another video", () => {
|
|
16
|
+
const v = clip("v", "video", 0);
|
|
17
|
+
const a = clip("a", "audio", 0);
|
|
18
|
+
const other = clip("v2", "video", 0);
|
|
19
|
+
expect(syncPartnerOf(a, [v, a, other])?.id).toBe("v");
|
|
20
|
+
expect(syncPartnerOf(v, [v, a, other])?.id).toBe("a");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores clips with no or another origin", () => {
|
|
24
|
+
const v = clip("v", "video", 0);
|
|
25
|
+
expect(syncPartnerOf(v, [v, clip("a", "audio", 0, { syncOrigin: "lk-2" })])).toBeNull();
|
|
26
|
+
expect(syncPartnerOf({ ...v, syncOrigin: undefined }, [v])).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([{ sourceFile: "child.html" }, { compositionScope: "child" }])(
|
|
30
|
+
"never pairs across compositions reusing an origin (%o)",
|
|
31
|
+
(scope) => {
|
|
32
|
+
const v = clip("v", "video", 0);
|
|
33
|
+
expect(syncPartnerOf(v, [v, clip("a", "audio", 0, scope)])).toBeNull();
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
it("after a split, picks the partner sharing the most timeline", () => {
|
|
38
|
+
const a = clip("a", "audio", 4.2);
|
|
39
|
+
const left = clip("v", "video", 0);
|
|
40
|
+
const right = clip("v-split", "video", 4);
|
|
41
|
+
expect(syncPartnerOf(a, [left, right, a])?.id).toBe("v-split");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("clipSyncState", () => {
|
|
46
|
+
it("is null in sync and signed per side when drifted", () => {
|
|
47
|
+
const v = clip("v", "video", 1);
|
|
48
|
+
expect(clipSyncState(v, [v, clip("a", "audio", 1)], 30)).toBeNull();
|
|
49
|
+
const late = clip("a", "audio", 1 + 10 / 30);
|
|
50
|
+
expect(clipSyncState(late, [v, late], 30)?.frames).toBe(10);
|
|
51
|
+
expect(clipSyncState(v, [v, late], 30)?.frames).toBe(-10);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("offers move and slip targets that each restore sync", () => {
|
|
55
|
+
const v = clip("v", "video", 1, { playbackStart: 0 });
|
|
56
|
+
const a = clip("a", "audio", 1.5, { playbackStart: 0 });
|
|
57
|
+
const state = clipSyncState(a, [v, a], 30);
|
|
58
|
+
expect(state?.moveStart).toBeCloseTo(1);
|
|
59
|
+
expect(state?.slipMediaStart).toBeCloseTo(0.5);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("has no badge when the halves play at different rates", () => {
|
|
63
|
+
const v = clip("v", "video", 1, { playbackRate: 2 });
|
|
64
|
+
const a = clip("a", "audio", 2);
|
|
65
|
+
expect(clipSyncState(a, [v, a], 30)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
moveIntoSyncStart,
|
|
3
|
+
slipIntoSyncMediaStart,
|
|
4
|
+
syncOffsetFrames,
|
|
5
|
+
type SyncTiming,
|
|
6
|
+
} from "@hyperframes/core/media-link";
|
|
7
|
+
import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
|
|
8
|
+
|
|
9
|
+
type SyncElement = Pick<
|
|
10
|
+
TimelineElement,
|
|
11
|
+
"id" | "key" | "tag" | "start" | "duration" | "playbackStart" | "playbackRate" | "syncOrigin"
|
|
12
|
+
> &
|
|
13
|
+
Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
14
|
+
|
|
15
|
+
const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
|
|
16
|
+
const kindOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
|
|
17
|
+
|
|
18
|
+
function syncTimingOf(el: SyncElement): SyncTiming {
|
|
19
|
+
return { start: el.start, mediaStart: el.playbackStart ?? 0, playbackRate: el.playbackRate ?? 1 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function overlap(a: SyncElement, b: SyncElement): number {
|
|
23
|
+
return Math.min(a.start + a.duration, b.start + b.duration) - Math.max(a.start, b.start);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The other half of `el`'s source pair: a video for an audio and the reverse,
|
|
28
|
+
* sharing its sync origin. After splits several may qualify; the one sharing the
|
|
29
|
+
* most timeline wins, then the nearest start.
|
|
30
|
+
*/
|
|
31
|
+
export function syncPartnerOf<T extends SyncElement>(el: T, elements: readonly T[]): T | null {
|
|
32
|
+
const kind = kindOf(el);
|
|
33
|
+
if (!el.syncOrigin || (kind !== "video" && kind !== "audio")) return null;
|
|
34
|
+
const partnerKind = kind === "video" ? "audio" : "video";
|
|
35
|
+
const candidates = elements.filter(
|
|
36
|
+
(other) =>
|
|
37
|
+
keyOf(other) !== keyOf(el) &&
|
|
38
|
+
other.syncOrigin === el.syncOrigin &&
|
|
39
|
+
sameCompositionScope(other, el) &&
|
|
40
|
+
kindOf(other) === partnerKind,
|
|
41
|
+
);
|
|
42
|
+
const rank = (other: T) => [overlap(el, other), -Math.abs(other.start - el.start)] as const;
|
|
43
|
+
let best: T | null = null;
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (!best) best = candidate;
|
|
46
|
+
else {
|
|
47
|
+
const [o1, d1] = rank(candidate);
|
|
48
|
+
const [o2, d2] = rank(best);
|
|
49
|
+
if (o1 > o2 || (o1 === o2 && d1 > d2)) best = candidate;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return best;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ClipSyncState<T> {
|
|
56
|
+
partner: T;
|
|
57
|
+
frames: number;
|
|
58
|
+
moveStart: number | null;
|
|
59
|
+
slipMediaStart: number | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `el`'s drift from its source partner, or null when in sync, unpaired or at a different rate. */
|
|
63
|
+
export function clipSyncState<T extends SyncElement>(
|
|
64
|
+
el: T,
|
|
65
|
+
elements: readonly T[],
|
|
66
|
+
fps: number,
|
|
67
|
+
): ClipSyncState<T> | null {
|
|
68
|
+
const partner = syncPartnerOf(el, elements);
|
|
69
|
+
if (!partner) return null;
|
|
70
|
+
const self = syncTimingOf(el);
|
|
71
|
+
const other = syncTimingOf(partner);
|
|
72
|
+
const frames = syncOffsetFrames(self, other, fps);
|
|
73
|
+
if (frames === null || frames === 0) return null;
|
|
74
|
+
return {
|
|
75
|
+
partner,
|
|
76
|
+
frames,
|
|
77
|
+
moveStart: moveIntoSyncStart(self, other),
|
|
78
|
+
slipMediaStart: slipIntoSyncMediaStart(self, other),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseAudioFxChain, serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
3
|
+
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
4
|
+
import {
|
|
5
|
+
activeLook,
|
|
6
|
+
activeVoicePreset,
|
|
7
|
+
chainWithVoicePreset,
|
|
8
|
+
hasCrop,
|
|
9
|
+
isDucked,
|
|
10
|
+
lookAttrValue,
|
|
11
|
+
readClipBadges,
|
|
12
|
+
splitVisibleBadges,
|
|
13
|
+
type ClipToolState,
|
|
14
|
+
} from "./clipToolAttrs";
|
|
15
|
+
|
|
16
|
+
const presetsIn = (raw: string | null) =>
|
|
17
|
+
raw ? [...new Set(parseAudioFxChain(raw).nodes.map((n) => n.fromPreset ?? n.type))] : [];
|
|
18
|
+
|
|
19
|
+
const handNode = serializeAudioFxChain({
|
|
20
|
+
version: 1,
|
|
21
|
+
nodes: [{ id: "n1", type: "highpass", enabled: true, params: { frequency: 80 } }],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("voice presets", () => {
|
|
25
|
+
it("writes the chosen preset's nodes and reads it back", () => {
|
|
26
|
+
const raw = chainWithVoicePreset(null, "voice-clean");
|
|
27
|
+
expect(presetsIn(raw)).toEqual(["voice-clean"]);
|
|
28
|
+
expect(activeVoicePreset(raw)).toBe("voice-clean");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is single-choice: picking another menu preset replaces the first", () => {
|
|
32
|
+
const raw = chainWithVoicePreset(chainWithVoicePreset(null, "voice-clean"), "telephone");
|
|
33
|
+
expect(presetsIn(raw)).toEqual(["telephone"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("None drops the attribute when the chain held only the preset", () => {
|
|
37
|
+
expect(chainWithVoicePreset(chainWithVoicePreset(null, "voice-warm"), null)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("None keeps nodes that are not from a menu preset", () => {
|
|
41
|
+
const withPreset = chainWithVoicePreset(handNode, "voice-broadcast");
|
|
42
|
+
expect(presetsIn(withPreset)).toEqual(["highpass", "voice-broadcast"]);
|
|
43
|
+
expect(chainWithVoicePreset(withPreset, null)).toBe(handNode);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("re-applying the same preset keeps one copy", () => {
|
|
47
|
+
const once = chainWithVoicePreset(null, "megaphone");
|
|
48
|
+
const twice = chainWithVoicePreset(once, "megaphone");
|
|
49
|
+
expect(parseAudioFxChain(twice ?? "").nodes).toHaveLength(
|
|
50
|
+
parseAudioFxChain(once ?? "").nodes.length,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("looks", () => {
|
|
56
|
+
it("writes the minimal preset form the runtime resolves", () => {
|
|
57
|
+
const raw = lookAttrValue("warm-daylight");
|
|
58
|
+
expect(raw).toBe('{"preset":"warm-daylight","intensity":1}');
|
|
59
|
+
expect(normalizeHfColorGrading(JSON.parse(raw ?? ""))?.preset).toBe("warm-daylight");
|
|
60
|
+
expect(activeLook(raw)).toBe("warm-daylight");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("None removes the attribute", () => {
|
|
64
|
+
expect(lookAttrValue(null)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads a full inspector-written grading and ignores garbage", () => {
|
|
68
|
+
expect(activeLook('{"preset":"mono-clean","intensity":0.4,"adjust":{"contrast":0.2}}')).toBe(
|
|
69
|
+
"mono-clean",
|
|
70
|
+
);
|
|
71
|
+
expect(activeLook("{nope")).toBeNull();
|
|
72
|
+
expect(activeLook('{"preset":"mono-clean","enabled":false}')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("small readers", () => {
|
|
77
|
+
it("treats an inset of zero or none as no crop", () => {
|
|
78
|
+
expect(hasCrop("inset(10px 0px 10px 0px)")).toBe(true);
|
|
79
|
+
expect(hasCrop("inset(0px)")).toBe(false);
|
|
80
|
+
expect(hasCrop("none")).toBe(false);
|
|
81
|
+
expect(hasCrop(null)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads a carve as ducked unless it is switched off", () => {
|
|
85
|
+
expect(isDucked('{"sources":["vo"]}')).toBe(true);
|
|
86
|
+
expect(isDucked('{"enabled":false,"sources":["vo"]}')).toBe(false);
|
|
87
|
+
expect(isDucked(null)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const baseState: ClipToolState = {
|
|
92
|
+
tag: "video",
|
|
93
|
+
hasSound: true,
|
|
94
|
+
volume: null,
|
|
95
|
+
muted: false,
|
|
96
|
+
fxChain: null,
|
|
97
|
+
automation: null,
|
|
98
|
+
colorGrading: null,
|
|
99
|
+
clipPath: null,
|
|
100
|
+
carve: null,
|
|
101
|
+
link: null,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const rampAutomation = JSON.stringify({
|
|
105
|
+
version: 1,
|
|
106
|
+
lanes: [
|
|
107
|
+
{
|
|
108
|
+
target: "rate",
|
|
109
|
+
points: [
|
|
110
|
+
{ t: 0, v: 0.5 },
|
|
111
|
+
{ t: 2, v: 1 },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("readClipBadges", () => {
|
|
118
|
+
it("shows nothing for a plain clip at 100%", () => {
|
|
119
|
+
expect(readClipBadges(baseState)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("lists every applied tool in the wireframe's order", () => {
|
|
123
|
+
const labels = readClipBadges({
|
|
124
|
+
...baseState,
|
|
125
|
+
volume: 1.8,
|
|
126
|
+
fxChain: chainWithVoicePreset(null, "voice-clean"),
|
|
127
|
+
automation: rampAutomation,
|
|
128
|
+
colorGrading: lookAttrValue("warm-daylight"),
|
|
129
|
+
clipPath: "inset(0px 20px)",
|
|
130
|
+
carve: "{}",
|
|
131
|
+
link: "talk",
|
|
132
|
+
}).map((b) => b.label);
|
|
133
|
+
expect(labels).toEqual([
|
|
134
|
+
"Linked",
|
|
135
|
+
"Warm daylight",
|
|
136
|
+
"Voice: Clean",
|
|
137
|
+
"Ramp",
|
|
138
|
+
"Crop",
|
|
139
|
+
"Ducked",
|
|
140
|
+
"180%",
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("badges a muted audio clip but not muted b-roll", () => {
|
|
145
|
+
expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted");
|
|
146
|
+
expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("caps at two visible badges plus the link badge", () => {
|
|
150
|
+
const badges = readClipBadges({
|
|
151
|
+
...baseState,
|
|
152
|
+
link: "talk",
|
|
153
|
+
colorGrading: lookAttrValue("mono-clean"),
|
|
154
|
+
clipPath: "inset(5px)",
|
|
155
|
+
volume: 0.6,
|
|
156
|
+
});
|
|
157
|
+
const { visible, hidden } = splitVisibleBadges(badges);
|
|
158
|
+
expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]);
|
|
159
|
+
expect(hidden.map((b) => b.label)).toEqual(["60%"]);
|
|
160
|
+
});
|
|
161
|
+
});
|