@hyperframes/studio 0.8.107 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -27,6 +27,12 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
|
27
27
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
28
28
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
29
29
|
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
30
|
+
import { useTimelineEditGuard } from "./useTimelineEditGuard";
|
|
31
|
+
import {
|
|
32
|
+
linkEditTargets,
|
|
33
|
+
useTimelineLinkEditing,
|
|
34
|
+
withLinkPartners,
|
|
35
|
+
} from "./useTimelineLinkEditing";
|
|
30
36
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
31
37
|
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
32
38
|
import {
|
|
@@ -35,26 +41,12 @@ import {
|
|
|
35
41
|
} from "./timelineTrackVisibility";
|
|
36
42
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
37
43
|
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
38
|
-
import {
|
|
39
|
-
useTimelineEditGate,
|
|
40
|
-
useTimelineEditRefusal,
|
|
41
|
-
type TimelineEditOutcome,
|
|
42
|
-
} from "./timelineEditPermission";
|
|
44
|
+
import { useTimelineEditGate, type TimelineEditOutcome } from "./timelineEditPermission";
|
|
43
45
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
44
46
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
45
47
|
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
46
48
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
47
49
|
|
|
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
50
|
export function useTimelineEditing({
|
|
59
51
|
projectId,
|
|
60
52
|
activeCompPath,
|
|
@@ -80,40 +72,7 @@ export function useTimelineEditing({
|
|
|
80
72
|
const editQueueRef = useRef(Promise.resolve());
|
|
81
73
|
const track = useTrackPendingTimelineEdit();
|
|
82
74
|
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
|
-
);
|
|
75
|
+
const guard = useTimelineEditGuard(canEdit, showToast);
|
|
117
76
|
|
|
118
77
|
const enqueueEdit = useCallback(
|
|
119
78
|
(
|
|
@@ -252,7 +211,7 @@ export function useTimelineEditing({
|
|
|
252
211
|
return reorderDone
|
|
253
212
|
.then(() => {
|
|
254
213
|
// SDK setTiming writes start only; a lane change needs the fallback's track patch.
|
|
255
|
-
if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
|
|
214
|
+
if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
|
|
256
215
|
return sdkTimingPersist(
|
|
257
216
|
element.hfId,
|
|
258
217
|
targetPath,
|
|
@@ -359,7 +318,7 @@ export function useTimelineEditing({
|
|
|
359
318
|
finishResizeGsapSync(),
|
|
360
319
|
);
|
|
361
320
|
const persistDone =
|
|
362
|
-
sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
|
|
321
|
+
sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
|
|
363
322
|
? sdkTimingPersist(
|
|
364
323
|
element.hfId,
|
|
365
324
|
targetPath,
|
|
@@ -461,7 +420,7 @@ export function useTimelineEditing({
|
|
|
461
420
|
isRecordingRef,
|
|
462
421
|
});
|
|
463
422
|
|
|
464
|
-
const { handleTimelineElementsDelete
|
|
423
|
+
const { handleTimelineElementsDelete } = useTimelineDeleteOps({
|
|
465
424
|
projectIdRef,
|
|
466
425
|
activeCompPath,
|
|
467
426
|
timelineElements,
|
|
@@ -491,9 +450,22 @@ export function useTimelineEditing({
|
|
|
491
450
|
checkEditable,
|
|
492
451
|
});
|
|
493
452
|
|
|
494
|
-
const
|
|
453
|
+
const linkEditing = useTimelineLinkEditing({
|
|
454
|
+
projectIdRef,
|
|
455
|
+
activeCompPath,
|
|
456
|
+
editQueueRef,
|
|
457
|
+
pendingTimelineEditPathRef,
|
|
458
|
+
isRecordingRef,
|
|
459
|
+
showToast,
|
|
460
|
+
writeProjectFile,
|
|
461
|
+
recordEdit,
|
|
462
|
+
reloadPreview,
|
|
463
|
+
forceReloadSdkSession,
|
|
464
|
+
handleTimelineElementsDelete,
|
|
465
|
+
});
|
|
495
466
|
|
|
496
|
-
const
|
|
467
|
+
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
468
|
+
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
|
|
497
469
|
projectId,
|
|
498
470
|
activeCompPath,
|
|
499
471
|
showToast,
|
|
@@ -527,7 +499,9 @@ export function useTimelineEditing({
|
|
|
527
499
|
// Every write-handler is tracked here, the one place all hand edits
|
|
528
500
|
// converge, so undo never races a write; canEdit gates the same point.
|
|
529
501
|
// Coverage boundary: see the PR body, not every kind resolves an element.
|
|
530
|
-
const trackedRazorSplit = track(
|
|
502
|
+
const trackedRazorSplit = track(
|
|
503
|
+
guard((element) => withLinkPartners([element]), handleRazorSplit),
|
|
504
|
+
);
|
|
531
505
|
return {
|
|
532
506
|
handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
|
|
533
507
|
handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
|
|
@@ -569,12 +543,19 @@ export function useTimelineEditing({
|
|
|
569
543
|
),
|
|
570
544
|
),
|
|
571
545
|
},
|
|
572
|
-
handleTimelineElementDelete: track(
|
|
546
|
+
handleTimelineElementDelete: track(
|
|
547
|
+
guard((element) => withLinkPartners([element]), linkEditing.handleLinkedElementDelete),
|
|
548
|
+
),
|
|
573
549
|
handleTimelineElementsDelete: track(
|
|
574
|
-
guard(
|
|
550
|
+
guard(withLinkPartners, linkEditing.handleLinkedElementsDelete),
|
|
551
|
+
),
|
|
552
|
+
handleTimelineElementDeleteOnly: track(
|
|
553
|
+
guard((element) => [element], linkEditing.handleDeleteElementOnly),
|
|
575
554
|
),
|
|
555
|
+
handleLinkEdit: track(guard(linkEditTargets, linkEditing.handleLinkEdit)),
|
|
576
556
|
handleTimelineElementSplit: trackedRazorSplit,
|
|
577
557
|
handleRazorSplit: trackedRazorSplit,
|
|
558
|
+
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
|
|
578
559
|
// Same selection the handler itself splits (useRazorSplit.ts).
|
|
579
560
|
handleRazorSplitAll: track(
|
|
580
561
|
guard(
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
export function useTimelineLinkEditing({
|
|
53
|
+
projectIdRef,
|
|
54
|
+
activeCompPath,
|
|
55
|
+
editQueueRef,
|
|
56
|
+
pendingTimelineEditPathRef,
|
|
57
|
+
isRecordingRef,
|
|
58
|
+
showToast,
|
|
59
|
+
writeProjectFile,
|
|
60
|
+
recordEdit,
|
|
61
|
+
reloadPreview,
|
|
62
|
+
forceReloadSdkSession,
|
|
63
|
+
handleTimelineElementsDelete,
|
|
64
|
+
}: UseTimelineLinkEditingOptions) {
|
|
65
|
+
const handleLinkEdit = useCallback(
|
|
66
|
+
async (edit: TimelineLinkEdit) => {
|
|
67
|
+
if (isRecordingRef?.current) {
|
|
68
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const pid = projectIdRef.current;
|
|
72
|
+
const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
|
|
73
|
+
if (!pid || !plan) return;
|
|
74
|
+
const path = plan.anchor.sourceFile || activeCompPath || "index.html";
|
|
75
|
+
pendingTimelineEditPathRef.current.add(path);
|
|
76
|
+
const queued = editQueueRef.current.then(() =>
|
|
77
|
+
saveProjectFilesWithHistory({
|
|
78
|
+
projectId: pid,
|
|
79
|
+
label: plan.label,
|
|
80
|
+
files: {
|
|
81
|
+
[path]: (current) => {
|
|
82
|
+
const next = plan.transform(current);
|
|
83
|
+
if (next === null || next === current) {
|
|
84
|
+
throw new Error(`Couldn't ${plan.label.toLowerCase()}`);
|
|
85
|
+
}
|
|
86
|
+
return next;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
readFile: (filePath) => readFileContent(pid, filePath),
|
|
90
|
+
writeFile: writeProjectFile,
|
|
91
|
+
recordEdit,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
editQueueRef.current = queued.then(
|
|
95
|
+
() => undefined,
|
|
96
|
+
() => undefined,
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
await queued;
|
|
100
|
+
forceReloadSdkSession?.();
|
|
101
|
+
reloadPreview();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
showToast(getStudioSaveErrorMessage(error), "error");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[
|
|
107
|
+
activeCompPath,
|
|
108
|
+
editQueueRef,
|
|
109
|
+
forceReloadSdkSession,
|
|
110
|
+
isRecordingRef,
|
|
111
|
+
pendingTimelineEditPathRef,
|
|
112
|
+
projectIdRef,
|
|
113
|
+
recordEdit,
|
|
114
|
+
reloadPreview,
|
|
115
|
+
showToast,
|
|
116
|
+
writeProjectFile,
|
|
117
|
+
],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const handleLinkedElementsDelete = useCallback(
|
|
121
|
+
(selection: TimelineElement[]) => {
|
|
122
|
+
if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection));
|
|
123
|
+
const removed = new Set(selection.map(keyOf));
|
|
124
|
+
const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter(
|
|
125
|
+
(el) => !removed.has(keyOf(el)),
|
|
126
|
+
);
|
|
127
|
+
return handleTimelineElementsDelete(selection, orphans);
|
|
128
|
+
},
|
|
129
|
+
[handleTimelineElementsDelete],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handleLinkedElementDelete = useCallback(
|
|
133
|
+
(element: TimelineElement) => handleLinkedElementsDelete([element]),
|
|
134
|
+
[handleLinkedElementsDelete],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const handleDeleteElementOnly = useCallback(
|
|
138
|
+
(element: TimelineElement) => {
|
|
139
|
+
const orphans = clipsToUnlink([element], usePlayerStore.getState().elements).filter(
|
|
140
|
+
(el) => keyOf(el) !== keyOf(element),
|
|
141
|
+
);
|
|
142
|
+
return handleTimelineElementsDelete([element], orphans);
|
|
143
|
+
},
|
|
144
|
+
[handleTimelineElementsDelete],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
handleLinkEdit,
|
|
149
|
+
handleLinkedElementsDelete,
|
|
150
|
+
handleLinkedElementDelete,
|
|
151
|
+
handleDeleteElementOnly,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export {
|
|
|
31
31
|
liveTime,
|
|
32
32
|
formatTime,
|
|
33
33
|
} from "./player";
|
|
34
|
+
export { usePreviewIframeStore } from "./player/store/previewIframeStore";
|
|
34
35
|
export type {
|
|
35
36
|
PlayerHandle,
|
|
36
37
|
PlayerHandleElement,
|
|
@@ -175,6 +176,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
|
|
|
175
176
|
export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
|
|
176
177
|
export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
|
|
177
178
|
export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
179
|
+
export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
|
|
178
180
|
export { useClipboard } from "./hooks/useClipboard";
|
|
179
181
|
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
180
182
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Share of the clip height the sound strip takes under a video's thumbnails. */
|
|
4
|
+
const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
|
|
5
|
+
|
|
6
|
+
/** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
|
|
7
|
+
export function AudibleVideoClipContent({
|
|
8
|
+
thumbnail,
|
|
9
|
+
waveform,
|
|
10
|
+
}: {
|
|
11
|
+
thumbnail: ReactNode;
|
|
12
|
+
waveform: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`;
|
|
15
|
+
return (
|
|
16
|
+
<div className="relative h-full w-full" data-testid="audible-video-clip">
|
|
17
|
+
<div className="absolute inset-x-0 top-0" style={{ bottom: waveHeight }}>
|
|
18
|
+
{thumbnail}
|
|
19
|
+
</div>
|
|
20
|
+
<div
|
|
21
|
+
className="absolute inset-x-0 bottom-0 bg-black/30"
|
|
22
|
+
data-testid="audible-video-wave"
|
|
23
|
+
style={{ height: waveHeight }}
|
|
24
|
+
>
|
|
25
|
+
{waveform}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -96,7 +96,7 @@ describe("AudioWaveform", () => {
|
|
|
96
96
|
act(() => root.unmount());
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it("greys the clip in place when muted
|
|
99
|
+
it("greys the clip in place when muted", () => {
|
|
100
100
|
const host = document.createElement("div");
|
|
101
101
|
host.className = "timeline-clip is-audio";
|
|
102
102
|
document.body.append(host);
|
|
@@ -112,15 +112,42 @@ describe("AudioWaveform", () => {
|
|
|
112
112
|
sessionEpoch={1}
|
|
113
113
|
priority="visible"
|
|
114
114
|
muted
|
|
115
|
-
linked
|
|
116
115
|
/>,
|
|
117
116
|
);
|
|
118
117
|
});
|
|
119
118
|
|
|
120
119
|
expect(host.getAttribute("data-audio-muted")).toBe("true");
|
|
121
|
-
expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
|
|
122
120
|
|
|
123
121
|
act(() => root.unmount());
|
|
124
122
|
expect(host.hasAttribute("data-audio-muted")).toBe(false);
|
|
125
123
|
});
|
|
124
|
+
|
|
125
|
+
it("fills a short sound strip when the label band is dropped", () => {
|
|
126
|
+
const heights = [16, 0].map((labelInset) => {
|
|
127
|
+
const host = document.createElement("div");
|
|
128
|
+
document.body.append(host);
|
|
129
|
+
const root = createRoot(host);
|
|
130
|
+
act(() => {
|
|
131
|
+
root.render(
|
|
132
|
+
<AudioWaveform
|
|
133
|
+
audioUrl="/media/talk.mp4"
|
|
134
|
+
label=""
|
|
135
|
+
labelColor="#fff"
|
|
136
|
+
projectId="project-a"
|
|
137
|
+
sessionEpoch={1}
|
|
138
|
+
priority="visible"
|
|
139
|
+
{...(labelInset === 16 ? {} : { labelInset })}
|
|
140
|
+
/>,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
const canvas = host.querySelector("canvas");
|
|
144
|
+
const box = { top: canvas?.style.top, height: canvas?.style.height };
|
|
145
|
+
act(() => root.unmount());
|
|
146
|
+
return box;
|
|
147
|
+
});
|
|
148
|
+
expect(heights).toEqual([
|
|
149
|
+
{ top: "16px", height: "calc(100% - 16px)" },
|
|
150
|
+
{ top: "0px", height: "calc(100% - 0px)" },
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
126
153
|
});
|
|
@@ -18,8 +18,7 @@ export interface AudioWaveformProps {
|
|
|
18
18
|
priority: ThumbnailPriority;
|
|
19
19
|
/** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
|
|
20
20
|
muted?: boolean;
|
|
21
|
-
|
|
22
|
-
linked?: boolean;
|
|
21
|
+
labelInset?: number;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
const BAR_STEP = 3;
|
|
@@ -167,7 +166,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
167
166
|
sessionEpoch,
|
|
168
167
|
priority,
|
|
169
168
|
muted = false,
|
|
170
|
-
|
|
169
|
+
labelInset = 16,
|
|
171
170
|
}: AudioWaveformProps) {
|
|
172
171
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
173
172
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
@@ -236,17 +235,17 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
236
235
|
|
|
237
236
|
return (
|
|
238
237
|
<div ref={rootRef} className="absolute inset-0">
|
|
239
|
-
{linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
|
|
240
238
|
<div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
|
|
241
239
|
<canvas
|
|
242
240
|
ref={setCanvasRef}
|
|
243
241
|
className="absolute inset-x-0 bottom-0 w-full"
|
|
244
|
-
style={{ top:
|
|
242
|
+
style={{ top: labelInset, height: `calc(100% - ${labelInset}px)` }}
|
|
245
243
|
/>
|
|
246
244
|
{snapshot.status === "loading" && (
|
|
247
245
|
<div
|
|
248
|
-
className="absolute inset-x-0 bottom-0
|
|
246
|
+
className="absolute inset-x-0 bottom-0 animate-pulse"
|
|
249
247
|
style={{
|
|
248
|
+
top: labelInset,
|
|
250
249
|
background: "var(--timeline-thumbnail-shimmer)",
|
|
251
250
|
}}
|
|
252
251
|
/>
|
|
@@ -256,7 +255,7 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
256
255
|
{snapshot.status === "error" && (
|
|
257
256
|
<div
|
|
258
257
|
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
|
|
259
|
-
style={{ top:
|
|
258
|
+
style={{ top: labelInset, bottom: 0 }}
|
|
260
259
|
>
|
|
261
260
|
<div
|
|
262
261
|
className="absolute inset-x-0"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
|
+
import { ClipBadges } from "./ClipBadges";
|
|
9
|
+
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
act(() => root?.unmount());
|
|
19
|
+
root = null;
|
|
20
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
21
|
+
document.body.innerHTML = "";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const talk: TimelineElement = {
|
|
25
|
+
id: "talk",
|
|
26
|
+
domId: "talk",
|
|
27
|
+
tag: "video",
|
|
28
|
+
start: 0,
|
|
29
|
+
duration: 6,
|
|
30
|
+
track: 0,
|
|
31
|
+
hasAudio: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function render(el: TimelineElement) {
|
|
35
|
+
const host = document.createElement("div");
|
|
36
|
+
document.body.append(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
act(() => root?.render(<ClipBadges el={el} />));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const labels = () =>
|
|
42
|
+
Array.from(document.querySelectorAll("[data-badge]")).map(
|
|
43
|
+
(badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function mountPreview(html: string): Document {
|
|
47
|
+
const iframe = document.createElement("iframe");
|
|
48
|
+
document.body.append(iframe);
|
|
49
|
+
const doc = iframe.contentDocument;
|
|
50
|
+
if (!doc) throw new Error("no iframe document");
|
|
51
|
+
doc.body.innerHTML = html;
|
|
52
|
+
usePreviewIframeStore.getState().setIframe(iframe);
|
|
53
|
+
return doc;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("ClipBadges", () => {
|
|
57
|
+
it("renders nothing for an untouched clip", () => {
|
|
58
|
+
render(talk);
|
|
59
|
+
expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to the store's volume without a preview", () => {
|
|
63
|
+
render({ ...talk, volume: 1.8 });
|
|
64
|
+
expect(labels()).toEqual(["volume:180%"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads the live node and follows an agent's attribute edit", async () => {
|
|
68
|
+
const doc = mountPreview(
|
|
69
|
+
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
|
|
70
|
+
);
|
|
71
|
+
render(talk);
|
|
72
|
+
expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
|
|
73
|
+
const node = doc.getElementById("talk");
|
|
74
|
+
await act(async () => {
|
|
75
|
+
node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
|
|
76
|
+
node?.setAttribute("data-volume", "0.6");
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
});
|
|
79
|
+
expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
|
|
80
|
+
expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
|
|
81
|
+
});
|
|
82
|
+
});
|