@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- 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/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- 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/domEditOverlayStartGesture.ts +5 -2
- 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/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.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 +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- 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/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- 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.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
|
|
4
|
+
import { useClipToolState } from "./useClipToolState";
|
|
5
|
+
|
|
6
|
+
function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
7
|
+
return (
|
|
8
|
+
<svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
|
|
9
|
+
<path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
|
|
10
|
+
{muted ? (
|
|
11
|
+
<path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
|
|
12
|
+
) : (
|
|
13
|
+
<path
|
|
14
|
+
d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
fill="none"
|
|
17
|
+
strokeWidth="1.4"
|
|
18
|
+
/>
|
|
19
|
+
)}
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function BadgeContent({ badge }: { badge: ClipBadge }) {
|
|
25
|
+
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
|
|
26
|
+
if (badge.kind === "volume") {
|
|
27
|
+
const muted = badge.label === "Muted";
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<SpeakerGlyph muted={muted} />
|
|
31
|
+
{muted ? null : <span>{badge.label}</span>}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return <span>{badge.label}</span>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BADGE_CLASS =
|
|
39
|
+
"inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
|
|
40
|
+
|
|
41
|
+
/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
|
|
42
|
+
export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
|
|
43
|
+
const state = useClipToolState(el);
|
|
44
|
+
const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
|
|
45
|
+
if (visible.length === 0) return null;
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
|
|
49
|
+
data-testid="clip-badges"
|
|
50
|
+
>
|
|
51
|
+
{visible.map((badge) => (
|
|
52
|
+
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
|
|
53
|
+
<BadgeContent badge={badge} />
|
|
54
|
+
</span>
|
|
55
|
+
))}
|
|
56
|
+
{hidden.length > 0 && (
|
|
57
|
+
<span
|
|
58
|
+
className={`${BADGE_CLASS} pointer-events-auto`}
|
|
59
|
+
title={hidden.map((badge) => badge.label).join(", ")}
|
|
60
|
+
data-badge="more"
|
|
61
|
+
>
|
|
62
|
+
+{hidden.length}
|
|
63
|
+
</span>
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
@@ -5,6 +5,8 @@ import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
|
5
5
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
6
6
|
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
|
+
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
|
+
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
8
10
|
|
|
9
11
|
interface ClipContextMenuProps {
|
|
10
12
|
x: number;
|
|
@@ -144,10 +146,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
144
146
|
<span>{splitLabel}</span>
|
|
145
147
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
146
148
|
</button>
|
|
149
|
+
<ClipMenuToolItems
|
|
150
|
+
group="time"
|
|
151
|
+
element={element}
|
|
152
|
+
currentTime={currentTime}
|
|
153
|
+
onClose={onClose}
|
|
154
|
+
/>
|
|
147
155
|
<div className="my-1 border-t border-neutral-700/60" />
|
|
148
156
|
</>
|
|
149
157
|
)}
|
|
150
158
|
|
|
159
|
+
<ClipMenuAudioItems element={element} onClose={onClose} />
|
|
160
|
+
|
|
151
161
|
{(onCopy || onPaste || onDuplicate) && (
|
|
152
162
|
<>
|
|
153
163
|
{onCopy && (
|
|
@@ -198,6 +208,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
198
208
|
</>
|
|
199
209
|
)}
|
|
200
210
|
|
|
211
|
+
<ClipMenuToolItems
|
|
212
|
+
group="sound"
|
|
213
|
+
element={element}
|
|
214
|
+
currentTime={currentTime}
|
|
215
|
+
onClose={onClose}
|
|
216
|
+
/>
|
|
217
|
+
<ClipMenuToolItems
|
|
218
|
+
group="picture"
|
|
219
|
+
element={element}
|
|
220
|
+
currentTime={currentTime}
|
|
221
|
+
onClose={onClose}
|
|
222
|
+
/>
|
|
201
223
|
<button
|
|
202
224
|
type="button"
|
|
203
225
|
role="menuitem"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipPeakMarks } from "./ClipPeakMarks";
|
|
6
|
+
|
|
7
|
+
const harness = createHappyDomRootHarness();
|
|
8
|
+
|
|
9
|
+
async function render(url: string, bins: number[], gain: number) {
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
"fetch",
|
|
12
|
+
vi.fn(async () => Response.json({ binSeconds: 1, bins })),
|
|
13
|
+
);
|
|
14
|
+
const host = document.createElement("div");
|
|
15
|
+
document.body.appendChild(host);
|
|
16
|
+
await act(async () => {
|
|
17
|
+
harness.mount(host).render(
|
|
18
|
+
<ClipPeakMarks peaksUrl={url} sourceWindow={{ mediaStart: 0, sourceSpan: 2 }} gain={gain}>
|
|
19
|
+
<span>wave</span>
|
|
20
|
+
</ClipPeakMarks>,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
await act(async () => {});
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
return host;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ClipPeakMarks", () => {
|
|
29
|
+
it("paints red marks and the peak on a clip that redlines", async () => {
|
|
30
|
+
const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1);
|
|
31
|
+
expect(host.textContent).toContain("wave");
|
|
32
|
+
expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain(
|
|
33
|
+
"▲ peaks −0.2 dBFS",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("paints nothing on a quiet clip", async () => {
|
|
38
|
+
const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1);
|
|
39
|
+
expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { clipPeakRuns, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
3
|
+
|
|
4
|
+
const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
|
|
5
|
+
|
|
6
|
+
function loadPeakMap(url: string): Promise<PeakMap | null> {
|
|
7
|
+
const pending = peakMapRequests.get(url);
|
|
8
|
+
if (pending) return pending;
|
|
9
|
+
const request = fetch(url)
|
|
10
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
11
|
+
.then((body: unknown) => (isPeakMap(body) ? body : null))
|
|
12
|
+
.catch(() => null);
|
|
13
|
+
peakMapRequests.set(url, request);
|
|
14
|
+
return request;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
|
|
18
|
+
|
|
19
|
+
/** A clip's waveform with red marks where it, at its own volume, reaches the export ceiling. */
|
|
20
|
+
export function ClipPeakMarks({
|
|
21
|
+
peaksUrl,
|
|
22
|
+
sourceWindow,
|
|
23
|
+
gain,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
peaksUrl: string | undefined;
|
|
27
|
+
sourceWindow: ClipSourceWindow;
|
|
28
|
+
gain: number;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const [map, setMap] = useState<PeakMap | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!peaksUrl) return;
|
|
34
|
+
let live = true;
|
|
35
|
+
void loadPeakMap(peaksUrl).then((loaded) => {
|
|
36
|
+
if (live) setMap(loaded);
|
|
37
|
+
});
|
|
38
|
+
return () => {
|
|
39
|
+
live = false;
|
|
40
|
+
};
|
|
41
|
+
}, [peaksUrl]);
|
|
42
|
+
const { runs, peakDbfs } = map
|
|
43
|
+
? clipPeakRuns(map, sourceWindow, gain)
|
|
44
|
+
: { runs: [], peakDbfs: null };
|
|
45
|
+
return (
|
|
46
|
+
<div className="relative h-full w-full">
|
|
47
|
+
{children}
|
|
48
|
+
{runs.length > 0 && peakDbfs !== null && (
|
|
49
|
+
<div
|
|
50
|
+
className="pointer-events-none absolute inset-0"
|
|
51
|
+
data-testid="clip-peak-marks"
|
|
52
|
+
title={`Peaks ${dbText(peakDbfs)} at this volume; export lowers the whole mix`}
|
|
53
|
+
>
|
|
54
|
+
{runs.map((run) => (
|
|
55
|
+
<div
|
|
56
|
+
key={run.from}
|
|
57
|
+
className="absolute inset-y-0 bg-red-500/80"
|
|
58
|
+
style={{
|
|
59
|
+
left: `${run.from * 100}%`,
|
|
60
|
+
width: `max(1px, ${(run.to - run.from) * 100}%)`,
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
<span className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400">
|
|
65
|
+
▲ peaks {dbText(peakDbfs)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
getTimelineScrollLeftForZoomAnchor,
|
|
17
17
|
getTimelineScrollLeftForZoomTransition,
|
|
18
18
|
shouldShowTimelineShortcutHint,
|
|
19
|
-
shouldHandleTimelineDeleteKey,
|
|
20
19
|
shouldAutoScrollTimeline,
|
|
21
20
|
getTimelineVisibleTimeRange,
|
|
22
21
|
getTimelineScrollTopForGeometryChange,
|
|
@@ -1248,26 +1247,6 @@ describe("shouldShowTimelineShortcutHint", () => {
|
|
|
1248
1247
|
});
|
|
1249
1248
|
});
|
|
1250
1249
|
|
|
1251
|
-
describe("shouldHandleTimelineDeleteKey", () => {
|
|
1252
|
-
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
1253
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
1254
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
it("ignores modifier shortcuts", () => {
|
|
1258
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
|
1259
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
it("ignores input and editable targets", () => {
|
|
1263
|
-
const input = { tagName: "INPUT", isContentEditable: false };
|
|
1264
|
-
const editable = { tagName: "DIV", isContentEditable: true };
|
|
1265
|
-
|
|
1266
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
|
1267
|
-
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
|
1268
|
-
});
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
1250
|
describe("getDefaultDroppedTrack", () => {
|
|
1272
1251
|
it("defaults to track 0 when there are no rows yet", () => {
|
|
1273
1252
|
expect(getDefaultDroppedTrack([])).toBe(0);
|
|
@@ -26,7 +26,6 @@ export {
|
|
|
26
26
|
getTimelineCanvasHeight,
|
|
27
27
|
shouldShowTimelineShortcutHint,
|
|
28
28
|
resolveTimelineAssetDrop,
|
|
29
|
-
shouldHandleTimelineDeleteKey,
|
|
30
29
|
getDefaultDroppedTrack,
|
|
31
30
|
} from "./timelineLayout";
|
|
32
31
|
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
|
|
@@ -11,6 +11,7 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
|
11
11
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
12
12
|
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
13
13
|
import { rendersWaveform } from "./AudioWaveform";
|
|
14
|
+
import { ClipBadges } from "./ClipBadges";
|
|
14
15
|
|
|
15
16
|
interface TimelineClipProps {
|
|
16
17
|
el: TimelineElement;
|
|
@@ -204,6 +205,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
204
205
|
</div>
|
|
205
206
|
)}
|
|
206
207
|
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
208
|
+
{showLabel && !isGestureActor && <ClipBadges el={el} />}
|
|
207
209
|
{showDefaultText && (
|
|
208
210
|
<span className="timeline-clip__timecode">
|
|
209
211
|
{startLabel}-{endLabel}s
|
|
@@ -181,13 +181,13 @@ describe("TimelineFxButton", () => {
|
|
|
181
181
|
<TimelineFxButton
|
|
182
182
|
variant="group-pointer"
|
|
183
183
|
clipCount={2}
|
|
184
|
-
refusal="
|
|
184
|
+
refusal="This track can't be grouped."
|
|
185
185
|
onGroupClips={onGroupClips}
|
|
186
186
|
/>,
|
|
187
187
|
);
|
|
188
188
|
act(() => byTextButton(host, "FX")?.click());
|
|
189
189
|
const dialog = document.querySelector('[role="dialog"]');
|
|
190
|
-
expect(dialog?.textContent).toContain("
|
|
190
|
+
expect(dialog?.textContent).toContain("This track can't be grouped");
|
|
191
191
|
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
|
|
192
192
|
expect(
|
|
193
193
|
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
|
|
@@ -869,6 +869,43 @@ describe("TimelineTrackHeader", () => {
|
|
|
869
869
|
act(() => view.root.unmount());
|
|
870
870
|
});
|
|
871
871
|
|
|
872
|
+
it("offers grouping on a track of audible videos, not on muted b-roll", () => {
|
|
873
|
+
const aRoll: TimelineElement = {
|
|
874
|
+
...VOICE,
|
|
875
|
+
id: "a-roll",
|
|
876
|
+
domId: "a-roll",
|
|
877
|
+
tag: "video",
|
|
878
|
+
hasAudio: true,
|
|
879
|
+
};
|
|
880
|
+
const aRoll2: TimelineElement = { ...aRoll, id: "a-roll-2", domId: "a-roll-2" };
|
|
881
|
+
const bRoll: TimelineElement = {
|
|
882
|
+
...aRoll,
|
|
883
|
+
id: "b-roll",
|
|
884
|
+
domId: "b-roll",
|
|
885
|
+
hasAudio: undefined,
|
|
886
|
+
};
|
|
887
|
+
const bRoll2: TimelineElement = { ...bRoll, id: "b-roll-2", domId: "b-roll-2" };
|
|
888
|
+
const opts = {
|
|
889
|
+
keyframeClip: aRoll,
|
|
890
|
+
trackElements: [aRoll, aRoll2],
|
|
891
|
+
clipCount: 2,
|
|
892
|
+
animations: [],
|
|
893
|
+
expanded: false,
|
|
894
|
+
};
|
|
895
|
+
const pointer = (host: HTMLElement) =>
|
|
896
|
+
host.querySelector<HTMLButtonElement>(
|
|
897
|
+
'button[aria-label="Effects — group these clips first"]',
|
|
898
|
+
);
|
|
899
|
+
const view = renderHeader(opts);
|
|
900
|
+
const button = pointer(view.host);
|
|
901
|
+
expect(button).not.toBeNull();
|
|
902
|
+
act(() => button?.click());
|
|
903
|
+
expect(document.body.textContent).not.toContain("can't be grouped");
|
|
904
|
+
view.rerender({ ...opts, keyframeClip: bRoll, trackElements: [bRoll, bRoll2] });
|
|
905
|
+
expect(pointer(view.host)).toBeNull();
|
|
906
|
+
act(() => view.root.unmount());
|
|
907
|
+
});
|
|
908
|
+
|
|
872
909
|
// The header is a 48px column of exactly TWO lines — what the row is, then
|
|
873
910
|
// what you can do to it. The group pointer used to render as a sibling of
|
|
874
911
|
// both, making a third: 17 + 24 + 24 + gaps in a 48px box, which
|
|
@@ -226,11 +226,9 @@ export function TimelineTrackHeader({
|
|
|
226
226
|
const singleAudioClip =
|
|
227
227
|
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
|
228
228
|
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const isVideoWithAudioTrack =
|
|
233
|
-
!isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
|
|
229
|
+
const audioBearingClips = isAudioTrack
|
|
230
|
+
? trackElements
|
|
231
|
+
: trackElements.filter((el) => el.hasAudio === true);
|
|
234
232
|
const writeClipFxChain = (
|
|
235
233
|
clip: TimelineElement,
|
|
236
234
|
next: HfAudioFxChain,
|
|
@@ -256,7 +254,7 @@ export function TimelineTrackHeader({
|
|
|
256
254
|
// UI showed the track as grouped. The button is withheld instead of acting on
|
|
257
255
|
// a subset, which is also why the carve path's loud guard cannot catch this:
|
|
258
256
|
// the unresolvable ids were filtered out before the call.
|
|
259
|
-
const groupableClipIds =
|
|
257
|
+
const groupableClipIds = audioBearingClips.map(runtimeAudioId);
|
|
260
258
|
const allGroupableClipIds = groupableClipIds.every((id): id is string => id !== null)
|
|
261
259
|
? groupableClipIds
|
|
262
260
|
: null;
|
|
@@ -342,25 +340,14 @@ export function TimelineTrackHeader({
|
|
|
342
340
|
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
343
341
|
/>
|
|
344
342
|
)}
|
|
345
|
-
{showAudioEffects &&
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Groups are audio-only in v1 (§1.4). A video track showing no
|
|
354
|
-
// button at all is the silent limit §5 forbids, so it gets the
|
|
355
|
-
// button and a reason instead.
|
|
356
|
-
refusal={
|
|
357
|
-
isAudioTrack
|
|
358
|
-
? undefined
|
|
359
|
-
: "Video audio can't be grouped yet — only audio clips can join a group."
|
|
360
|
-
}
|
|
361
|
-
onGroupClips={groupUngroupedClips}
|
|
362
|
-
/>
|
|
363
|
-
)}
|
|
343
|
+
{showAudioEffects && clipCount > 1 && !isTrackGrouped && canGroupWholeTrack && (
|
|
344
|
+
<TimelineFxButton
|
|
345
|
+
variant="group-pointer"
|
|
346
|
+
clipCount={audioBearingClips.length}
|
|
347
|
+
defaultLabel={trackLabel}
|
|
348
|
+
onGroupClips={groupUngroupedClips}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
364
351
|
{/* The lane disclosure, on the row's own layout rather than by
|
|
365
352
|
swapping it for a keyframe-layer row. */}
|
|
366
353
|
{disclosable && (
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
6
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
7
|
+
|
|
8
|
+
const showToast = vi.fn();
|
|
9
|
+
const setQuiet = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {});
|
|
10
|
+
const iframe = document.createElement("iframe");
|
|
11
|
+
|
|
12
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
13
|
+
useStudioShellContextOptional: () => ({
|
|
14
|
+
projectId: "p1",
|
|
15
|
+
showToast,
|
|
16
|
+
previewIframeRef: { current: iframe },
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
20
|
+
useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const harness = createHappyDomRootHarness();
|
|
24
|
+
|
|
25
|
+
function render(element: TimelineElement) {
|
|
26
|
+
const host = document.createElement("div");
|
|
27
|
+
document.body.appendChild(host);
|
|
28
|
+
act(() =>
|
|
29
|
+
harness.mount(host).render(<ClipMenuAudioItems element={element} onClose={() => {}} />),
|
|
30
|
+
);
|
|
31
|
+
return host;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const base = { start: 0, duration: 4, track: 0 };
|
|
35
|
+
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
36
|
+
const clickItem = async (host: HTMLElement, label: string) => {
|
|
37
|
+
const button = [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
|
|
38
|
+
await act(async () => button?.click());
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
showToast.mockClear();
|
|
43
|
+
setQuiet.mockReset();
|
|
44
|
+
setQuiet.mockResolvedValue(undefined);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("ClipMenuAudioItems", () => {
|
|
48
|
+
it("offers nothing on a muted video", () => {
|
|
49
|
+
const host = render({ ...base, id: "b", tag: "video", hasAudio: true, muted: true });
|
|
50
|
+
expect(host.textContent).toBe("");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("normalizes a video with sound by writing data-volume as one edit", async () => {
|
|
54
|
+
const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null };
|
|
55
|
+
vi.stubGlobal(
|
|
56
|
+
"fetch",
|
|
57
|
+
vi.fn(async () => Response.json({ plan })),
|
|
58
|
+
);
|
|
59
|
+
const element: TimelineElement = {
|
|
60
|
+
...base,
|
|
61
|
+
id: "a-roll",
|
|
62
|
+
tag: "video",
|
|
63
|
+
hasAudio: true,
|
|
64
|
+
src: "talk.mp4",
|
|
65
|
+
};
|
|
66
|
+
const host = render(element);
|
|
67
|
+
const button = [...host.querySelectorAll("button")].find((b) =>
|
|
68
|
+
b.textContent?.includes("Normalize loudness"),
|
|
69
|
+
);
|
|
70
|
+
await act(async () => button?.click());
|
|
71
|
+
await vi.waitFor(() => expect(setQuiet).toHaveBeenCalled());
|
|
72
|
+
expect(setQuiet).toHaveBeenCalledWith(element, "data-volume", "2", "Normalize loudness");
|
|
73
|
+
expect(showToast).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info");
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refuses to normalize a clip whose volume lane owns its gain", async () => {
|
|
78
|
+
const fetchSpy = vi.fn(async () => Response.json({ plan }));
|
|
79
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
80
|
+
const automation = JSON.stringify({
|
|
81
|
+
version: 1,
|
|
82
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }],
|
|
83
|
+
});
|
|
84
|
+
const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3", automation });
|
|
85
|
+
await clickItem(host, "Normalize loudness");
|
|
86
|
+
await vi.waitFor(() => expect(showToast).toHaveBeenCalled());
|
|
87
|
+
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("volume is automated"), "error");
|
|
88
|
+
expect(setQuiet).not.toHaveBeenCalled();
|
|
89
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
90
|
+
vi.unstubAllGlobals();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reports a failed save instead of claiming the clip was normalized", async () => {
|
|
94
|
+
vi.stubGlobal(
|
|
95
|
+
"fetch",
|
|
96
|
+
vi.fn(async () => Response.json({ plan })),
|
|
97
|
+
);
|
|
98
|
+
setQuiet.mockResolvedValue({ status: "failed", reason: "disk full" });
|
|
99
|
+
const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3" });
|
|
100
|
+
await clickItem(host, "Normalize loudness");
|
|
101
|
+
await vi.waitFor(() => expect(showToast).toHaveBeenCalledWith("disk full", "error"));
|
|
102
|
+
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Normalized"), "info");
|
|
103
|
+
vi.unstubAllGlobals();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("stops ducking at the first refused save and says why", async () => {
|
|
107
|
+
document.body.appendChild(iframe);
|
|
108
|
+
const doc = iframe.contentDocument;
|
|
109
|
+
if (!doc) throw new Error("fixture");
|
|
110
|
+
doc.body.innerHTML = `<audio id="music" src="music.mp3" data-start="0" data-duration="10"></audio>
|
|
111
|
+
<audio id="voiceover" src="vo.wav" data-start="1" data-duration="3"></audio>`;
|
|
112
|
+
setQuiet.mockResolvedValue({
|
|
113
|
+
status: "refused",
|
|
114
|
+
reason: "Cannot edit timeline while recording",
|
|
115
|
+
});
|
|
116
|
+
const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" });
|
|
117
|
+
await clickItem(host, "Duck under voice");
|
|
118
|
+
await vi.waitFor(() =>
|
|
119
|
+
expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"),
|
|
120
|
+
);
|
|
121
|
+
expect(setQuiet).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Ducks under"), "info");
|
|
123
|
+
iframe.remove();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { formatAudioGain } from "@hyperframes/core/audio-gain";
|
|
2
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
3
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
4
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
5
|
+
import {
|
|
6
|
+
clipHasSound,
|
|
7
|
+
normalizeToastText,
|
|
8
|
+
requestNormalizePlan,
|
|
9
|
+
throwUnlessSaved,
|
|
10
|
+
TimelineSaveError,
|
|
11
|
+
VOLUME_LANE_REFUSAL,
|
|
12
|
+
volumeLaneOwnsGain,
|
|
13
|
+
} from "./clipMenuNormalize";
|
|
14
|
+
import { isDuckableBed, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
|
|
15
|
+
|
|
16
|
+
const ITEM_CLASS =
|
|
17
|
+
"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";
|
|
18
|
+
|
|
19
|
+
const DUCK_TOAST: Record<DuckOutcome, string> = {
|
|
20
|
+
ducked: "Ducks under the voice. Normalize the voice first for the most even result.",
|
|
21
|
+
off: "No longer ducks under the voice.",
|
|
22
|
+
"no-voice": "No overlapping voice to duck under.",
|
|
23
|
+
aborted: "Could not group the voices to duck under.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Sound group of the clip menu: Normalize loudness (one-shot) and Duck under voice (toggle). */
|
|
27
|
+
export function ClipMenuAudioItems({
|
|
28
|
+
element,
|
|
29
|
+
onClose,
|
|
30
|
+
}: {
|
|
31
|
+
element: TimelineElement;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
}) {
|
|
34
|
+
const shell = useStudioShellContextOptional();
|
|
35
|
+
const { onSetElementAttributeQuiet, onGroupClips } = useTimelineEditContextOptional();
|
|
36
|
+
const doc = shell?.previewIframeRef.current?.contentDocument ?? null;
|
|
37
|
+
const bed = doc?.getElementById(element.domId ?? element.id) ?? null;
|
|
38
|
+
const ducked = bed ? readBedCarve(bed)?.enabled === true : false;
|
|
39
|
+
if (!clipHasSound(element) || !shell || !onSetElementAttributeQuiet) return null;
|
|
40
|
+
const { showToast, projectId } = shell;
|
|
41
|
+
|
|
42
|
+
const normalize = async () => {
|
|
43
|
+
onClose();
|
|
44
|
+
if (volumeLaneOwnsGain(element)) {
|
|
45
|
+
showToast(VOLUME_LANE_REFUSAL, "error");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const plan = await requestNormalizePlan(projectId, element);
|
|
50
|
+
throwUnlessSaved(
|
|
51
|
+
await onSetElementAttributeQuiet(
|
|
52
|
+
element,
|
|
53
|
+
"data-volume",
|
|
54
|
+
formatAudioGain(plan.volume),
|
|
55
|
+
"Normalize loudness",
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
showToast(normalizeToastText(plan), "info");
|
|
59
|
+
} catch (error) {
|
|
60
|
+
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const toggleDuck = async () => {
|
|
65
|
+
onClose();
|
|
66
|
+
if (!doc || !bed) return;
|
|
67
|
+
const outcome = await setDuckUnderVoice(
|
|
68
|
+
doc,
|
|
69
|
+
bed,
|
|
70
|
+
!ducked,
|
|
71
|
+
async (attr, value) => {
|
|
72
|
+
throwUnlessSaved(
|
|
73
|
+
await onSetElementAttributeQuiet(element, attr, value, "Duck under voice"),
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
onGroupClips ? (ids, groupId) => onGroupClips(ids, groupId, "Voice") : undefined,
|
|
77
|
+
).catch((error: unknown): DuckOutcome | TimelineSaveError =>
|
|
78
|
+
error instanceof TimelineSaveError ? error : "aborted",
|
|
79
|
+
);
|
|
80
|
+
if (outcome instanceof TimelineSaveError) {
|
|
81
|
+
showToast(outcome.message, "error");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
<button type="button" role="menuitem" className={ITEM_CLASS} onClick={() => void normalize()}>
|
|
90
|
+
<span>Normalize loudness</span>
|
|
91
|
+
</button>
|
|
92
|
+
{isDuckableBed(bed) && (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
role="menuitemcheckbox"
|
|
96
|
+
aria-checked={ducked}
|
|
97
|
+
className={ITEM_CLASS}
|
|
98
|
+
onClick={() => void toggleDuck()}
|
|
99
|
+
>
|
|
100
|
+
<span>
|
|
101
|
+
<span className="inline-block w-3">{ducked ? "✓" : ""}</span>Duck under voice
|
|
102
|
+
</span>
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|