@hyperframes/studio 0.8.108 → 0.8.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
4
4
|
import { canSplitElement } from "../../utils/timelineElementSplit";
|
|
@@ -7,6 +7,25 @@ import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
|
7
7
|
import type { TimelineClipMenuItem } from "./TimelineTypes";
|
|
8
8
|
import { ClipMenuToolItems } from "./clipMenuToolItems";
|
|
9
9
|
import { ClipMenuAudioItems } from "./clipMenuAudioItems";
|
|
10
|
+
import { ClipMenuLinkItems } from "./clipMenuLinkItems";
|
|
11
|
+
|
|
12
|
+
const MENU_MARGIN = 8;
|
|
13
|
+
// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
|
|
14
|
+
const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
|
|
15
|
+
|
|
16
|
+
function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
|
|
17
|
+
const [height, setHeight] = useState(0);
|
|
18
|
+
useLayoutEffect(() => {
|
|
19
|
+
const node = ref.current;
|
|
20
|
+
if (!node) return;
|
|
21
|
+
setHeight(node.offsetHeight);
|
|
22
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
23
|
+
const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
|
|
24
|
+
observer.observe(node);
|
|
25
|
+
return () => observer.disconnect();
|
|
26
|
+
}, [ref, anchorKey]);
|
|
27
|
+
return height;
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
interface ClipContextMenuProps {
|
|
12
31
|
x: number;
|
|
@@ -62,7 +81,6 @@ function HostItems({
|
|
|
62
81
|
)}
|
|
63
82
|
</button>
|
|
64
83
|
))}
|
|
65
|
-
<div className="my-1 border-t border-neutral-700/60" />
|
|
66
84
|
</>
|
|
67
85
|
);
|
|
68
86
|
}
|
|
@@ -105,16 +123,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
105
123
|
? `Split at ${currentTime.toFixed(2)}s`
|
|
106
124
|
: "Split (move playhead inside clip)";
|
|
107
125
|
|
|
108
|
-
const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
|
|
109
|
-
const hostRows = hostItems.length;
|
|
110
|
-
const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
|
|
111
|
-
const dividerCount =
|
|
112
|
-
(hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
|
|
113
126
|
const menuWidth = 200;
|
|
114
|
-
const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
|
|
115
|
-
const overflowY = y + menuHeight - window.innerHeight;
|
|
116
127
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
117
|
-
const
|
|
128
|
+
const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`);
|
|
129
|
+
const overflowY = y + menuHeight - window.innerHeight;
|
|
130
|
+
const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y;
|
|
118
131
|
|
|
119
132
|
return createPortal(
|
|
120
133
|
<div
|
|
@@ -124,9 +137,13 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
124
137
|
className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
125
138
|
style={{ left: adjustedX, top: adjustedY }}
|
|
126
139
|
>
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
140
|
+
{hostItems.length > 0 && (
|
|
141
|
+
<div role="group" aria-label="Host" className={GROUP_CLASS}>
|
|
142
|
+
<HostItems items={hostItems} onClose={onClose} />
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
<div role="group" aria-label="Time" className={GROUP_CLASS}>
|
|
146
|
+
{splitLabel && (
|
|
130
147
|
<button
|
|
131
148
|
type="button"
|
|
132
149
|
role="menuitem"
|
|
@@ -146,92 +163,100 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
146
163
|
<span>{splitLabel}</span>
|
|
147
164
|
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
|
148
165
|
</button>
|
|
166
|
+
)}
|
|
167
|
+
{splitLabel && (
|
|
149
168
|
<ClipMenuToolItems
|
|
150
169
|
group="time"
|
|
151
170
|
element={element}
|
|
152
171
|
currentTime={currentTime}
|
|
153
172
|
onClose={onClose}
|
|
154
173
|
/>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)}
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
158
176
|
|
|
159
|
-
<
|
|
177
|
+
<div role="group" aria-label="Sound" className={GROUP_CLASS}>
|
|
178
|
+
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
|
|
179
|
+
<ClipMenuToolItems
|
|
180
|
+
group="sound"
|
|
181
|
+
element={element}
|
|
182
|
+
currentTime={currentTime}
|
|
183
|
+
onClose={onClose}
|
|
184
|
+
/>
|
|
185
|
+
<ClipMenuLinkItems part="link" element={element} onClose={onClose} />
|
|
186
|
+
<ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
|
|
187
|
+
</div>
|
|
160
188
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
189
|
+
<div role="group" aria-label="Picture" className={GROUP_CLASS}>
|
|
190
|
+
<ClipMenuToolItems
|
|
191
|
+
group="picture"
|
|
192
|
+
element={element}
|
|
193
|
+
currentTime={currentTime}
|
|
194
|
+
onClose={onClose}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
|
|
199
|
+
{onCopy && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
role="menuitem"
|
|
203
|
+
className={itemClass(true)}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
onCopy();
|
|
206
|
+
onClose();
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
|
|
210
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
|
|
211
|
+
</button>
|
|
212
|
+
)}
|
|
213
|
+
{onPaste && (
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
role="menuitem"
|
|
217
|
+
className={itemClass(!!canPaste)}
|
|
218
|
+
disabled={!canPaste}
|
|
219
|
+
onClick={() => {
|
|
220
|
+
if (!canPaste) return;
|
|
221
|
+
void onPaste();
|
|
222
|
+
onClose();
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<span>Paste</span>
|
|
226
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
{onDuplicate && (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
role="menuitem"
|
|
233
|
+
className={itemClass(true)}
|
|
234
|
+
onClick={() => {
|
|
235
|
+
void onDuplicate();
|
|
236
|
+
onClose();
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
|
|
240
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
210
244
|
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
227
|
-
onClick={() => {
|
|
228
|
-
onDelete(element);
|
|
229
|
-
onClose();
|
|
230
|
-
}}
|
|
231
|
-
>
|
|
232
|
-
<span>Delete</span>
|
|
233
|
-
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
234
|
-
</button>
|
|
245
|
+
<div role="group" aria-label="Delete">
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
role="menuitem"
|
|
249
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
|
|
250
|
+
onClick={() => {
|
|
251
|
+
onDelete(element);
|
|
252
|
+
onClose();
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<span>Delete</span>
|
|
256
|
+
<span className="text-neutral-500 text-[10px] ml-3">⌫</span>
|
|
257
|
+
</button>
|
|
258
|
+
<ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
|
|
259
|
+
</div>
|
|
235
260
|
</div>,
|
|
236
261
|
document.body,
|
|
237
262
|
);
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
-
import { 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
|
-
}
|
|
2
|
+
import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
3
|
+
import { loadPeakMap } from "./clipPeakMap";
|
|
16
4
|
|
|
17
5
|
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
|
|
18
6
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
5
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
8
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
const harness = createHappyDomRootHarness();
|
|
13
|
+
const clip = (id: string, tag: string, start: number): TimelineElement => ({
|
|
14
|
+
id,
|
|
15
|
+
domId: id,
|
|
16
|
+
tag,
|
|
17
|
+
start,
|
|
18
|
+
duration: 4,
|
|
19
|
+
track: 0,
|
|
20
|
+
playbackStart: 0,
|
|
21
|
+
syncOrigin: "lk-1",
|
|
22
|
+
});
|
|
23
|
+
const video = clip("talk", "video", 1);
|
|
24
|
+
const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
|
|
25
|
+
|
|
26
|
+
afterEach(() =>
|
|
27
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function render(el: TimelineElement, onLinkEdit = vi.fn()) {
|
|
31
|
+
usePlayerStore.getState().setElements([video, lateAudio]);
|
|
32
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
33
|
+
act(() =>
|
|
34
|
+
harness.mount(host).render(
|
|
35
|
+
<TimelineEditProvider value={{ onLinkEdit }}>
|
|
36
|
+
<OutOfSyncBadge el={el} />
|
|
37
|
+
</TimelineEditProvider>,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
|
|
41
|
+
return { badge, onLinkEdit };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("OutOfSyncBadge", () => {
|
|
45
|
+
it("shows the signed offset on both halves", () => {
|
|
46
|
+
useLinkedClipPreferences.setState({ compositionFps: 30 });
|
|
47
|
+
expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
|
|
48
|
+
document.body.innerHTML = "";
|
|
49
|
+
expect(render(video).badge()?.textContent).toBe("-1:21");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("hides when the preference is off", () => {
|
|
53
|
+
useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
|
|
54
|
+
expect(render(lateAudio).badge()).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
|
|
58
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
59
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
60
|
+
const items = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
|
|
61
|
+
expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
|
|
62
|
+
act(() => items[0]?.click());
|
|
63
|
+
expect(onLinkEdit).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("slips from a right-click, and the click never reaches the clip", () => {
|
|
69
|
+
const clipClick = vi.fn();
|
|
70
|
+
document.body.addEventListener("contextmenu", clipClick);
|
|
71
|
+
const { badge, onLinkEdit } = render(lateAudio);
|
|
72
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
|
|
73
|
+
expect(clipClick).not.toHaveBeenCalled();
|
|
74
|
+
const slip = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
|
|
75
|
+
act(() => slip?.click());
|
|
76
|
+
const edit = onLinkEdit.mock.calls[0]?.[0];
|
|
77
|
+
expect(edit.kind).toBe("slip-into-sync");
|
|
78
|
+
expect(edit.mediaStart).toBeCloseTo(51 / 30);
|
|
79
|
+
document.body.removeEventListener("contextmenu", clipClick);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("flips the menu above the pointer near the bottom edge", () => {
|
|
83
|
+
const { badge } = render(lateAudio);
|
|
84
|
+
const y = window.innerHeight - 10;
|
|
85
|
+
act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
|
|
86
|
+
const menu = document.querySelector<HTMLElement>('[role="menu"]');
|
|
87
|
+
const top = Number.parseFloat(menu?.style.top ?? "");
|
|
88
|
+
expect(top).toBeLessThan(y);
|
|
89
|
+
expect(top).toBeGreaterThanOrEqual(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { memo, useCallback, useState, type SyntheticEvent } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { formatSyncOffset } from "@hyperframes/core/media-link";
|
|
4
|
+
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
5
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
6
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
7
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
|
+
import { clipSyncState, type ClipSyncState } from "./clipSync";
|
|
9
|
+
import { useMenuKeyboardNav } from "./menuKeyboardNav";
|
|
10
|
+
|
|
11
|
+
const stop = (e: SyntheticEvent) => e.stopPropagation();
|
|
12
|
+
|
|
13
|
+
const MENU_ROW =
|
|
14
|
+
"block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
|
|
15
|
+
|
|
16
|
+
function SyncMenu({
|
|
17
|
+
x,
|
|
18
|
+
y,
|
|
19
|
+
el,
|
|
20
|
+
sync,
|
|
21
|
+
onClose,
|
|
22
|
+
}: {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
el: TimelineElement;
|
|
26
|
+
sync: ClipSyncState<TimelineElement>;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
const menuRef = useContextMenuDismiss(onClose);
|
|
30
|
+
useMenuKeyboardNav(menuRef);
|
|
31
|
+
const { onLinkEdit } = useTimelineEditContextOptional();
|
|
32
|
+
const { moveStart, slipMediaStart } = sync;
|
|
33
|
+
const menuWidth = 170;
|
|
34
|
+
const menuHeight = 66;
|
|
35
|
+
const left = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
36
|
+
const top = y + menuHeight > window.innerHeight ? Math.max(0, y - menuHeight) : y;
|
|
37
|
+
return createPortal(
|
|
38
|
+
<div
|
|
39
|
+
ref={menuRef}
|
|
40
|
+
role="menu"
|
|
41
|
+
aria-label="Out of sync"
|
|
42
|
+
className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
|
|
43
|
+
style={{ left, top }}
|
|
44
|
+
>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
role="menuitem"
|
|
48
|
+
className={MENU_ROW}
|
|
49
|
+
disabled={!onLinkEdit || moveStart === null}
|
|
50
|
+
title={moveStart === null ? "Would move the clip before 0:00" : undefined}
|
|
51
|
+
onClick={() => {
|
|
52
|
+
if (moveStart !== null)
|
|
53
|
+
void onLinkEdit?.({ kind: "move-into-sync", element: el, start: moveStart });
|
|
54
|
+
onClose();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
Move into Sync
|
|
58
|
+
</button>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="menuitem"
|
|
62
|
+
className={MENU_ROW}
|
|
63
|
+
disabled={!onLinkEdit || slipMediaStart === null}
|
|
64
|
+
title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
if (slipMediaStart !== null) {
|
|
67
|
+
void onLinkEdit?.({ kind: "slip-into-sync", element: el, mediaStart: slipMediaStart });
|
|
68
|
+
}
|
|
69
|
+
onClose();
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
Slip into Sync
|
|
73
|
+
</button>
|
|
74
|
+
</div>,
|
|
75
|
+
document.body,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Premiere's red out-of-sync number, at the start of each half of a source pair
|
|
81
|
+
* that drifted. The whole badge opens Move / Slip into Sync (Premiere's own hit
|
|
82
|
+
* target is a sliver at the badge edge, a known complaint).
|
|
83
|
+
*/
|
|
84
|
+
export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: TimelineElement }) {
|
|
85
|
+
const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
|
|
86
|
+
const fps = useLinkedClipPreferences((s) => s.compositionFps);
|
|
87
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
88
|
+
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
|
89
|
+
const closeMenu = useCallback(() => setMenuAt(null), []);
|
|
90
|
+
const sync = visible ? clipSyncState(el, elements, fps) : null;
|
|
91
|
+
if (!sync) return null;
|
|
92
|
+
const label = formatSyncOffset(sync.frames, fps);
|
|
93
|
+
const partnerName = sync.partner.label || sync.partner.id;
|
|
94
|
+
const open = (e: React.MouseEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
setMenuAt({ x: e.clientX, y: e.clientY });
|
|
98
|
+
};
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<span
|
|
102
|
+
role="button"
|
|
103
|
+
tabIndex={-1}
|
|
104
|
+
aria-haspopup="menu"
|
|
105
|
+
aria-label={`Out of sync with ${partnerName} by ${label} frames`}
|
|
106
|
+
title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
|
|
107
|
+
data-testid="out-of-sync-badge"
|
|
108
|
+
className="absolute bottom-0.5 left-1 z-[7] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
|
|
109
|
+
onPointerDown={stop}
|
|
110
|
+
onDoubleClick={stop}
|
|
111
|
+
onClick={open}
|
|
112
|
+
onContextMenu={open}
|
|
113
|
+
>
|
|
114
|
+
{label}
|
|
115
|
+
</span>
|
|
116
|
+
{menuAt && <SyncMenu x={menuAt.x} y={menuAt.y} el={el} sync={sync} onClose={closeMenu} />}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -70,6 +70,21 @@ describe("timeline viewport geometry", () => {
|
|
|
70
70
|
const scrollTop = previous.getRowTop(2) - RULER_H + 6;
|
|
71
71
|
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("keeps a row added above the top row in view while the list sits at the top", () => {
|
|
75
|
+
// No top padding (trackPadding { top: 0 }), so the first row sits right under the ruler.
|
|
76
|
+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48], { top: 0 });
|
|
77
|
+
const next = createTimelineRowGeometry([9, 1, 2, 3], [48, 48, 48, 48], { top: 0 });
|
|
78
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, 0)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves scrollTop to edge auto-scroll while a clip drag adds a row above", () => {
|
|
82
|
+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]);
|
|
83
|
+
const next = createTimelineRowGeometry([9, 1, 2, 3], [48, 48, 48, 48]);
|
|
84
|
+
const scrollTop = previous.getRowTop(1) - RULER_H + 6;
|
|
85
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop, true)).toBe(scrollTop);
|
|
86
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 48);
|
|
87
|
+
});
|
|
73
88
|
});
|
|
74
89
|
|
|
75
90
|
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
|
@@ -105,6 +105,23 @@ describe("TimelineClip", () => {
|
|
|
105
105
|
expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("gives both halves of a link group the same label colour, and unlinked clips none", () => {
|
|
109
|
+
const colorOf = (element: TimelineElement) => {
|
|
110
|
+
const { host, root } = renderClip({ element });
|
|
111
|
+
const clip = host.querySelector<HTMLElement>(".timeline-clip");
|
|
112
|
+
const color = clip?.getAttribute("data-link-color") ?? null;
|
|
113
|
+
expect(clip?.style.getPropertyValue("--clip-link-color") || null).toBe(color);
|
|
114
|
+
act(() => root.unmount());
|
|
115
|
+
return color;
|
|
116
|
+
};
|
|
117
|
+
const base = { start: 0, duration: 2, track: 0 };
|
|
118
|
+
const video = colorOf({ id: "v", tag: "video", link: "lk-3", ...base });
|
|
119
|
+
expect(video).not.toBeNull();
|
|
120
|
+
expect(colorOf({ id: "a", tag: "audio", link: "lk-3", ...base })).toBe(video);
|
|
121
|
+
expect(colorOf({ id: "b", tag: "audio", link: "lk-4", ...base })).not.toBe(video);
|
|
122
|
+
expect(colorOf({ id: "c", tag: "audio", ...base })).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
108
125
|
it("renders the clip label above custom content without showing default timecode", () => {
|
|
109
126
|
const { host, root } = renderClip({
|
|
110
127
|
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
|
|
@@ -116,6 +133,47 @@ describe("TimelineClip", () => {
|
|
|
116
133
|
act(() => root.unmount());
|
|
117
134
|
});
|
|
118
135
|
|
|
136
|
+
it("names a speed-changed clip like Premiere: [150%] for a constant rate, [ramp] for a lane", () => {
|
|
137
|
+
const fast = renderClip({
|
|
138
|
+
element: {
|
|
139
|
+
id: "a",
|
|
140
|
+
label: "Hero",
|
|
141
|
+
tag: "video",
|
|
142
|
+
start: 0,
|
|
143
|
+
duration: 2,
|
|
144
|
+
track: 0,
|
|
145
|
+
playbackRate: 1.5,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
expect(fast.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [150%]");
|
|
149
|
+
act(() => fast.root.unmount());
|
|
150
|
+
const automation = JSON.stringify({
|
|
151
|
+
version: 1,
|
|
152
|
+
lanes: [
|
|
153
|
+
{
|
|
154
|
+
target: "rate",
|
|
155
|
+
points: [
|
|
156
|
+
{ t: 0, v: 0.5 },
|
|
157
|
+
{ t: 1, v: 1 },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
const ramp = renderClip({
|
|
163
|
+
element: {
|
|
164
|
+
id: "b",
|
|
165
|
+
label: "Hero",
|
|
166
|
+
tag: "video",
|
|
167
|
+
start: 0,
|
|
168
|
+
duration: 2,
|
|
169
|
+
track: 0,
|
|
170
|
+
automation,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(ramp.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [ramp]");
|
|
174
|
+
act(() => ramp.root.unmount());
|
|
175
|
+
});
|
|
176
|
+
|
|
119
177
|
it("drops the label chip under 60px even when the clip is selected", () => {
|
|
120
178
|
const { host, root } = renderClip({
|
|
121
179
|
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
|
|
@@ -12,6 +12,9 @@ import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
|
12
12
|
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
|
|
13
13
|
import { rendersWaveform } from "./AudioWaveform";
|
|
14
14
|
import { ClipBadges } from "./ClipBadges";
|
|
15
|
+
import { linkLabelColor } from "./linkLabelColor";
|
|
16
|
+
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
17
|
+
import { clipSpeedSuffix } from "./clipToolAttrs";
|
|
15
18
|
|
|
16
19
|
interface TimelineClipProps {
|
|
17
20
|
el: TimelineElement;
|
|
@@ -66,7 +69,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
66
69
|
const leftPx = el.start * pps;
|
|
67
70
|
const widthPx = Math.max(el.duration * pps, 4);
|
|
68
71
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
69
|
-
const displayLabel = el.label || el.id || el.tag
|
|
72
|
+
const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`;
|
|
70
73
|
const ladder = clipWidthLadder(widthPx);
|
|
71
74
|
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
|
|
72
75
|
const showLabel = ladder === "labeled";
|
|
@@ -83,6 +86,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
83
86
|
"--clip-border-active": theme.clipBorderActive,
|
|
84
87
|
"--clip-handle": theme.handleColor,
|
|
85
88
|
} as CSSProperties;
|
|
89
|
+
const linkColor = linkLabelColor(el.link);
|
|
90
|
+
if (linkColor) Object.assign(themeVariables, { "--clip-link-color": linkColor });
|
|
86
91
|
const isAudioClip = isAudioTimelineElement(el);
|
|
87
92
|
const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
|
|
88
93
|
const fade = useClipFadeDraft(el);
|
|
@@ -124,6 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
124
129
|
data-clip-start={el.start}
|
|
125
130
|
data-clip-end={el.start + el.duration}
|
|
126
131
|
data-clip-hidden={el.hidden ? "true" : undefined}
|
|
132
|
+
data-link-color={linkColor ?? undefined}
|
|
127
133
|
data-ladder={ladder}
|
|
128
134
|
data-active={isActive ? "" : undefined}
|
|
129
135
|
aria-hidden={isGestureActor ? "true" : undefined}
|
|
@@ -205,7 +211,8 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
205
211
|
</div>
|
|
206
212
|
)}
|
|
207
213
|
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
208
|
-
{showLabel && !isGestureActor && <ClipBadges el={el} />}
|
|
214
|
+
{showLabel && !isGestureActor && <ClipBadges el={el} onOpenMenu={onContextMenu} />}
|
|
215
|
+
{!isGestureActor && el.syncOrigin && <OutOfSyncBadge el={el} />}
|
|
209
216
|
{showDefaultText && (
|
|
210
217
|
<span className="timeline-clip__timecode">
|
|
211
218
|
{startLabel}-{endLabel}s
|