@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { useTimelineEditRefusal } from "./timelineEditPermission";
|
|
4
|
+
|
|
5
|
+
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
6
|
+
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
7
|
+
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
8
|
+
|
|
9
|
+
interface GuardedTimelineEntry {
|
|
10
|
+
resolveTargets: GuardedTimelineResolver;
|
|
11
|
+
onRefused?: GuardedTimelineRefusal;
|
|
12
|
+
wrapped: GuardedTimelineHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useTimelineEditGuard(
|
|
16
|
+
canEdit: Parameters<typeof useTimelineEditRefusal>[0],
|
|
17
|
+
showToast: Parameters<typeof useTimelineEditRefusal>[1],
|
|
18
|
+
) {
|
|
19
|
+
const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
|
|
20
|
+
const refuseEditRef = useRef(refuseEdit);
|
|
21
|
+
refuseEditRef.current = refuseEdit;
|
|
22
|
+
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
23
|
+
// Refuses (no call, no write, no history entry) when any target is
|
|
24
|
+
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
25
|
+
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
26
|
+
const guard = useCallback(
|
|
27
|
+
<H extends (...args: never[]) => Promise<unknown>>(
|
|
28
|
+
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
29
|
+
fn: H,
|
|
30
|
+
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
31
|
+
): H => {
|
|
32
|
+
const key = fn as unknown as GuardedTimelineHandler;
|
|
33
|
+
const cached = guardedRef.current.get(key);
|
|
34
|
+
if (cached) {
|
|
35
|
+
cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
36
|
+
cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
37
|
+
return cached.wrapped as H;
|
|
38
|
+
}
|
|
39
|
+
const entry = {} as GuardedTimelineEntry;
|
|
40
|
+
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
41
|
+
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
42
|
+
entry.wrapped = ((...args: Parameters<H>) => {
|
|
43
|
+
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
44
|
+
if (reason !== null)
|
|
45
|
+
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
46
|
+
return fn(...args);
|
|
47
|
+
}) as H as unknown as GuardedTimelineHandler;
|
|
48
|
+
guardedRef.current.set(key, entry);
|
|
49
|
+
return entry.wrapped as H;
|
|
50
|
+
},
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
return guard;
|
|
54
|
+
}
|
|
@@ -26,7 +26,14 @@ import {
|
|
|
26
26
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
27
27
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
28
28
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
29
|
+
import { useSetElementsAttribute } from "./timelineElementsAttribute";
|
|
29
30
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
31
|
+
import { useTimelineEditGuard } from "./useTimelineEditGuard";
|
|
32
|
+
import {
|
|
33
|
+
linkEditTargets,
|
|
34
|
+
useTimelineLinkEditing,
|
|
35
|
+
withLinkPartners,
|
|
36
|
+
} from "./useTimelineLinkEditing";
|
|
30
37
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
31
38
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
32
39
|
import {
|
|
@@ -35,26 +42,12 @@ import {
|
|
|
35
42
|
} from "./timelineTrackVisibility";
|
|
36
43
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
37
44
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
38
|
-
import {
|
|
39
|
-
useTimelineEditGate,
|
|
40
|
-
useTimelineEditRefusal,
|
|
41
|
-
type TimelineEditOutcome,
|
|
42
|
-
} from "./timelineEditPermission";
|
|
45
|
+
import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
|
|
43
46
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
44
47
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
45
48
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
46
49
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
47
50
|
|
|
48
|
-
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
49
|
-
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
50
|
-
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
51
|
-
|
|
52
|
-
interface GuardedTimelineEntry {
|
|
53
|
-
resolveTargets: GuardedTimelineResolver;
|
|
54
|
-
onRefused?: GuardedTimelineRefusal;
|
|
55
|
-
wrapped: GuardedTimelineHandler;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
51
|
export function useTimelineEditing({
|
|
59
52
|
projectId,
|
|
60
53
|
activeCompPath,
|
|
@@ -80,40 +73,7 @@ export function useTimelineEditing({
|
|
|
80
73
|
const editQueueRef = useRef(Promise.resolve());
|
|
81
74
|
const track = useTrackPendingTimelineEdit();
|
|
82
75
|
const checkEditable = useTimelineEditGate(canEdit, showToast);
|
|
83
|
-
const
|
|
84
|
-
const refuseEditRef = useRef(refuseEdit);
|
|
85
|
-
refuseEditRef.current = refuseEdit;
|
|
86
|
-
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
87
|
-
// Refuses (no call, no write, no history entry) when any target is
|
|
88
|
-
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
89
|
-
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
90
|
-
const guard = useCallback(
|
|
91
|
-
<H extends (...args: never[]) => Promise<unknown>>(
|
|
92
|
-
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
93
|
-
fn: H,
|
|
94
|
-
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
95
|
-
): H => {
|
|
96
|
-
const key = fn as unknown as GuardedTimelineHandler;
|
|
97
|
-
const cached = guardedRef.current.get(key);
|
|
98
|
-
if (cached) {
|
|
99
|
-
cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
100
|
-
cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
101
|
-
return cached.wrapped as H;
|
|
102
|
-
}
|
|
103
|
-
const entry = {} as GuardedTimelineEntry;
|
|
104
|
-
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
105
|
-
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
106
|
-
entry.wrapped = ((...args: Parameters<H>) => {
|
|
107
|
-
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
108
|
-
if (reason !== null)
|
|
109
|
-
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
110
|
-
return fn(...args);
|
|
111
|
-
}) as H as unknown as GuardedTimelineHandler;
|
|
112
|
-
guardedRef.current.set(key, entry);
|
|
113
|
-
return entry.wrapped as H;
|
|
114
|
-
},
|
|
115
|
-
[],
|
|
116
|
-
);
|
|
76
|
+
const guard = useTimelineEditGuard(canEdit, showToast);
|
|
117
77
|
|
|
118
78
|
const enqueueEdit = useCallback(
|
|
119
79
|
(
|
|
@@ -252,7 +212,7 @@ export function useTimelineEditing({
|
|
|
252
212
|
return reorderDone
|
|
253
213
|
.then(() => {
|
|
254
214
|
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
255
|
-
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
|
|
215
|
+
if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
|
|
256
216
|
return sdkTimingPersist(
|
|
257
217
|
element.hfId,
|
|
258
218
|
targetPath,
|
|
@@ -359,7 +319,7 @@ export function useTimelineEditing({
|
|
|
359
319
|
finishResizeGsapSync(),
|
|
360
320
|
);
|
|
361
321
|
const persistDone =
|
|
362
|
-
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
|
322
|
+
sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
|
|
363
323
|
? sdkTimingPersist(
|
|
364
324
|
element.hfId,
|
|
365
325
|
targetPath,
|
|
@@ -450,6 +410,17 @@ export function useTimelineEditing({
|
|
|
450
410
|
isRecordingRef,
|
|
451
411
|
});
|
|
452
412
|
|
|
413
|
+
const setElementsAttribute = useSetElementsAttribute({
|
|
414
|
+
projectIdRef,
|
|
415
|
+
activeCompPath,
|
|
416
|
+
showToast,
|
|
417
|
+
writeProjectFile,
|
|
418
|
+
recordEdit,
|
|
419
|
+
previewIframeRef,
|
|
420
|
+
pendingTimelineEditPathRef,
|
|
421
|
+
isRecordingRef,
|
|
422
|
+
});
|
|
423
|
+
|
|
453
424
|
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
|
454
425
|
projectIdRef,
|
|
455
426
|
activeCompPath,
|
|
@@ -461,7 +432,7 @@ export function useTimelineEditing({
|
|
|
461
432
|
isRecordingRef,
|
|
462
433
|
});
|
|
463
434
|
|
|
464
|
-
const { handleTimelineElementsDelete
|
|
435
|
+
const { handleTimelineElementsDelete } = useTimelineDeleteOps({
|
|
465
436
|
projectIdRef,
|
|
466
437
|
activeCompPath,
|
|
467
438
|
timelineElements,
|
|
@@ -491,6 +462,20 @@ export function useTimelineEditing({
|
|
|
491
462
|
checkEditable,
|
|
492
463
|
});
|
|
493
464
|
|
|
465
|
+
const linkEditing = useTimelineLinkEditing({
|
|
466
|
+
projectIdRef,
|
|
467
|
+
activeCompPath,
|
|
468
|
+
editQueueRef,
|
|
469
|
+
pendingTimelineEditPathRef,
|
|
470
|
+
isRecordingRef,
|
|
471
|
+
showToast,
|
|
472
|
+
writeProjectFile,
|
|
473
|
+
recordEdit,
|
|
474
|
+
reloadPreview,
|
|
475
|
+
forceReloadSdkSession,
|
|
476
|
+
handleTimelineElementsDelete,
|
|
477
|
+
});
|
|
478
|
+
|
|
494
479
|
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
495
480
|
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
|
|
496
481
|
projectId,
|
|
@@ -526,7 +511,9 @@ export function useTimelineEditing({
|
|
|
526
511
|
// Every write-handler is tracked here, the one place all hand edits
|
|
527
512
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
528
513
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
529
|
-
const trackedRazorSplit = track(
|
|
514
|
+
const trackedRazorSplit = track(
|
|
515
|
+
guard((element) => withLinkPartners([element]), handleRazorSplit),
|
|
516
|
+
);
|
|
530
517
|
return {
|
|
531
518
|
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
|
532
519
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
@@ -557,6 +544,7 @@ export function useTimelineEditing({
|
|
|
557
544
|
},
|
|
558
545
|
setElementFxAttribute: {
|
|
559
546
|
...setElementFxAttribute,
|
|
547
|
+
setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
|
|
560
548
|
setQuiet: track(
|
|
561
549
|
guard(
|
|
562
550
|
(element) => [element],
|
|
@@ -568,10 +556,16 @@ export function useTimelineEditing({
|
|
|
568
556
|
),
|
|
569
557
|
),
|
|
570
558
|
},
|
|
571
|
-
handleTimelineElementDelete: track(
|
|
559
|
+
handleTimelineElementDelete: track(
|
|
560
|
+
guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
|
|
561
|
+
),
|
|
572
562
|
handleTimelineElementsDelete: track(
|
|
573
|
-
guard(
|
|
563
|
+
guard(withLinkPartners, linkEditing.handleLinkedElementsDelete),
|
|
564
|
+
),
|
|
565
|
+
handleTimelineElementDeleteOnly: track(
|
|
566
|
+
guard((element) => [element], linkEditing.handleDeleteElementOnly),
|
|
574
567
|
),
|
|
568
|
+
handleLinkEdit: track(guard(linkEditTargets, linkEditing.handleLinkEdit)),
|
|
575
569
|
handleTimelineElementSplit: trackedRazorSplit,
|
|
576
570
|
handleRazorSplit: trackedRazorSplit,
|
|
577
571
|
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
6
|
+
import { useTimelineLinkEditing, withLinkPartners } from "./useTimelineLinkEditing";
|
|
7
|
+
import { useLinkedClipPreferences } from "../utils/linkedClipPreferences";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const clip = (id: string, tag: string, link?: string): TimelineElement => ({
|
|
12
|
+
id,
|
|
13
|
+
domId: id,
|
|
14
|
+
tag,
|
|
15
|
+
start: 0,
|
|
16
|
+
duration: 4,
|
|
17
|
+
track: 0,
|
|
18
|
+
...(link ? { link } : {}),
|
|
19
|
+
});
|
|
20
|
+
const video = clip("talk", "video", "lk-1");
|
|
21
|
+
const audio = clip("talk-audio", "audio", "lk-1");
|
|
22
|
+
const title = clip("title", "div");
|
|
23
|
+
|
|
24
|
+
const SOURCE =
|
|
25
|
+
'<div><video id="talk" muted data-link="lk-1"></video><audio id="talk-audio" data-link="lk-1"></audio></div>';
|
|
26
|
+
|
|
27
|
+
async function renderLinkEditing(handleTimelineElementsDelete = vi.fn()) {
|
|
28
|
+
usePlayerStore.getState().setElements([video, audio, title]);
|
|
29
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
30
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
let api: ReturnType<typeof useTimelineLinkEditing> | undefined;
|
|
32
|
+
function Harness() {
|
|
33
|
+
api = useTimelineLinkEditing({
|
|
34
|
+
projectIdRef: { current: "p1" },
|
|
35
|
+
activeCompPath: "index.html",
|
|
36
|
+
editQueueRef: { current: Promise.resolve() },
|
|
37
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
38
|
+
showToast: vi.fn(),
|
|
39
|
+
writeProjectFile,
|
|
40
|
+
recordEdit,
|
|
41
|
+
reloadPreview: vi.fn(),
|
|
42
|
+
handleTimelineElementsDelete,
|
|
43
|
+
});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
47
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
48
|
+
if (!api) throw new Error("hook did not render");
|
|
49
|
+
return { api, writeProjectFile, recordEdit, unmount: () => act(() => root.unmount()) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("withLinkPartners", () => {
|
|
53
|
+
it("adds partners after the selection, once", () => {
|
|
54
|
+
usePlayerStore.getState().setElements([video, audio, title]);
|
|
55
|
+
expect(withLinkPartners([video, title]).map((el) => el.id)).toEqual([
|
|
56
|
+
"talk",
|
|
57
|
+
"title",
|
|
58
|
+
"talk-audio",
|
|
59
|
+
]);
|
|
60
|
+
expect(withLinkPartners([video, audio])).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("useTimelineLinkEditing", () => {
|
|
65
|
+
afterEach(() => vi.restoreAllMocks());
|
|
66
|
+
|
|
67
|
+
it("deletes a linked clip with its partner", async () => {
|
|
68
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
69
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
70
|
+
await act(async () => api.handleLinkedElementDelete(video));
|
|
71
|
+
expect(del.mock.calls[0]?.[0].map((el: TimelineElement) => el.id)).toEqual([
|
|
72
|
+
"talk",
|
|
73
|
+
"talk-audio",
|
|
74
|
+
]);
|
|
75
|
+
unmount();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("with Linked Selection off, delete removes the clip alone and unlinks the survivor", async () => {
|
|
79
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
80
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
81
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
82
|
+
try {
|
|
83
|
+
expect(withLinkPartners([video])).toEqual([video]);
|
|
84
|
+
await act(async () => api.handleLinkedElementDelete(video));
|
|
85
|
+
expect(del).toHaveBeenCalledWith([video], [audio]);
|
|
86
|
+
} finally {
|
|
87
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
88
|
+
}
|
|
89
|
+
unmount();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("delete-only removes one member and unlinks the survivor", async () => {
|
|
93
|
+
const del = vi.fn().mockResolvedValue(undefined);
|
|
94
|
+
const { api, unmount } = await renderLinkEditing(del);
|
|
95
|
+
await act(async () => api.handleDeleteElementOnly(video));
|
|
96
|
+
expect(del).toHaveBeenCalledWith([video], [audio]);
|
|
97
|
+
unmount();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("unlink writes one undo step removing data-link from both", async () => {
|
|
101
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
102
|
+
new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
|
|
103
|
+
);
|
|
104
|
+
const { api, writeProjectFile, recordEdit, unmount } = await renderLinkEditing();
|
|
105
|
+
await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
|
|
106
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).not.toContain("data-link");
|
|
108
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips");
|
|
110
|
+
unmount();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("unlink leaves nothing selected, so a later trim moves only the grabbed clip", async () => {
|
|
114
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
115
|
+
new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
|
|
116
|
+
);
|
|
117
|
+
const { api, unmount } = await renderLinkEditing();
|
|
118
|
+
usePlayerStore.getState().setSelection(["talk", "talk-audio"], "talk");
|
|
119
|
+
await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
|
|
120
|
+
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
|
121
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
122
|
+
unmount();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
|
+
import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
|
|
4
|
+
import { expandToLinkedMembers } from "../player/components/audioClipLink";
|
|
5
|
+
import { isLinkedSelectionOn } from "../utils/linkedClipPreferences";
|
|
6
|
+
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
7
|
+
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
8
|
+
import { readFileContent } from "./timelineEditingHelpers";
|
|
9
|
+
import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
|
|
10
|
+
|
|
11
|
+
interface UseTimelineLinkEditingOptions {
|
|
12
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
13
|
+
activeCompPath: string | null;
|
|
14
|
+
editQueueRef: MutableRefObject<Promise<void>>;
|
|
15
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
16
|
+
isRecordingRef?: MutableRefObject<boolean>;
|
|
17
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
19
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
20
|
+
reloadPreview: () => void;
|
|
21
|
+
forceReloadSdkSession?: () => void;
|
|
22
|
+
handleTimelineElementsDelete: (
|
|
23
|
+
selection: TimelineElement[],
|
|
24
|
+
alsoUnlink?: readonly TimelineElement[],
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
29
|
+
|
|
30
|
+
/** Every clip a link edit writes, for the edit-permission gate. */
|
|
31
|
+
export function linkEditTargets(edit: TimelineLinkEdit): TimelineElement[] {
|
|
32
|
+
switch (edit.kind) {
|
|
33
|
+
case "unlink":
|
|
34
|
+
case "link":
|
|
35
|
+
return [...edit.elements];
|
|
36
|
+
case "detach":
|
|
37
|
+
case "move-into-sync":
|
|
38
|
+
case "slip-into-sync":
|
|
39
|
+
return [edit.element];
|
|
40
|
+
case "merge":
|
|
41
|
+
return [edit.video, edit.audio];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withLinkPartners(selection: readonly TimelineElement[]): TimelineElement[] {
|
|
46
|
+
const elements = usePlayerStore.getState().elements;
|
|
47
|
+
const keys = expandToLinkedMembers(selection.map(keyOf), elements, isLinkedSelectionOn());
|
|
48
|
+
const known = new Set(selection.map(keyOf));
|
|
49
|
+
return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clearSelectionOnUnlink(edit: TimelineLinkEdit): void {
|
|
53
|
+
if (edit.kind === "unlink") usePlayerStore.getState().clearSelection();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useTimelineLinkEditing({
|
|
57
|
+
projectIdRef,
|
|
58
|
+
activeCompPath,
|
|
59
|
+
editQueueRef,
|
|
60
|
+
pendingTimelineEditPathRef,
|
|
61
|
+
isRecordingRef,
|
|
62
|
+
showToast,
|
|
63
|
+
writeProjectFile,
|
|
64
|
+
recordEdit,
|
|
65
|
+
reloadPreview,
|
|
66
|
+
forceReloadSdkSession,
|
|
67
|
+
handleTimelineElementsDelete,
|
|
68
|
+
}: UseTimelineLinkEditingOptions) {
|
|
69
|
+
const handleLinkEdit = useCallback(
|
|
70
|
+
async (edit: TimelineLinkEdit) => {
|
|
71
|
+
if (isRecordingRef?.current) {
|
|
72
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const pid = projectIdRef.current;
|
|
76
|
+
const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
|
|
77
|
+
if (!pid || !plan) return;
|
|
78
|
+
clearSelectionOnUnlink(edit);
|
|
79
|
+
const path = plan.anchor.sourceFile || activeCompPath || "index.html";
|
|
80
|
+
pendingTimelineEditPathRef.current.add(path);
|
|
81
|
+
const queued = editQueueRef.current.then(() =>
|
|
82
|
+
saveProjectFilesWithHistory({
|
|
83
|
+
projectId: pid,
|
|
84
|
+
label: plan.label,
|
|
85
|
+
files: {
|
|
86
|
+
[path]: (current) => {
|
|
87
|
+
const next = plan.transform(current);
|
|
88
|
+
if (next === null || next === current) {
|
|
89
|
+
throw new Error(`Couldn't ${plan.label.toLowerCase()}`);
|
|
90
|
+
}
|
|
91
|
+
return next;
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
readFile: (filePath) => readFileContent(pid, filePath),
|
|
95
|
+
writeFile: writeProjectFile,
|
|
96
|
+
recordEdit,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
editQueueRef.current = queued.then(
|
|
100
|
+
() => undefined,
|
|
101
|
+
() => undefined,
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
await queued;
|
|
105
|
+
forceReloadSdkSession?.();
|
|
106
|
+
reloadPreview();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
showToast(getStudioSaveErrorMessage(error), "error");
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
[
|
|
112
|
+
activeCompPath,
|
|
113
|
+
editQueueRef,
|
|
114
|
+
forceReloadSdkSession,
|
|
115
|
+
isRecordingRef,
|
|
116
|
+
pendingTimelineEditPathRef,
|
|
117
|
+
projectIdRef,
|
|
118
|
+
recordEdit,
|
|
119
|
+
reloadPreview,
|
|
120
|
+
showToast,
|
|
121
|
+
writeProjectFile,
|
|
122
|
+
],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const handleLinkedElementsDelete = useCallback(
|
|
126
|
+
(selection: TimelineElement[]) => {
|
|
127
|
+
if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection));
|
|
128
|
+
const removed = new Set(selection.map(keyOf));
|
|
129
|
+
const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter(
|
|
130
|
+
(el) => !removed.has(keyOf(el)),
|
|
131
|
+
);
|
|
132
|
+
return handleTimelineElementsDelete(selection, orphans);
|
|
133
|
+
},
|
|
134
|
+
[handleTimelineElementsDelete],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const handleLinkedElementDelete = useCallback(
|
|
138
|
+
(element: TimelineElement) => handleLinkedElementsDelete([element]),
|
|
139
|
+
[handleLinkedElementsDelete],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleDeleteElementOnly = useCallback(
|
|
143
|
+
(element: TimelineElement) => {
|
|
144
|
+
const orphans = clipsToUnlink([element], usePlayerStore.getState().elements).filter(
|
|
145
|
+
(el) => keyOf(el) !== keyOf(element),
|
|
146
|
+
);
|
|
147
|
+
return handleTimelineElementsDelete([element], orphans);
|
|
148
|
+
},
|
|
149
|
+
[handleTimelineElementsDelete],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
handleLinkEdit,
|
|
154
|
+
handleLinkedElementsDelete,
|
|
155
|
+
handleLinkedElementDelete,
|
|
156
|
+
handleDeleteElementOnly,
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,11 @@ export {
|
|
|
31
31
|
liveTime,
|
|
32
32
|
formatTime,
|
|
33
33
|
} from "./player";
|
|
34
|
+
export { usePreviewIframeStore } from "./player/store/previewIframeStore";
|
|
35
|
+
export {
|
|
36
|
+
openAudioGainDialog,
|
|
37
|
+
useAudioGainDialogStore,
|
|
38
|
+
} from "./player/components/audioGainDialogStore";
|
|
34
39
|
export type {
|
|
35
40
|
PlayerHandle,
|
|
36
41
|
PlayerHandleElement,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
5
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
6
|
+
import { AudioGainDialog } from "./AudioGainDialog";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
9
|
+
|
|
10
|
+
const onNotice = vi.fn();
|
|
11
|
+
const setQuiet = vi.fn(async () => {});
|
|
12
|
+
const setMany = vi.fn(
|
|
13
|
+
async (_edits: ReadonlyArray<{ value: string | null }>, _attr: string, _label: string) => {},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
17
|
+
useStudioShellContextOptional: () => null,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
20
|
+
useTimelineEditContextOptional: () => ({
|
|
21
|
+
onSetElementAttributeQuiet: setQuiet,
|
|
22
|
+
onSetElementsAttributeQuiet: setMany,
|
|
23
|
+
onNotice,
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const harness = createHappyDomRootHarness();
|
|
28
|
+
const clip = (id: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
29
|
+
id,
|
|
30
|
+
tag: "audio",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: 2,
|
|
33
|
+
track: 0,
|
|
34
|
+
src: `${id}.wav`,
|
|
35
|
+
...extra,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function stubPeaks(peaks: Record<string, number[]>) {
|
|
39
|
+
vi.stubGlobal(
|
|
40
|
+
"fetch",
|
|
41
|
+
vi.fn(async (url: string) => {
|
|
42
|
+
const name = Object.keys(peaks).find((key) => String(url).includes(`/peaks/${key}.wav`));
|
|
43
|
+
return name
|
|
44
|
+
? Response.json({ binSeconds: 1, bins: peaks[name] })
|
|
45
|
+
: new Response("", { status: 404 });
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function open(elements: TimelineElement[]) {
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
document.body.appendChild(host);
|
|
53
|
+
act(() => harness.mount(host).render(<AudioGainDialog elements={elements} onClose={() => {}} />));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const row = (text: string) =>
|
|
57
|
+
[...document.querySelectorAll("label")].find((label) => label.textContent?.startsWith(text));
|
|
58
|
+
const choose = (text: string) =>
|
|
59
|
+
act(() => row(text)?.querySelector<HTMLInputElement>("input[type=radio]")?.click());
|
|
60
|
+
function typeDb(text: string, value: string) {
|
|
61
|
+
const input = row(text)?.querySelector<HTMLInputElement>("input[type=number]");
|
|
62
|
+
if (!input) throw new Error(`no field for ${text}`);
|
|
63
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
64
|
+
act(() => {
|
|
65
|
+
setter?.call(input, value);
|
|
66
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const ok = () => [...document.querySelectorAll("button")].find((b) => b.textContent === "OK");
|
|
70
|
+
const peakText = () => document.querySelector("[data-testid='audio-gain-peak']")?.textContent;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
usePlayerStore.getState().beginTimelineSession("p1");
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
77
|
+
|
|
78
|
+
describe("AudioGainDialog", () => {
|
|
79
|
+
it("shows the selection's peak amplitude at the clips' current gain", async () => {
|
|
80
|
+
stubPeaks({ pk1: [0.25, 0.5] });
|
|
81
|
+
open([clip("pk1", { volume: 2 })]);
|
|
82
|
+
await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: 0.0 dB"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("Set Gain writes every selected clip in one undo step", async () => {
|
|
86
|
+
stubPeaks({});
|
|
87
|
+
const a = clip("set-a");
|
|
88
|
+
const b = clip("set-b", { volume: 0.5 });
|
|
89
|
+
open([a, b]);
|
|
90
|
+
choose("Set Gain to");
|
|
91
|
+
typeDb("Set Gain to", "-6");
|
|
92
|
+
await act(async () => ok()?.click());
|
|
93
|
+
expect(setMany).toHaveBeenCalledTimes(1);
|
|
94
|
+
const [edits, attr, label] = setMany.mock.calls[0] ?? [];
|
|
95
|
+
expect(attr).toBe("data-volume");
|
|
96
|
+
expect(label).toBe("Audio Gain");
|
|
97
|
+
expect(edits?.map((e) => audioGainToDb(Number(e.value)))).toEqual([
|
|
98
|
+
expect.closeTo(-6, 3),
|
|
99
|
+
expect.closeTo(-6, 3),
|
|
100
|
+
]);
|
|
101
|
+
expect(setQuiet).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("Normalize All Peaks brings each clip's own peak to the target", async () => {
|
|
105
|
+
stubPeaks({ na1: [0.5], na2: [0.25] });
|
|
106
|
+
open([clip("na1"), clip("na2")]);
|
|
107
|
+
await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
|
|
108
|
+
choose("Normalize All Peaks to");
|
|
109
|
+
typeDb("Normalize All Peaks to", "-3");
|
|
110
|
+
await act(async () => ok()?.click());
|
|
111
|
+
const [edits] = setMany.mock.calls[0] ?? [];
|
|
112
|
+
const gains = edits?.map((e) => Number(e.value)) ?? [];
|
|
113
|
+
expect(audioGainToDb(0.5 * (gains[0] ?? 0))).toBeCloseTo(-3, 3);
|
|
114
|
+
expect(audioGainToDb(0.25 * (gains[1] ?? 0))).toBeCloseTo(-3, 3);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("refuses a peak target above 0 dB", async () => {
|
|
118
|
+
stubPeaks({ over: [0.5] });
|
|
119
|
+
open([clip("over")]);
|
|
120
|
+
await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
|
|
121
|
+
choose("Normalize Max Peak to");
|
|
122
|
+
typeDb("Normalize Max Peak to", "2");
|
|
123
|
+
expect(ok()?.disabled).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("disables the peak options when the clip cannot be measured", async () => {
|
|
127
|
+
stubPeaks({});
|
|
128
|
+
open([clip("unmeasured")]);
|
|
129
|
+
await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —"));
|
|
130
|
+
expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true);
|
|
131
|
+
expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|