@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,226 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
4
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
7
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
8
|
+
import { measureClipSourcePeak } from "./clipPeakMap";
|
|
9
|
+
import { AUDIO_GAIN_LABEL, normalizeClipsLoudness, writeClipGains } from "./audioGainApply";
|
|
10
|
+
import { useAudioGainDialogStore } from "./audioGainDialogStore";
|
|
11
|
+
import {
|
|
12
|
+
peakAmplitudeDb,
|
|
13
|
+
planAudioGain,
|
|
14
|
+
type AudioGainClip,
|
|
15
|
+
type AudioGainMode,
|
|
16
|
+
} from "./audioGainPlan";
|
|
17
|
+
|
|
18
|
+
type DialogMode = AudioGainMode | "loudness";
|
|
19
|
+
|
|
20
|
+
const PEAK_MODES: ReadonlySet<DialogMode> = new Set(["normalize-max", "normalize-all"]);
|
|
21
|
+
|
|
22
|
+
const ROWS: ReadonlyArray<{ mode: AudioGainMode; label: string }> = [
|
|
23
|
+
{ mode: "set", label: "Set Gain to" },
|
|
24
|
+
{ mode: "adjust", label: "Adjust Gain by" },
|
|
25
|
+
{ mode: "normalize-max", label: "Normalize Max Peak to" },
|
|
26
|
+
{ mode: "normalize-all", label: "Normalize All Peaks to" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
30
|
+
const currentGainDb = (el: TimelineElement | undefined) => {
|
|
31
|
+
const gain = el?.volume ?? 1;
|
|
32
|
+
return gain > 0 ? Math.round(audioGainToDb(gain) * 10) / 10 : -60;
|
|
33
|
+
};
|
|
34
|
+
const dbText = (db: number) => `${db < 0 ? "−" : ""}${Math.abs(db).toFixed(1)} dB`;
|
|
35
|
+
|
|
36
|
+
function useSourcePeaks(elements: readonly TimelineElement[], projectId: string | null) {
|
|
37
|
+
const [peaks, setPeaks] = useState<Map<string, number | null> | null>(null);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!projectId) return;
|
|
40
|
+
let live = true;
|
|
41
|
+
void Promise.all(elements.map((el) => measureClipSourcePeak(el, projectId))).then((found) => {
|
|
42
|
+
if (live) setPeaks(new Map(elements.map((el, i) => [keyOf(el), found[i] ?? null])));
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
live = false;
|
|
46
|
+
};
|
|
47
|
+
}, [elements, projectId]);
|
|
48
|
+
return peaks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Row({
|
|
52
|
+
checked,
|
|
53
|
+
disabled,
|
|
54
|
+
label,
|
|
55
|
+
onSelect,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
checked: boolean;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
label: string;
|
|
61
|
+
onSelect: () => void;
|
|
62
|
+
children?: ReactNode;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<label
|
|
66
|
+
className={`flex items-center justify-between gap-3 py-1 ${disabled ? "opacity-40" : ""}`}
|
|
67
|
+
>
|
|
68
|
+
<span className="flex items-center gap-2">
|
|
69
|
+
<input
|
|
70
|
+
type="radio"
|
|
71
|
+
name="audio-gain-mode"
|
|
72
|
+
checked={checked}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
onChange={onSelect}
|
|
75
|
+
/>
|
|
76
|
+
{label}
|
|
77
|
+
</span>
|
|
78
|
+
{children}
|
|
79
|
+
</label>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Premiere's Audio Gain dialog for the clips the store names. */
|
|
84
|
+
export function AudioGainDialog({
|
|
85
|
+
elements,
|
|
86
|
+
onClose,
|
|
87
|
+
}: {
|
|
88
|
+
elements: readonly TimelineElement[];
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
}) {
|
|
91
|
+
const shell = useStudioShellContextOptional();
|
|
92
|
+
const edit = useTimelineEditContextOptional();
|
|
93
|
+
const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
|
|
94
|
+
const projectId = shell?.projectId ?? sessionProjectId;
|
|
95
|
+
const showToast = shell?.showToast ?? edit.onNotice;
|
|
96
|
+
const peaks = useSourcePeaks(elements, projectId);
|
|
97
|
+
const [mode, setMode] = useState<DialogMode>("adjust");
|
|
98
|
+
const [values, setValues] = useState<Record<AudioGainMode, number>>(() => ({
|
|
99
|
+
set: currentGainDb(elements[0]),
|
|
100
|
+
adjust: 0,
|
|
101
|
+
"normalize-max": 0,
|
|
102
|
+
"normalize-all": 0,
|
|
103
|
+
}));
|
|
104
|
+
const clips: AudioGainClip[] = elements.map((el) => ({
|
|
105
|
+
key: keyOf(el),
|
|
106
|
+
gain: el.volume ?? 1,
|
|
107
|
+
sourcePeak: peaks?.get(keyOf(el)) ?? null,
|
|
108
|
+
}));
|
|
109
|
+
const peakDb = peakAmplitudeDb(clips);
|
|
110
|
+
const outOfRange = PEAK_MODES.has(mode) && mode !== "loudness" && values[mode] > 0;
|
|
111
|
+
const canApply = !outOfRange && !(PEAK_MODES.has(mode) && peakDb === null);
|
|
112
|
+
|
|
113
|
+
const apply = async () => {
|
|
114
|
+
if (!canApply) return;
|
|
115
|
+
onClose();
|
|
116
|
+
try {
|
|
117
|
+
if (mode === "loudness") {
|
|
118
|
+
if (!projectId) return;
|
|
119
|
+
showToast?.(await normalizeClipsLoudness(projectId, elements, edit), "info");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const byKey = new Map(elements.map((el) => [keyOf(el), el]));
|
|
123
|
+
const plan = planAudioGain(clips, { mode, db: values[mode] });
|
|
124
|
+
const edits = plan.flatMap(({ key, gain }) => {
|
|
125
|
+
const element = byKey.get(key);
|
|
126
|
+
return element ? [{ element, gain }] : [];
|
|
127
|
+
});
|
|
128
|
+
await writeClipGains(edits, edit, AUDIO_GAIN_LABEL);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
showToast?.(error instanceof Error ? error.message : String(error), "error");
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const field = (rowMode: AudioGainMode) => (
|
|
135
|
+
<span className="flex items-center gap-1">
|
|
136
|
+
<input
|
|
137
|
+
type="number"
|
|
138
|
+
step={0.1}
|
|
139
|
+
max={PEAK_MODES.has(rowMode) ? 0 : undefined}
|
|
140
|
+
aria-label={`${ROWS.find((row) => row.mode === rowMode)?.label ?? ""} dB`}
|
|
141
|
+
value={values[rowMode]}
|
|
142
|
+
disabled={mode !== rowMode}
|
|
143
|
+
className="w-16 rounded border border-neutral-700 bg-neutral-950 px-1 text-right disabled:opacity-50"
|
|
144
|
+
onChange={(event) =>
|
|
145
|
+
setValues((prev) => ({ ...prev, [rowMode]: Number(event.target.value) || 0 }))
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
dB
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return createPortal(
|
|
153
|
+
<div
|
|
154
|
+
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50"
|
|
155
|
+
onPointerDown={(event) => event.target === event.currentTarget && onClose()}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
role="dialog"
|
|
159
|
+
aria-modal="true"
|
|
160
|
+
aria-label={AUDIO_GAIN_LABEL}
|
|
161
|
+
className="w-[340px] rounded-md border border-neutral-700 bg-neutral-900 p-4 text-xs text-neutral-200 shadow-xl"
|
|
162
|
+
onKeyDown={(event) => {
|
|
163
|
+
event.stopPropagation();
|
|
164
|
+
if (event.key === "Escape") onClose();
|
|
165
|
+
if (event.key !== "Enter") return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
void apply();
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<div className="mb-3 text-sm font-medium">{AUDIO_GAIN_LABEL}</div>
|
|
171
|
+
{ROWS.map((row) => (
|
|
172
|
+
<Row
|
|
173
|
+
key={row.mode}
|
|
174
|
+
label={row.label}
|
|
175
|
+
checked={mode === row.mode}
|
|
176
|
+
disabled={PEAK_MODES.has(row.mode) && peaks !== null && peakDb === null}
|
|
177
|
+
onSelect={() => setMode(row.mode)}
|
|
178
|
+
>
|
|
179
|
+
{field(row.mode)}
|
|
180
|
+
</Row>
|
|
181
|
+
))}
|
|
182
|
+
<Row
|
|
183
|
+
label="Normalize loudness to −16 LUFS"
|
|
184
|
+
checked={mode === "loudness"}
|
|
185
|
+
onSelect={() => setMode("loudness")}
|
|
186
|
+
/>
|
|
187
|
+
<div className="mt-3 text-neutral-400" data-testid="audio-gain-peak">
|
|
188
|
+
Peak Amplitude: {peaks === null ? "measuring…" : peakDb === null ? "—" : dbText(peakDb)}
|
|
189
|
+
</div>
|
|
190
|
+
{outOfRange && <div className="mt-1 text-red-400">Peak targets must be 0 dB or lower.</div>}
|
|
191
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="rounded px-3 py-1 text-neutral-300 hover:bg-neutral-800"
|
|
195
|
+
onClick={onClose}
|
|
196
|
+
>
|
|
197
|
+
Cancel
|
|
198
|
+
</button>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
disabled={!canApply}
|
|
202
|
+
autoFocus
|
|
203
|
+
className="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-40"
|
|
204
|
+
onClick={() => void apply()}
|
|
205
|
+
>
|
|
206
|
+
OK
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>,
|
|
211
|
+
document.body,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Mounted in the timeline's overlays so Studio and embedders that mount the Timeline both get it. */
|
|
216
|
+
export function TimelineAudioGainOverlay() {
|
|
217
|
+
const targetKeys = useAudioGainDialogStore((s) => s.targetKeys);
|
|
218
|
+
const close = useAudioGainDialogStore((s) => s.close);
|
|
219
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
220
|
+
const targets = useMemo(() => {
|
|
221
|
+
const wanted = new Set(targetKeys ?? []);
|
|
222
|
+
return elements.filter((el) => wanted.has(keyOf(el)));
|
|
223
|
+
}, [elements, targetKeys]);
|
|
224
|
+
if (!targetKeys || targets.length === 0) return null;
|
|
225
|
+
return <AudioGainDialog key={targetKeys.join("\n")} elements={targets} onClose={close} />;
|
|
226
|
+
}
|
|
@@ -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"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
8
|
import { ClipBadges } from "./ClipBadges";
|
|
@@ -54,29 +54,53 @@ function mountPreview(html: string): Document {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
describe("ClipBadges", () => {
|
|
57
|
-
it("
|
|
57
|
+
it("shows a grey fx badge and no volume on an untouched clip", () => {
|
|
58
58
|
render(talk);
|
|
59
|
-
expect(
|
|
59
|
+
expect(labels()).toEqual(["fx:fx"]);
|
|
60
|
+
const fx = document.querySelector("[data-badge='fx']");
|
|
61
|
+
expect(fx?.getAttribute("data-fx-active")).toBe("false");
|
|
62
|
+
expect(fx?.getAttribute("title")).toBe("No effects");
|
|
60
63
|
});
|
|
61
64
|
|
|
62
65
|
it("falls back to the store's volume without a preview", () => {
|
|
63
66
|
render({ ...talk, volume: 1.8 });
|
|
64
|
-
expect(labels()).toEqual(["volume:180%"]);
|
|
67
|
+
expect(labels()).toEqual(["volume:180%", "fx:fx"]);
|
|
65
68
|
});
|
|
66
69
|
|
|
67
|
-
it("
|
|
70
|
+
it("lights the fx badge from the live node, lists the effects, and shows no link badge", async () => {
|
|
68
71
|
const doc = mountPreview(
|
|
69
72
|
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
|
|
70
73
|
);
|
|
71
74
|
render(talk);
|
|
72
|
-
|
|
75
|
+
const fx = () => document.querySelector("[data-badge='fx']");
|
|
76
|
+
expect(labels()).toEqual(["fx:fx"]);
|
|
77
|
+
expect(fx()?.getAttribute("data-fx-active")).toBe("true");
|
|
78
|
+
expect(fx()?.getAttribute("title")).toBe("Crop");
|
|
73
79
|
const node = doc.getElementById("talk");
|
|
74
80
|
await act(async () => {
|
|
75
81
|
node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
|
|
76
82
|
node?.setAttribute("data-volume", "0.6");
|
|
77
83
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
84
|
});
|
|
79
|
-
expect(labels()).toEqual(["
|
|
80
|
-
expect(
|
|
85
|
+
expect(labels()).toEqual(["volume:60%", "fx:fx"]);
|
|
86
|
+
expect(fx()?.getAttribute("title")).toBe("Look: Warm daylight · Crop");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("opens the clip menu from the fx badge without reaching the clip", () => {
|
|
90
|
+
const host = document.createElement("div");
|
|
91
|
+
document.body.append(host);
|
|
92
|
+
const onOpenMenu = vi.fn();
|
|
93
|
+
const onClipClick = vi.fn();
|
|
94
|
+
root = createRoot(host);
|
|
95
|
+
act(() =>
|
|
96
|
+
root?.render(
|
|
97
|
+
<div onClick={onClipClick}>
|
|
98
|
+
<ClipBadges el={talk} onOpenMenu={onOpenMenu} />
|
|
99
|
+
</div>,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
act(() => document.querySelector<HTMLElement>("[data-badge='fx']")?.click());
|
|
103
|
+
expect(onOpenMenu).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(onClipClick).not.toHaveBeenCalled();
|
|
81
105
|
});
|
|
82
106
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, type MouseEvent } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import {
|
|
3
|
+
import { clipVolumeBadge, readClipEffects } from "./clipToolAttrs";
|
|
4
4
|
import { useClipToolState } from "./useClipToolState";
|
|
5
5
|
|
|
6
6
|
function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
@@ -21,47 +21,53 @@ function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function BadgeContent({ badge }: { badge: ClipBadge }) {
|
|
25
|
-
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
|
|
26
|
-
if (badge.kind === "volume") {
|
|
27
|
-
const muted = badge.label === "Muted";
|
|
28
|
-
return (
|
|
29
|
-
<>
|
|
30
|
-
<SpeakerGlyph muted={muted} />
|
|
31
|
-
{muted ? null : <span>{badge.label}</span>}
|
|
32
|
-
</>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
return <span>{badge.label}</span>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
24
|
const BADGE_CLASS =
|
|
39
|
-
"inline-flex items-center gap-0.5 rounded-[3px] border
|
|
25
|
+
"timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border bg-black/55 px-1 text-[9px] leading-[14px] whitespace-nowrap";
|
|
26
|
+
const FX_ON = "border-white/40 text-white font-semibold";
|
|
27
|
+
const FX_OFF = "border-white/10 text-white/35";
|
|
40
28
|
|
|
41
|
-
/**
|
|
42
|
-
export const ClipBadges = memo(function ClipBadges({
|
|
29
|
+
/** Premiere's fx badge — grey with no effects, white with any; hover lists them, click opens the clip menu. */
|
|
30
|
+
export const ClipBadges = memo(function ClipBadges({
|
|
31
|
+
el,
|
|
32
|
+
onOpenMenu,
|
|
33
|
+
}: {
|
|
34
|
+
el: TimelineElement;
|
|
35
|
+
onOpenMenu?: (event: MouseEvent) => void;
|
|
36
|
+
}) {
|
|
43
37
|
const state = useClipToolState(el);
|
|
44
|
-
const
|
|
45
|
-
|
|
38
|
+
const effects = readClipEffects(state);
|
|
39
|
+
const volume = clipVolumeBadge(state);
|
|
40
|
+
const hasEffects = effects.length > 0;
|
|
41
|
+
const openMenu = (event: MouseEvent) => {
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
onOpenMenu?.(event);
|
|
44
|
+
};
|
|
46
45
|
return (
|
|
47
46
|
<span
|
|
48
47
|
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
|
|
49
48
|
data-testid="clip-badges"
|
|
50
49
|
>
|
|
51
|
-
{
|
|
52
|
-
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
|
|
53
|
-
<BadgeContent badge={badge} />
|
|
54
|
-
</span>
|
|
55
|
-
))}
|
|
56
|
-
{hidden.length > 0 && (
|
|
50
|
+
{volume && (
|
|
57
51
|
<span
|
|
58
|
-
className={`${BADGE_CLASS}
|
|
59
|
-
title={
|
|
60
|
-
data-badge="
|
|
52
|
+
className={`${BADGE_CLASS} border-white/20 text-white/90`}
|
|
53
|
+
title={volume}
|
|
54
|
+
data-badge="volume"
|
|
61
55
|
>
|
|
62
|
-
|
|
56
|
+
<SpeakerGlyph muted={volume === "Muted"} />
|
|
57
|
+
{volume === "Muted" ? null : <span>{volume}</span>}
|
|
63
58
|
</span>
|
|
64
59
|
)}
|
|
60
|
+
<span
|
|
61
|
+
className={`${BADGE_CLASS} pointer-events-auto cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
|
|
62
|
+
title={hasEffects ? effects.join(" · ") : "No effects"}
|
|
63
|
+
data-badge="fx"
|
|
64
|
+
data-fx-active={hasEffects ? "true" : "false"}
|
|
65
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
66
|
+
onClick={openMenu}
|
|
67
|
+
onContextMenu={openMenu}
|
|
68
|
+
>
|
|
69
|
+
fx
|
|
70
|
+
</span>
|
|
65
71
|
</span>
|
|
66
72
|
);
|
|
67
73
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "./testRootHarness";
|
|
5
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
8
|
+
|
|
9
|
+
const iframe = document.createElement("iframe");
|
|
10
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
11
|
+
useStudioShellContextOptional: () => ({
|
|
12
|
+
projectId: "p1",
|
|
13
|
+
showToast: vi.fn(),
|
|
14
|
+
previewIframeRef: { current: iframe },
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const harness = createHappyDomRootHarness();
|
|
19
|
+
|
|
20
|
+
const talk: TimelineElement = {
|
|
21
|
+
id: "talk",
|
|
22
|
+
domId: "talk",
|
|
23
|
+
tag: "video",
|
|
24
|
+
src: "talk.mp4",
|
|
25
|
+
start: 0,
|
|
26
|
+
duration: 6,
|
|
27
|
+
track: 0,
|
|
28
|
+
hasAudio: true,
|
|
29
|
+
link: "lk-1",
|
|
30
|
+
};
|
|
31
|
+
const talkAudio: TimelineElement = {
|
|
32
|
+
...talk,
|
|
33
|
+
id: "talk-a",
|
|
34
|
+
domId: "talk-a",
|
|
35
|
+
tag: "audio",
|
|
36
|
+
track: 1,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function renderMenu(element: TimelineElement) {
|
|
40
|
+
usePlayerStore.getState().setElements([talk, talkAudio]);
|
|
41
|
+
document.body.appendChild(iframe);
|
|
42
|
+
const node = iframe.contentDocument?.createElement("video");
|
|
43
|
+
if (node) {
|
|
44
|
+
node.id = "talk";
|
|
45
|
+
node.setAttribute("data-has-audio", "true");
|
|
46
|
+
iframe.contentDocument?.body.appendChild(node);
|
|
47
|
+
}
|
|
48
|
+
const host = document.createElement("div");
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
const noop = vi.fn();
|
|
51
|
+
act(() =>
|
|
52
|
+
harness.mount(host).render(
|
|
53
|
+
<TimelineEditProvider
|
|
54
|
+
value={{
|
|
55
|
+
onSetElementAttributeQuiet: vi.fn(async () => undefined),
|
|
56
|
+
onFreezeFrame: noop,
|
|
57
|
+
onLinkEdit: noop,
|
|
58
|
+
onDeleteElementOnly: noop,
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<ClipContextMenu
|
|
62
|
+
x={10}
|
|
63
|
+
y={10}
|
|
64
|
+
element={element}
|
|
65
|
+
currentTime={2}
|
|
66
|
+
onClose={noop}
|
|
67
|
+
onSplit={noop}
|
|
68
|
+
onDelete={noop}
|
|
69
|
+
/>
|
|
70
|
+
</TimelineEditProvider>,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function menuLabels(): string[] {
|
|
76
|
+
return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map(
|
|
77
|
+
(item) => item.querySelector("span")?.textContent ?? "",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("ClipContextMenu order", () => {
|
|
82
|
+
it("groups time, sound, picture, then delete for a linked video with sound", () => {
|
|
83
|
+
renderMenu(talk);
|
|
84
|
+
const expected = [
|
|
85
|
+
"Split at 2.00s",
|
|
86
|
+
"Freeze frame",
|
|
87
|
+
"Audio Gain…",
|
|
88
|
+
"Voice",
|
|
89
|
+
"Detach audio",
|
|
90
|
+
"Unlink from audio",
|
|
91
|
+
"Look",
|
|
92
|
+
"Crop",
|
|
93
|
+
"Delete",
|
|
94
|
+
"Delete this clip only",
|
|
95
|
+
];
|
|
96
|
+
expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected);
|
|
97
|
+
const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) =>
|
|
98
|
+
group.getAttribute("aria-label"),
|
|
99
|
+
);
|
|
100
|
+
expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => {
|
|
|
60
60
|
unmount();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("
|
|
63
|
+
it("keeps the menu, host rows included, inside the window by its measured height", () => {
|
|
64
64
|
const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
65
65
|
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
|
|
66
|
+
const offsetHeight = vi
|
|
67
|
+
.spyOn(HTMLElement.prototype, "offsetHeight", "get")
|
|
68
|
+
.mockImplementation(function (this: HTMLElement) {
|
|
69
|
+
return this.getAttribute("role") === "menu" ? 150 : 0;
|
|
70
|
+
});
|
|
66
71
|
try {
|
|
67
72
|
const host = document.createElement("div");
|
|
68
73
|
document.body.appendChild(host);
|
|
@@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => {
|
|
|
86
91
|
/>,
|
|
87
92
|
),
|
|
88
93
|
);
|
|
89
|
-
//
|
|
90
|
-
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("
|
|
94
|
+
// 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge.
|
|
95
|
+
expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("442px");
|
|
91
96
|
act(() => root.unmount());
|
|
92
97
|
} finally {
|
|
98
|
+
offsetHeight.mockRestore();
|
|
93
99
|
if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
|
|
94
100
|
}
|
|
95
101
|
});
|
|
@@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => {
|
|
|
106
112
|
it("shows only Studio's items when the host adds none", () => {
|
|
107
113
|
const { items, unmount } = renderMenu([]);
|
|
108
114
|
expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
|
|
109
|
-
expect(document.body.
|
|
115
|
+
expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
|
|
110
116
|
unmount();
|
|
111
117
|
});
|
|
112
118
|
});
|