@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,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState, type KeyboardEvent } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
2
2
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
3
|
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
@@ -30,6 +30,47 @@ const ROW_CLASS =
|
|
|
30
30
|
const DISABLED_ROW_CLASS =
|
|
31
31
|
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
|
|
32
32
|
const SUBMENU_WIDTH = 170;
|
|
33
|
+
const HOVER_PREVIEW_DELAY_MS = 80;
|
|
34
|
+
|
|
35
|
+
interface HoverPreview {
|
|
36
|
+
apply: (id: string | null) => void;
|
|
37
|
+
clear: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useHoverPreview(preview: HoverPreview | undefined) {
|
|
41
|
+
const previewRef = useRef(preview);
|
|
42
|
+
previewRef.current = preview;
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const activeRef = useRef(false);
|
|
45
|
+
|
|
46
|
+
const cancelTimer = () => {
|
|
47
|
+
if (timerRef.current === null) return;
|
|
48
|
+
clearTimeout(timerRef.current);
|
|
49
|
+
timerRef.current = null;
|
|
50
|
+
};
|
|
51
|
+
const end = () => {
|
|
52
|
+
cancelTimer();
|
|
53
|
+
if (!activeRef.current) return;
|
|
54
|
+
activeRef.current = false;
|
|
55
|
+
previewRef.current?.clear();
|
|
56
|
+
};
|
|
57
|
+
const settle = () => {
|
|
58
|
+
cancelTimer();
|
|
59
|
+
activeRef.current = false;
|
|
60
|
+
};
|
|
61
|
+
const start = (id: string | null) => {
|
|
62
|
+
if (!previewRef.current) return;
|
|
63
|
+
cancelTimer();
|
|
64
|
+
timerRef.current = setTimeout(() => {
|
|
65
|
+
timerRef.current = null;
|
|
66
|
+
activeRef.current = true;
|
|
67
|
+
previewRef.current?.apply(id);
|
|
68
|
+
}, HOVER_PREVIEW_DELAY_MS);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => end, []);
|
|
72
|
+
return { start, end, settle };
|
|
73
|
+
}
|
|
33
74
|
|
|
34
75
|
interface ChoiceSection {
|
|
35
76
|
heading?: string;
|
|
@@ -48,22 +89,36 @@ function ChoiceSubmenu({
|
|
|
48
89
|
sections,
|
|
49
90
|
activeId,
|
|
50
91
|
onPick,
|
|
92
|
+
preview,
|
|
51
93
|
}: {
|
|
52
94
|
label: string;
|
|
53
95
|
sections: readonly ChoiceSection[];
|
|
54
96
|
activeId: string | null;
|
|
55
97
|
onPick: (id: string | null) => void;
|
|
98
|
+
preview?: HoverPreview;
|
|
56
99
|
}) {
|
|
57
100
|
const [open, setOpen] = useState(false);
|
|
58
101
|
const [flipLeft, setFlipLeft] = useState(false);
|
|
59
102
|
const rowRef = useRef<HTMLButtonElement | null>(null);
|
|
60
103
|
const submenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
|
+
const hover = useHoverPreview(preview);
|
|
105
|
+
const openingFocusRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
const hideSubmenu = () => {
|
|
108
|
+
hover.end();
|
|
109
|
+
setOpen(false);
|
|
110
|
+
};
|
|
61
111
|
|
|
62
112
|
const show = (focusFirst: boolean) => {
|
|
63
113
|
const rect = rowRef.current?.getBoundingClientRect();
|
|
64
114
|
setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
|
|
65
115
|
setOpen(true);
|
|
66
|
-
if (focusFirst)
|
|
116
|
+
if (!focusFirst) return;
|
|
117
|
+
openingFocusRef.current = true;
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
focusSibling(submenuRef.current, 1);
|
|
120
|
+
openingFocusRef.current = false;
|
|
121
|
+
});
|
|
67
122
|
};
|
|
68
123
|
|
|
69
124
|
const onSubmenuKeyDown = (event: KeyboardEvent) => {
|
|
@@ -74,7 +129,7 @@ function ChoiceSubmenu({
|
|
|
74
129
|
} else if (event.key === "ArrowLeft") {
|
|
75
130
|
event.preventDefault();
|
|
76
131
|
event.stopPropagation();
|
|
77
|
-
|
|
132
|
+
hideSubmenu();
|
|
78
133
|
rowRef.current?.focus();
|
|
79
134
|
}
|
|
80
135
|
};
|
|
@@ -86,7 +141,15 @@ function ChoiceSubmenu({
|
|
|
86
141
|
role="menuitemradio"
|
|
87
142
|
aria-checked={activeId === id}
|
|
88
143
|
className={ROW_CLASS}
|
|
89
|
-
onClick={() =>
|
|
144
|
+
onClick={() => {
|
|
145
|
+
hover.settle();
|
|
146
|
+
onPick(id);
|
|
147
|
+
}}
|
|
148
|
+
onMouseEnter={() => hover.start(id)}
|
|
149
|
+
onFocus={() => {
|
|
150
|
+
if (openingFocusRef.current) openingFocusRef.current = false;
|
|
151
|
+
else hover.start(id);
|
|
152
|
+
}}
|
|
90
153
|
>
|
|
91
154
|
<span className="flex items-center gap-1.5">
|
|
92
155
|
<span aria-hidden="true" className="w-3 text-center">
|
|
@@ -98,7 +161,7 @@ function ChoiceSubmenu({
|
|
|
98
161
|
);
|
|
99
162
|
|
|
100
163
|
return (
|
|
101
|
-
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={
|
|
164
|
+
<div className="relative" onMouseEnter={() => show(false)} onMouseLeave={hideSubmenu}>
|
|
102
165
|
<button
|
|
103
166
|
ref={rowRef}
|
|
104
167
|
type="button"
|
|
@@ -175,14 +238,15 @@ function FreezeFrameItem({ element, currentTime, onClose }: Omit<ClipMenuToolIte
|
|
|
175
238
|
);
|
|
176
239
|
}
|
|
177
240
|
|
|
178
|
-
/** Freeze (time), Voice (sound), Look and Crop (picture)
|
|
241
|
+
/** Freeze (time), Voice (sound), Look and Crop (picture). */
|
|
179
242
|
export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
|
|
180
243
|
if (props.group === "time") return <FreezeFrameItem {...props} />;
|
|
181
244
|
return <ClipMenuAttributeItems {...props} />;
|
|
182
245
|
}
|
|
183
246
|
|
|
184
247
|
function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
|
|
185
|
-
const { onSetElementAttributeQuiet } =
|
|
248
|
+
const { onSetElementAttributeQuiet, onSetElementAttributeLive, onRevertElementAttributeLive } =
|
|
249
|
+
useTimelineEditContextOptional();
|
|
186
250
|
const state = useClipToolState(element);
|
|
187
251
|
const openCropBar = useCropPresetBarStore((s) => s.open);
|
|
188
252
|
if (!onSetElementAttributeQuiet) return null;
|
|
@@ -204,12 +268,19 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
204
268
|
write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
|
|
205
269
|
}
|
|
206
270
|
/>
|
|
207
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
208
271
|
</>
|
|
209
272
|
);
|
|
210
273
|
}
|
|
211
274
|
|
|
212
275
|
if (!isPictureClip(state.tag)) return null;
|
|
276
|
+
const lookPreview =
|
|
277
|
+
onSetElementAttributeLive && onRevertElementAttributeLive
|
|
278
|
+
? {
|
|
279
|
+
apply: (id: string | null) =>
|
|
280
|
+
onSetElementAttributeLive(element, HF_COLOR_GRADING_ATTR, lookAttrValue(id)),
|
|
281
|
+
clear: () => onRevertElementAttributeLive(element, HF_COLOR_GRADING_ATTR),
|
|
282
|
+
}
|
|
283
|
+
: undefined;
|
|
213
284
|
return (
|
|
214
285
|
<>
|
|
215
286
|
<ChoiceSubmenu
|
|
@@ -217,6 +288,7 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
217
288
|
sections={LOOK_SECTIONS}
|
|
218
289
|
activeId={activeLook(state.colorGrading)}
|
|
219
290
|
onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
|
|
291
|
+
preview={lookPreview}
|
|
220
292
|
/>
|
|
221
293
|
<button
|
|
222
294
|
type="button"
|
|
@@ -229,7 +301,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
|
|
|
229
301
|
>
|
|
230
302
|
<span>Crop</span>
|
|
231
303
|
</button>
|
|
232
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
233
304
|
</>
|
|
234
305
|
);
|
|
235
306
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../../utils/previewRelativePath";
|
|
3
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
4
|
+
import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
5
|
+
import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
|
|
6
|
+
|
|
7
|
+
const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
|
|
8
|
+
|
|
9
|
+
/** One fetch per peak-map URL per session; a failed or malformed answer reads as null. */
|
|
10
|
+
export function loadPeakMap(url: string): Promise<PeakMap | null> {
|
|
11
|
+
const pending = peakMapRequests.get(url);
|
|
12
|
+
if (pending) return pending;
|
|
13
|
+
const request = fetch(url)
|
|
14
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
15
|
+
.then((body: unknown) => (isPeakMap(body) ? body : null))
|
|
16
|
+
.catch(() => null);
|
|
17
|
+
peakMapRequests.set(url, request);
|
|
18
|
+
return request;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The studio-server `/peaks/*` URL for a clip's project media, or undefined for outside media. */
|
|
22
|
+
export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
|
|
23
|
+
const origin = window.location.origin;
|
|
24
|
+
const relative = resolvePreviewRelative(
|
|
25
|
+
resolveMediaPreviewUrl(src ?? "", projectId, origin),
|
|
26
|
+
projectId,
|
|
27
|
+
origin,
|
|
28
|
+
);
|
|
29
|
+
return relative
|
|
30
|
+
? buildProjectApiPath(projectId, `/peaks/${encodePreviewPath(relative)}`)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clipSourceWindow(el: TimelineElement): ClipSourceWindow {
|
|
35
|
+
return { mediaStart: el.playbackStart ?? 0, sourceSpan: el.duration * (el.playbackRate ?? 1) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The clip's source peak, linear, over the part it plays; null when it cannot be measured. */
|
|
39
|
+
export async function measureClipSourcePeak(
|
|
40
|
+
el: TimelineElement,
|
|
41
|
+
projectId: string,
|
|
42
|
+
): Promise<number | null> {
|
|
43
|
+
const url = clipPeaksUrl(el.src, projectId);
|
|
44
|
+
const map = url ? await loadPeakMap(url) : null;
|
|
45
|
+
return map ? clipSourcePeak(map, clipSourceWindow(el)) : null;
|
|
46
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { clipPeakRuns } from "./clipPeakRuns";
|
|
2
|
+
import { clipPeakRuns, clipSourcePeak } from "./clipPeakRuns";
|
|
3
3
|
|
|
4
4
|
const map = (bins: number[]) => ({ binSeconds: 1, bins });
|
|
5
5
|
|
|
@@ -36,3 +36,16 @@ describe("clipPeakRuns", () => {
|
|
|
36
36
|
expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]);
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("clipSourcePeak", () => {
|
|
41
|
+
it("is the loudest bin inside the played source window only", () => {
|
|
42
|
+
const bins = map([0.9, 0.2, 0.4, 0.3, 1]);
|
|
43
|
+
expect(clipSourcePeak(bins, { mediaStart: 1, sourceSpan: 3 })).toBe(0.4);
|
|
44
|
+
expect(clipSourcePeak(bins, { mediaStart: 0, sourceSpan: 5 })).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is null for an empty window or one past the file's end", () => {
|
|
48
|
+
expect(clipSourcePeak(map([0.5]), { mediaStart: 0, sourceSpan: 0 })).toBeNull();
|
|
49
|
+
expect(clipSourcePeak(map([0.5]), { mediaStart: 4, sourceSpan: 2 })).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -48,6 +48,17 @@ export function clipPeakRuns(
|
|
|
48
48
|
return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export function clipSourcePeak(map: PeakMap, window: ClipSourceWindow): number | null {
|
|
52
|
+
if (!(window.sourceSpan > 0) || !(map.binSeconds > 0)) return null;
|
|
53
|
+
const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds));
|
|
54
|
+
const last = Math.min(
|
|
55
|
+
map.bins.length,
|
|
56
|
+
Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds),
|
|
57
|
+
);
|
|
58
|
+
if (last <= first) return null;
|
|
59
|
+
return map.bins.slice(first, last).reduce((loudest, bin) => Math.max(loudest, bin), 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export function isPeakMap(value: unknown): value is PeakMap {
|
|
52
63
|
if (typeof value !== "object" || value === null) return false;
|
|
53
64
|
const bins: unknown = Reflect.get(value, "bins");
|
|
@@ -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
|
+
}
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
activeLook,
|
|
6
6
|
activeVoicePreset,
|
|
7
7
|
chainWithVoicePreset,
|
|
8
|
+
clipSpeedSuffix,
|
|
8
9
|
hasCrop,
|
|
9
10
|
isDucked,
|
|
11
|
+
clipVolumeBadge,
|
|
10
12
|
lookAttrValue,
|
|
11
|
-
|
|
12
|
-
splitVisibleBadges,
|
|
13
|
+
readClipEffects,
|
|
13
14
|
type ClipToolState,
|
|
14
15
|
} from "./clipToolAttrs";
|
|
15
16
|
|
|
@@ -98,7 +99,6 @@ const baseState: ClipToolState = {
|
|
|
98
99
|
colorGrading: null,
|
|
99
100
|
clipPath: null,
|
|
100
101
|
carve: null,
|
|
101
|
-
link: null,
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
const rampAutomation = JSON.stringify({
|
|
@@ -114,48 +114,64 @@ const rampAutomation = JSON.stringify({
|
|
|
114
114
|
],
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
describe("
|
|
118
|
-
it("
|
|
119
|
-
expect(
|
|
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
|
-
]);
|
|
117
|
+
describe("readClipEffects", () => {
|
|
118
|
+
it("lists nothing for a plain clip, so the fx badge stays grey", () => {
|
|
119
|
+
expect(readClipEffects(baseState)).toEqual([]);
|
|
120
|
+
expect(readClipEffects({ ...baseState, automation: rampAutomation, volume: 1.8 })).toEqual([]);
|
|
142
121
|
});
|
|
143
122
|
|
|
144
|
-
it("
|
|
145
|
-
expect(
|
|
146
|
-
|
|
123
|
+
it("lists every applied effect in the tooltip's order", () => {
|
|
124
|
+
expect(
|
|
125
|
+
readClipEffects({
|
|
126
|
+
...baseState,
|
|
127
|
+
fxChain: chainWithVoicePreset(null, "voice-clean"),
|
|
128
|
+
colorGrading: lookAttrValue("warm-daylight"),
|
|
129
|
+
clipPath: "inset(0px 20px)",
|
|
130
|
+
carve: "{}",
|
|
131
|
+
}),
|
|
132
|
+
).toEqual(["Look: Warm daylight", "Voice: Clean", "Crop", "Ducked"]);
|
|
147
133
|
});
|
|
148
134
|
|
|
149
|
-
it("
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
135
|
+
it("names a hand-added effect and skips disabled and carve-generated nodes", () => {
|
|
136
|
+
const chain = serializeAudioFxChain({
|
|
137
|
+
version: 1,
|
|
138
|
+
nodes: [
|
|
139
|
+
{ id: "a", type: "highpass", enabled: true, params: { frequency: 80 } },
|
|
140
|
+
{ id: "b", type: "gain", enabled: false, params: {} },
|
|
141
|
+
{ id: "c", type: "gain", enabled: true, fromCarve: true, params: {} },
|
|
142
|
+
],
|
|
156
143
|
});
|
|
157
|
-
const {
|
|
158
|
-
expect(
|
|
159
|
-
expect(
|
|
144
|
+
const effects = readClipEffects({ ...baseState, fxChain: chain });
|
|
145
|
+
expect(effects).toHaveLength(1);
|
|
146
|
+
expect(effects[0]).not.toBe("highpass");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reads a hand-made grading without a menu preset as a Look", () => {
|
|
150
|
+
expect(readClipEffects({ ...baseState, colorGrading: '{"adjust":{"contrast":0.2}}' })).toEqual([
|
|
151
|
+
"Look",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("clipVolumeBadge", () => {
|
|
157
|
+
it("shows volume off 100% and Muted only on a clip with sound", () => {
|
|
158
|
+
expect(clipVolumeBadge({ ...baseState, volume: 1.8 })).toBe("180%");
|
|
159
|
+
expect(clipVolumeBadge(baseState)).toBeNull();
|
|
160
|
+
expect(clipVolumeBadge({ ...baseState, tag: "audio", muted: true })).toBe("Muted");
|
|
161
|
+
expect(clipVolumeBadge({ ...baseState, hasSound: false, muted: true })).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("clipSpeedSuffix", () => {
|
|
166
|
+
it("shows a constant speed as a percentage and hides 100%", () => {
|
|
167
|
+
expect(clipSpeedSuffix(1.5, null)).toBe(" [150%]");
|
|
168
|
+
expect(clipSpeedSuffix(0.35, null)).toBe(" [35%]");
|
|
169
|
+
expect(clipSpeedSuffix(1, null)).toBe("");
|
|
170
|
+
expect(clipSpeedSuffix(undefined, null)).toBe("");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("shows a rate lane as a ramp, whatever the base rate", () => {
|
|
174
|
+
expect(clipSpeedSuffix(2, rampAutomation)).toBe(" [ramp]");
|
|
175
|
+
expect(clipSpeedSuffix(1, rampAutomation)).toBe(" [ramp]");
|
|
160
176
|
});
|
|
161
177
|
});
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
getAudioFxDef,
|
|
7
8
|
parseAudioFxChain,
|
|
8
9
|
serializeAudioFxChain,
|
|
9
10
|
type HfAudioFxChain,
|
|
11
|
+
type HfAudioFxNode,
|
|
10
12
|
} from "@hyperframes/core/audio-fx";
|
|
11
|
-
import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
|
|
13
|
+
import { activeAudioFxPresetIds, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
|
12
14
|
import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
|
13
15
|
import { parseRateLane } from "@hyperframes/core/speed-ramp";
|
|
14
16
|
import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
|
|
@@ -117,6 +119,17 @@ function hasRateRamp(rawAutomation: string | null | undefined): boolean {
|
|
|
117
119
|
return parseRateLane(rawAutomation) !== null;
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/** Premiere's speed cue on the clip name: ` [ramp]` under a rate lane, else ` [150%]` off 100%. */
|
|
123
|
+
export function clipSpeedSuffix(
|
|
124
|
+
playbackRate: number | null | undefined,
|
|
125
|
+
rawAutomation: string | null | undefined,
|
|
126
|
+
): string {
|
|
127
|
+
if (hasRateRamp(rawAutomation)) return " [ramp]";
|
|
128
|
+
const rate = playbackRate ?? 1;
|
|
129
|
+
if (!Number.isFinite(rate) || Math.abs(rate - 1) < 0.005) return "";
|
|
130
|
+
return ` [${Math.round(rate * 100)}%]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
|
|
121
134
|
|
|
122
135
|
export function hasCrop(clipPath: string | null | undefined): boolean {
|
|
@@ -124,13 +137,6 @@ export function hasCrop(clipPath: string | null | undefined): boolean {
|
|
|
124
137
|
return value !== "" && value !== "none" && !ZERO_INSET.test(value);
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
|
|
128
|
-
|
|
129
|
-
export interface ClipBadge {
|
|
130
|
-
kind: ClipBadgeKind;
|
|
131
|
-
label: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
140
|
/** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
|
|
135
141
|
export interface ClipToolState {
|
|
136
142
|
tag: string;
|
|
@@ -142,46 +148,43 @@ export interface ClipToolState {
|
|
|
142
148
|
colorGrading: string | null;
|
|
143
149
|
clipPath: string | null;
|
|
144
150
|
carve: string | null;
|
|
145
|
-
link: string | null;
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
if (state.muted) return { kind: "volume", label: "Muted" };
|
|
153
|
+
export function clipVolumeBadge(state: ClipToolState): string | null {
|
|
154
|
+
if (state.tag !== "audio" && !state.hasSound) return null;
|
|
155
|
+
if (state.muted) return "Muted";
|
|
152
156
|
const volume = state.volume ?? 1;
|
|
153
157
|
if (Math.abs(volume - 1) < 0.005) return null;
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
158
|
+
return `${Math.round(volume * 100)}%`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function lookEffect(rawGrading: string | null): string | null {
|
|
162
|
+
const grading = normalizeHfColorGrading(parseJson(rawGrading));
|
|
163
|
+
if (!grading) return null;
|
|
164
|
+
const label = lookLabel(grading.preset ?? null);
|
|
165
|
+
return label ? `Look: ${label}` : "Look";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function presetEffect(presetId: string): string {
|
|
169
|
+
const voice = voicePresetLabel(presetId);
|
|
170
|
+
return voice ? `Voice: ${voice}` : (getAudioFxPreset(presetId)?.label ?? presetId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function chainNodeEffect(node: HfAudioFxNode): string | null {
|
|
174
|
+
if (node.enabled === false || node.fromCarve) return null;
|
|
175
|
+
if (node.fromPreset) return presetEffect(node.fromPreset);
|
|
176
|
+
if (node.fromEq) return "EQ";
|
|
177
|
+
if (node.fromLeveller) return "Leveller";
|
|
178
|
+
return node.label ?? getAudioFxDef(node.type)?.label ?? node.type;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Every effect on the clip, in the fx badge tooltip's order; empty means a grey badge. */
|
|
182
|
+
export function readClipEffects(state: ClipToolState): string[] {
|
|
183
|
+
const effects: Array<string | null> = [
|
|
184
|
+
lookEffect(state.colorGrading),
|
|
185
|
+
...parseChainOrEmpty(state.fxChain).nodes.map(chainNodeEffect),
|
|
186
|
+
hasCrop(state.clipPath) ? "Crop" : null,
|
|
187
|
+
isDucked(state.carve) ? "Ducked" : null,
|
|
170
188
|
];
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const MAX_VISIBLE_BADGES = 2;
|
|
175
|
-
|
|
176
|
-
/** The link badge is always shown; the rest cap at two, then `+N`. */
|
|
177
|
-
export function splitVisibleBadges(badges: readonly ClipBadge[]): {
|
|
178
|
-
visible: ClipBadge[];
|
|
179
|
-
hidden: ClipBadge[];
|
|
180
|
-
} {
|
|
181
|
-
const link = badges.filter((badge) => badge.kind === "link");
|
|
182
|
-
const rest = badges.filter((badge) => badge.kind !== "link");
|
|
183
|
-
return {
|
|
184
|
-
visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
|
|
185
|
-
hidden: rest.slice(MAX_VISIBLE_BADGES),
|
|
186
|
-
};
|
|
189
|
+
return [...new Set(effects.filter((effect): effect is string => effect !== null))];
|
|
187
190
|
}
|