@hyperframes/studio 0.8.57 → 0.8.59
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-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +577 -8
- package/dist/index.html +2 -2
- package/dist/index.js +22537 -21600
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +25 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type PointerEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
HF_AUDIO_FADE_IN_ATTR,
|
|
11
|
+
HF_AUDIO_FADE_OUT_ATTR,
|
|
12
|
+
clampFadesToDuration,
|
|
13
|
+
formatFadeSeconds,
|
|
14
|
+
} from "@hyperframes/core/audio-fade";
|
|
15
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
16
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
17
|
+
|
|
18
|
+
type FadeEdge = "in" | "out";
|
|
19
|
+
|
|
20
|
+
const HANDLE_SIZE = 10;
|
|
21
|
+
const HANDLE_HIT = 16;
|
|
22
|
+
/** Pixels of pointer travel before a press on the handle counts as a drag. */
|
|
23
|
+
const DRAG_THRESHOLD_PX = 2;
|
|
24
|
+
|
|
25
|
+
interface TimelineClipFadesProps {
|
|
26
|
+
el: TimelineElement;
|
|
27
|
+
pps: number;
|
|
28
|
+
widthPx: number;
|
|
29
|
+
/** Handles show on hover/selection; the shaded ramps show whenever a fade is set. */
|
|
30
|
+
showHandles: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
|
|
34
|
+
export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
|
|
35
|
+
const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
|
36
|
+
useTimelineEditContextOptional();
|
|
37
|
+
const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
|
|
38
|
+
|
|
39
|
+
// Draft holds the value under the pointer during a drag; it also bridges
|
|
40
|
+
// the gap between release and the store re-reading the file, so the ramp
|
|
41
|
+
// does not snap back for a frame.
|
|
42
|
+
const [draft, setDraft] = useState<{ edge: FadeEdge; seconds: number } | null>(null);
|
|
43
|
+
const [dragging, setDragging] = useState<FadeEdge | null>(null);
|
|
44
|
+
const authoredIn = el.fadeIn ?? 0;
|
|
45
|
+
const authoredOut = el.fadeOut ?? 0;
|
|
46
|
+
useEffect(() => setDraft(null), [authoredIn, authoredOut]);
|
|
47
|
+
|
|
48
|
+
const fades = clampFadesToDuration(
|
|
49
|
+
{
|
|
50
|
+
fadeIn: draft?.edge === "in" ? draft.seconds : authoredIn,
|
|
51
|
+
fadeOut: draft?.edge === "out" ? draft.seconds : authoredOut,
|
|
52
|
+
},
|
|
53
|
+
el.duration,
|
|
54
|
+
);
|
|
55
|
+
const inPx = Math.min(widthPx, fades.fadeIn * pps);
|
|
56
|
+
const outPx = Math.min(widthPx, fades.fadeOut * pps);
|
|
57
|
+
|
|
58
|
+
const gesture = useRef<{
|
|
59
|
+
edge: FadeEdge;
|
|
60
|
+
pointerId: number;
|
|
61
|
+
originClientX: number;
|
|
62
|
+
originSeconds: number;
|
|
63
|
+
otherSeconds: number;
|
|
64
|
+
moved: boolean;
|
|
65
|
+
last: number;
|
|
66
|
+
} | null>(null);
|
|
67
|
+
|
|
68
|
+
const attrFor = (edge: FadeEdge) =>
|
|
69
|
+
edge === "in" ? HF_AUDIO_FADE_IN_ATTR : HF_AUDIO_FADE_OUT_ATTR;
|
|
70
|
+
const attrText = (seconds: number) => (seconds > 0 ? formatFadeSeconds(seconds) : null);
|
|
71
|
+
|
|
72
|
+
const secondsAt = (clientX: number): number => {
|
|
73
|
+
const g = gesture.current;
|
|
74
|
+
if (!g) return 0;
|
|
75
|
+
const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
|
|
76
|
+
// Fade-in grows to the right, fade-out grows to the left.
|
|
77
|
+
const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
|
|
78
|
+
const limit = Math.max(0, el.duration - g.otherSeconds);
|
|
79
|
+
const clamped = Math.min(limit, Math.max(0, raw));
|
|
80
|
+
return Math.round(clamped * 100) / 100;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onHandlePointerDown = (edge: FadeEdge) => (e: PointerEvent<HTMLDivElement>) => {
|
|
84
|
+
if (e.button !== 0 || !canEdit) return;
|
|
85
|
+
// Ours, not the clip's: a press here must not start a move or trim.
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
89
|
+
gesture.current = {
|
|
90
|
+
edge,
|
|
91
|
+
pointerId: e.pointerId,
|
|
92
|
+
originClientX: e.clientX,
|
|
93
|
+
originSeconds: edge === "in" ? authoredIn : authoredOut,
|
|
94
|
+
otherSeconds: edge === "in" ? authoredOut : authoredIn,
|
|
95
|
+
moved: false,
|
|
96
|
+
last: edge === "in" ? authoredIn : authoredOut,
|
|
97
|
+
};
|
|
98
|
+
setDragging(edge);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const onHandlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
102
|
+
const g = gesture.current;
|
|
103
|
+
if (!g || g.pointerId !== e.pointerId) return;
|
|
104
|
+
if (!g.moved && Math.abs(e.clientX - g.originClientX) < DRAG_THRESHOLD_PX) return;
|
|
105
|
+
g.moved = true;
|
|
106
|
+
const seconds = secondsAt(e.clientX);
|
|
107
|
+
if (seconds === g.last) return;
|
|
108
|
+
g.last = seconds;
|
|
109
|
+
setDraft({ edge: g.edge, seconds });
|
|
110
|
+
onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(seconds));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type Gesture = NonNullable<typeof gesture.current>;
|
|
114
|
+
|
|
115
|
+
/** Ends the pointer gesture and returns it, or null when the event is not ours. */
|
|
116
|
+
const endGesture = (e: PointerEvent<HTMLDivElement>): Gesture | null => {
|
|
117
|
+
const g = gesture.current;
|
|
118
|
+
if (!g || g.pointerId !== e.pointerId) return null;
|
|
119
|
+
gesture.current = null;
|
|
120
|
+
setDragging(null);
|
|
121
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
|
122
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
123
|
+
}
|
|
124
|
+
return g;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Puts the live document back where the file has it and drops the draft. */
|
|
128
|
+
const revertGesture = (g: Gesture) => {
|
|
129
|
+
if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
|
|
130
|
+
setDraft(null);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
|
134
|
+
const g = endGesture(e);
|
|
135
|
+
if (!g) return;
|
|
136
|
+
if (cancelled || !g.moved) return revertGesture(g);
|
|
137
|
+
setDraft({ edge: g.edge, seconds: g.last });
|
|
138
|
+
void onSetElementAttributeQuiet?.(
|
|
139
|
+
el,
|
|
140
|
+
attrFor(g.edge),
|
|
141
|
+
attrText(g.last),
|
|
142
|
+
g.edge === "in" ? "Fade in" : "Fade out",
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
147
|
+
const g = gesture.current;
|
|
148
|
+
if (e.key !== "Escape" || !g) return;
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
gesture.current = null;
|
|
151
|
+
setDragging(null);
|
|
152
|
+
revertGesture(g);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const showIn = fades.fadeIn > 0;
|
|
156
|
+
const showOut = fades.fadeOut > 0;
|
|
157
|
+
const handlesVisible = showHandles || dragging !== null;
|
|
158
|
+
if (!showIn && !showOut && !handlesVisible) return null;
|
|
159
|
+
|
|
160
|
+
const handleStyle = (leftPx: number): CSSProperties => ({
|
|
161
|
+
position: "absolute",
|
|
162
|
+
top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
|
|
163
|
+
left: leftPx - HANDLE_HIT / 2,
|
|
164
|
+
width: HANDLE_HIT,
|
|
165
|
+
height: HANDLE_HIT,
|
|
166
|
+
display: "flex",
|
|
167
|
+
alignItems: "center",
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
cursor: "ew-resize",
|
|
170
|
+
zIndex: 6,
|
|
171
|
+
opacity: handlesVisible ? 1 : 0,
|
|
172
|
+
pointerEvents: handlesVisible && canEdit ? "auto" : "none",
|
|
173
|
+
touchAction: "none",
|
|
174
|
+
outline: "none",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
{(showIn || showOut) && (
|
|
180
|
+
<svg
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
data-testid="clip-fade-ramps"
|
|
183
|
+
style={{
|
|
184
|
+
position: "absolute",
|
|
185
|
+
inset: 0,
|
|
186
|
+
width: "100%",
|
|
187
|
+
height: "100%",
|
|
188
|
+
pointerEvents: "none",
|
|
189
|
+
zIndex: 5,
|
|
190
|
+
overflow: "visible",
|
|
191
|
+
}}
|
|
192
|
+
viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
|
|
193
|
+
preserveAspectRatio="none"
|
|
194
|
+
>
|
|
195
|
+
{/* The shaded wedge is the gain that is NOT there: above the ramp. */}
|
|
196
|
+
{showIn && (
|
|
197
|
+
<>
|
|
198
|
+
<polygon
|
|
199
|
+
data-testid="clip-fade-in"
|
|
200
|
+
points={`0,0 ${inPx},0 0,100`}
|
|
201
|
+
fill="var(--timeline-fade-shade)"
|
|
202
|
+
/>
|
|
203
|
+
<line
|
|
204
|
+
x1={0}
|
|
205
|
+
y1={100}
|
|
206
|
+
x2={inPx}
|
|
207
|
+
y2={0}
|
|
208
|
+
stroke="var(--clip-handle)"
|
|
209
|
+
strokeOpacity={0.9}
|
|
210
|
+
strokeWidth={1.25}
|
|
211
|
+
vectorEffect="non-scaling-stroke"
|
|
212
|
+
/>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
{showOut && (
|
|
216
|
+
<>
|
|
217
|
+
<polygon
|
|
218
|
+
data-testid="clip-fade-out"
|
|
219
|
+
points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
|
|
220
|
+
fill="var(--timeline-fade-shade)"
|
|
221
|
+
/>
|
|
222
|
+
<line
|
|
223
|
+
x1={widthPx - outPx}
|
|
224
|
+
y1={0}
|
|
225
|
+
x2={widthPx}
|
|
226
|
+
y2={100}
|
|
227
|
+
stroke="var(--clip-handle)"
|
|
228
|
+
strokeOpacity={0.9}
|
|
229
|
+
strokeWidth={1.25}
|
|
230
|
+
vectorEffect="non-scaling-stroke"
|
|
231
|
+
/>
|
|
232
|
+
</>
|
|
233
|
+
)}
|
|
234
|
+
</svg>
|
|
235
|
+
)}
|
|
236
|
+
{canEdit && (
|
|
237
|
+
<>
|
|
238
|
+
<div
|
|
239
|
+
role="slider"
|
|
240
|
+
tabIndex={-1}
|
|
241
|
+
aria-label="Fade in"
|
|
242
|
+
aria-valuemin={0}
|
|
243
|
+
aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
|
|
244
|
+
aria-valuenow={fades.fadeIn}
|
|
245
|
+
aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
|
|
246
|
+
data-testid="clip-fade-handle-in"
|
|
247
|
+
title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
|
|
248
|
+
style={handleStyle(inPx)}
|
|
249
|
+
onPointerDown={onHandlePointerDown("in")}
|
|
250
|
+
onPointerMove={onHandlePointerMove}
|
|
251
|
+
onPointerUp={(e) => finish(e, false)}
|
|
252
|
+
onPointerCancel={(e) => finish(e, true)}
|
|
253
|
+
onKeyDown={onHandleKeyDown}
|
|
254
|
+
onClick={(e) => e.stopPropagation()}
|
|
255
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
256
|
+
>
|
|
257
|
+
<FadeDot active={dragging === "in"} />
|
|
258
|
+
</div>
|
|
259
|
+
<div
|
|
260
|
+
role="slider"
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
aria-label="Fade out"
|
|
263
|
+
aria-valuemin={0}
|
|
264
|
+
aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
|
|
265
|
+
aria-valuenow={fades.fadeOut}
|
|
266
|
+
aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
|
|
267
|
+
data-testid="clip-fade-handle-out"
|
|
268
|
+
title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
|
|
269
|
+
style={handleStyle(widthPx - outPx)}
|
|
270
|
+
onPointerDown={onHandlePointerDown("out")}
|
|
271
|
+
onPointerMove={onHandlePointerMove}
|
|
272
|
+
onPointerUp={(e) => finish(e, false)}
|
|
273
|
+
onPointerCancel={(e) => finish(e, true)}
|
|
274
|
+
onKeyDown={onHandleKeyDown}
|
|
275
|
+
onClick={(e) => e.stopPropagation()}
|
|
276
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
277
|
+
>
|
|
278
|
+
<FadeDot active={dragging === "out"} />
|
|
279
|
+
</div>
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
282
|
+
</>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function FadeDot({ active }: { active: boolean }) {
|
|
287
|
+
return (
|
|
288
|
+
<span
|
|
289
|
+
aria-hidden="true"
|
|
290
|
+
style={{
|
|
291
|
+
display: "block",
|
|
292
|
+
width: HANDLE_SIZE,
|
|
293
|
+
height: HANDLE_SIZE,
|
|
294
|
+
borderRadius: "50%",
|
|
295
|
+
background: active ? "var(--timeline-fade-dot-active)" : "var(--clip-handle)",
|
|
296
|
+
boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
|
|
297
|
+
transform: active ? "scale(1.15)" : undefined,
|
|
298
|
+
transition: "transform 80ms ease-out",
|
|
299
|
+
}}
|
|
300
|
+
/>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -65,9 +65,9 @@ function GroupNameButton({
|
|
|
65
65
|
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
66
66
|
▤
|
|
67
67
|
</span>
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
<span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
|
|
69
|
+
{label}
|
|
70
|
+
</span>
|
|
71
71
|
<span
|
|
72
72
|
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
|
73
73
|
aria-hidden="true"
|
|
@@ -109,9 +109,7 @@ export function TimelineGroupHeader({
|
|
|
109
109
|
}}
|
|
110
110
|
>
|
|
111
111
|
{/* One line, like a track header's: caret and name, then every control
|
|
112
|
-
anchored to the right edge.
|
|
113
|
-
`shrink-0`, so they hold the edge and the name gives way — no second
|
|
114
|
-
line needed to keep five controls off the label. */}
|
|
112
|
+
anchored to the right edge. */}
|
|
115
113
|
<button
|
|
116
114
|
type="button"
|
|
117
115
|
tabIndex={-1}
|
|
@@ -4,7 +4,7 @@ import { EditPopover } from "./EditModal";
|
|
|
4
4
|
import { KeyframeDiamondContextMenu } from "./KeyframeDiamondContextMenu";
|
|
5
5
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
6
6
|
import { TrackGapContextMenu } from "./TrackGapContextMenu";
|
|
7
|
-
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
7
|
+
import { TimelineShortcutHint as TimelineShortcutHintImpl } from "./TimelineShortcutHint";
|
|
8
8
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
9
9
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
10
10
|
import { useTimelineContext } from "./TimelineProvider";
|
|
@@ -17,7 +17,6 @@ interface TimelineContextTargetInput {
|
|
|
17
17
|
elements: readonly TimelineElement[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The captured project session and current selection jointly own a context target. */
|
|
21
20
|
export function resolveTimelineContextElement({
|
|
22
21
|
capturedElement,
|
|
23
22
|
targetSessionEpoch,
|
|
@@ -31,192 +30,192 @@ export function resolveTimelineContextElement({
|
|
|
31
30
|
return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
export function TimelineShortcutHintOverlay() {
|
|
34
|
+
const { state } = useTimelineContext();
|
|
35
|
+
const { showShortcutHint, showPopover, rangeSelection, theme } = state.overlays;
|
|
36
|
+
if (!showShortcutHint || showPopover || rangeSelection) return null;
|
|
37
|
+
return <TimelineShortcutHintImpl theme={theme} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function TimelineEditPopoverOverlay() {
|
|
41
|
+
const { state } = useTimelineContext();
|
|
42
|
+
const { showPopover, rangeSelection, setShowPopover, setRangeSelection } = state.overlays;
|
|
43
|
+
if (!showPopover || !rangeSelection) return null;
|
|
44
|
+
return (
|
|
45
|
+
<EditPopover
|
|
46
|
+
rangeStart={rangeSelection.start}
|
|
47
|
+
rangeEnd={rangeSelection.end}
|
|
48
|
+
anchorX={rangeSelection.anchorX}
|
|
49
|
+
anchorY={rangeSelection.anchorY}
|
|
50
|
+
onClose={() => {
|
|
51
|
+
setShowPopover(false);
|
|
52
|
+
setRangeSelection(null);
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function TimelineKeyframeMenuOverlay() {
|
|
38
59
|
const { state, actions } = useTimelineContext();
|
|
39
|
-
const
|
|
40
|
-
const {
|
|
41
|
-
elements,
|
|
42
|
-
elementsRef,
|
|
43
|
-
theme,
|
|
44
|
-
showShortcutHint,
|
|
45
|
-
showPopover,
|
|
46
|
-
rangeSelection,
|
|
47
|
-
setShowPopover,
|
|
48
|
-
setRangeSelection,
|
|
49
|
-
kfContextMenu,
|
|
50
|
-
setKfContextMenu,
|
|
51
|
-
onDeleteKeyframe,
|
|
52
|
-
onDeleteAllKeyframes,
|
|
53
|
-
onMoveKeyframeToPlayhead,
|
|
54
|
-
clipContextMenu,
|
|
55
|
-
setClipContextMenu,
|
|
56
|
-
currentTime,
|
|
57
|
-
onSplitElement,
|
|
58
|
-
pinZoomBeforeEdit,
|
|
59
|
-
onDeleteElement,
|
|
60
|
-
onCopyClip,
|
|
61
|
-
onPasteClip,
|
|
62
|
-
onDuplicateClip,
|
|
63
|
-
canPasteClip,
|
|
64
|
-
gapContextMenu,
|
|
65
|
-
onDismissGapContextMenu,
|
|
66
|
-
onCloseTrackGap,
|
|
67
|
-
onCloseAllTrackGaps,
|
|
68
|
-
onHoverGapAction,
|
|
69
|
-
} = overlayProps;
|
|
60
|
+
const overlay = state.overlays;
|
|
61
|
+
const { kfContextMenu, setKfContextMenu } = overlay;
|
|
70
62
|
const { selectedElementId, sessionEpoch, keyframeCache } = state;
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const keyframeElement = kfContextMenu
|
|
63
|
+
const targetEpoch = kfContextMenu?.sessionEpoch;
|
|
64
|
+
const element = kfContextMenu
|
|
74
65
|
? resolveTimelineContextElement({
|
|
75
66
|
capturedElement: kfContextMenu.element,
|
|
76
|
-
targetSessionEpoch:
|
|
67
|
+
targetSessionEpoch: targetEpoch,
|
|
77
68
|
sessionEpoch,
|
|
78
69
|
selectedElementId,
|
|
79
|
-
elements,
|
|
70
|
+
elements: overlay.elements,
|
|
80
71
|
})
|
|
81
72
|
: null;
|
|
82
|
-
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (kfContextMenu && !element) setKfContextMenu(null);
|
|
75
|
+
}, [element, kfContextMenu, setKfContextMenu]);
|
|
76
|
+
if (!kfContextMenu || !element) return null;
|
|
77
|
+
const readCurrentElement = () =>
|
|
78
|
+
resolveTimelineContextElement({
|
|
79
|
+
capturedElement: element,
|
|
80
|
+
targetSessionEpoch: targetEpoch,
|
|
81
|
+
sessionEpoch,
|
|
82
|
+
selectedElementId,
|
|
83
|
+
elements: overlay.elementsRef.current,
|
|
84
|
+
});
|
|
85
|
+
const menu = kfContextMenu;
|
|
86
|
+
return (
|
|
87
|
+
<KeyframeDiamondContextMenu
|
|
88
|
+
state={{ ...menu, element }}
|
|
89
|
+
onClose={() => setKfContextMenu(null)}
|
|
90
|
+
onDelete={(...args) => {
|
|
91
|
+
if (readCurrentElement()) overlay.onDeleteKeyframe?.(...args);
|
|
92
|
+
}}
|
|
93
|
+
onDeleteAll={(_element, animationId) => {
|
|
94
|
+
const current = readCurrentElement();
|
|
95
|
+
if (current) overlay.onDeleteAllKeyframes?.(current, animationId);
|
|
96
|
+
}}
|
|
97
|
+
onMoveToPlayhead={
|
|
98
|
+
overlay.onMoveKeyframeToPlayhead
|
|
99
|
+
? (_element, ...args) => {
|
|
100
|
+
const current = readCurrentElement();
|
|
101
|
+
if (current) overlay.onMoveKeyframeToPlayhead?.(current, ...args);
|
|
102
|
+
}
|
|
103
|
+
: undefined
|
|
104
|
+
}
|
|
105
|
+
onEditEase={
|
|
106
|
+
// Routed to the same focused-ease-segment path a segment click takes,
|
|
107
|
+
// so the menu advertises the editor that exists rather than growing a
|
|
108
|
+
// second one. Offered only for a keyframe that names a tween to focus.
|
|
109
|
+
menu.animationId !== undefined && menu.tweenPercentage !== undefined
|
|
110
|
+
? (elementId, keyframe) => {
|
|
111
|
+
if (keyframe.animationId === undefined || keyframe.tweenPercentage === undefined)
|
|
112
|
+
return;
|
|
113
|
+
actions.setFocusedEaseSegment({
|
|
114
|
+
animationId: keyframe.animationId,
|
|
115
|
+
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
116
|
+
tweenPercentage: keyframe.tweenPercentage,
|
|
117
|
+
elementId,
|
|
118
|
+
});
|
|
119
|
+
trackStudioSegmentEaseEdit({ action: "open" });
|
|
120
|
+
}
|
|
121
|
+
: undefined
|
|
122
|
+
}
|
|
123
|
+
onCopyProperties={(elementId, keyframe) => {
|
|
124
|
+
const entry = keyframeCache.get(elementId);
|
|
125
|
+
// Match the existing keyframe lookup tolerance so copied properties
|
|
126
|
+
// follow the same nearby-keyframe selection as the editor.
|
|
127
|
+
const keyframeValue = entry?.keyframes.find(
|
|
128
|
+
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
|
129
|
+
);
|
|
130
|
+
if (!keyframeValue) return false;
|
|
131
|
+
return copyTextToClipboard(JSON.stringify(keyframeValue.properties, null, 2));
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function TimelineClipMenuOverlay() {
|
|
138
|
+
const { state } = useTimelineContext();
|
|
139
|
+
const overlay = state.overlays;
|
|
140
|
+
const { clipContextMenu, setClipContextMenu } = overlay;
|
|
141
|
+
const { selectedElementId, sessionEpoch } = state;
|
|
142
|
+
const targetEpoch = clipContextMenu?.sessionEpoch;
|
|
143
|
+
const element = clipContextMenu
|
|
83
144
|
? resolveTimelineContextElement({
|
|
84
145
|
capturedElement: clipContextMenu.element,
|
|
85
|
-
targetSessionEpoch:
|
|
146
|
+
targetSessionEpoch: targetEpoch,
|
|
86
147
|
sessionEpoch,
|
|
87
148
|
selectedElementId,
|
|
88
|
-
elements,
|
|
149
|
+
elements: overlay.elements,
|
|
89
150
|
})
|
|
90
151
|
: null;
|
|
91
|
-
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (clipContextMenu && !element) setClipContextMenu(null);
|
|
154
|
+
}, [element, clipContextMenu, setClipContextMenu]);
|
|
155
|
+
if (!clipContextMenu || !element) return null;
|
|
156
|
+
const readCurrentElement = () =>
|
|
92
157
|
resolveTimelineContextElement({
|
|
93
158
|
capturedElement: element,
|
|
94
|
-
targetSessionEpoch,
|
|
159
|
+
targetSessionEpoch: targetEpoch,
|
|
95
160
|
sessionEpoch,
|
|
96
161
|
selectedElementId,
|
|
97
|
-
elements: elementsRef.current,
|
|
162
|
+
elements: overlay.elementsRef.current,
|
|
98
163
|
});
|
|
164
|
+
const menu = clipContextMenu;
|
|
165
|
+
return (
|
|
166
|
+
<ClipContextMenu
|
|
167
|
+
x={menu.x}
|
|
168
|
+
y={menu.y}
|
|
169
|
+
element={element}
|
|
170
|
+
currentTime={overlay.currentTime}
|
|
171
|
+
onClose={() => setClipContextMenu(null)}
|
|
172
|
+
onSplit={(_element, time) => {
|
|
173
|
+
const current = readCurrentElement();
|
|
174
|
+
if (current) overlay.onSplitElement?.(current, time);
|
|
175
|
+
}}
|
|
176
|
+
onDelete={() => {
|
|
177
|
+
const current = readCurrentElement();
|
|
178
|
+
if (!current) return;
|
|
179
|
+
overlay.pinZoomBeforeEdit();
|
|
180
|
+
overlay.onDeleteElement?.(current);
|
|
181
|
+
}}
|
|
182
|
+
onCopy={overlay.onCopyClip}
|
|
183
|
+
onPaste={overlay.onPasteClip}
|
|
184
|
+
onDuplicate={overlay.onDuplicateClip}
|
|
185
|
+
canPaste={overlay.canPasteClip?.() ?? false}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
99
189
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
190
|
+
export function TimelineGapMenuOverlay() {
|
|
191
|
+
const { state } = useTimelineContext();
|
|
192
|
+
const menu = state.overlays.gapContextMenu;
|
|
193
|
+
const overlay = state.overlays;
|
|
194
|
+
if (!menu) return null;
|
|
195
|
+
return (
|
|
196
|
+
<TrackGapContextMenu
|
|
197
|
+
x={menu.x}
|
|
198
|
+
y={menu.y}
|
|
199
|
+
gapWidth={menu.gapWidth}
|
|
200
|
+
canCloseGap={menu.canCloseGap}
|
|
201
|
+
canCloseAllGaps={menu.canCloseAllGaps}
|
|
202
|
+
hasAnyGaps={menu.hasAnyGaps}
|
|
203
|
+
onClose={overlay.onDismissGapContextMenu}
|
|
204
|
+
onCloseGap={overlay.onCloseTrackGap}
|
|
205
|
+
onCloseAllGaps={overlay.onCloseAllTrackGaps}
|
|
206
|
+
onHoverAction={overlay.onHoverGapAction}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
107
210
|
|
|
211
|
+
export function TimelineOverlays() {
|
|
108
212
|
return (
|
|
109
213
|
<>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<EditPopover
|
|
116
|
-
rangeStart={rangeSelection.start}
|
|
117
|
-
rangeEnd={rangeSelection.end}
|
|
118
|
-
anchorX={rangeSelection.anchorX}
|
|
119
|
-
anchorY={rangeSelection.anchorY}
|
|
120
|
-
onClose={() => {
|
|
121
|
-
setShowPopover(false);
|
|
122
|
-
setRangeSelection(null);
|
|
123
|
-
}}
|
|
124
|
-
/>
|
|
125
|
-
)}
|
|
126
|
-
|
|
127
|
-
{kfContextMenu && keyframeElement && (
|
|
128
|
-
<KeyframeDiamondContextMenu
|
|
129
|
-
state={{ ...kfContextMenu, element: keyframeElement }}
|
|
130
|
-
onClose={() => setKfContextMenu(null)}
|
|
131
|
-
onDelete={(...args) => {
|
|
132
|
-
if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
|
|
133
|
-
onDeleteKeyframe?.(...args);
|
|
134
|
-
}}
|
|
135
|
-
onDeleteAll={(_element, animationId) => {
|
|
136
|
-
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
137
|
-
if (element) onDeleteAllKeyframes?.(element, animationId);
|
|
138
|
-
}}
|
|
139
|
-
onMoveToPlayhead={
|
|
140
|
-
onMoveKeyframeToPlayhead
|
|
141
|
-
? (_element, ...args) => {
|
|
142
|
-
const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
|
|
143
|
-
if (element) onMoveKeyframeToPlayhead(element, ...args);
|
|
144
|
-
}
|
|
145
|
-
: undefined
|
|
146
|
-
}
|
|
147
|
-
// Routed to the same focused-ease-segment path a segment click takes,
|
|
148
|
-
// so the menu advertises the editor that exists rather than growing a
|
|
149
|
-
// second one. Offered only for a keyframe that names a tween to focus.
|
|
150
|
-
onEditEase={
|
|
151
|
-
kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
|
|
152
|
-
? (elementId, keyframe) => {
|
|
153
|
-
if (
|
|
154
|
-
keyframe.animationId === undefined ||
|
|
155
|
-
keyframe.tweenPercentage === undefined
|
|
156
|
-
) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
actions.setFocusedEaseSegment({
|
|
160
|
-
animationId: keyframe.animationId,
|
|
161
|
-
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
162
|
-
tweenPercentage: keyframe.tweenPercentage,
|
|
163
|
-
elementId,
|
|
164
|
-
});
|
|
165
|
-
trackStudioSegmentEaseEdit({ action: "open" });
|
|
166
|
-
}
|
|
167
|
-
: undefined
|
|
168
|
-
}
|
|
169
|
-
onCopyProperties={(elementId, keyframe) => {
|
|
170
|
-
const entry = keyframeCache.get(elementId);
|
|
171
|
-
// Tolerance match on clip-%, the same basis the cache is keyed on —
|
|
172
|
-
// an exact float compare misses a keyframe the menu just opened over.
|
|
173
|
-
const kf = entry?.keyframes.find(
|
|
174
|
-
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
|
|
175
|
-
);
|
|
176
|
-
if (!kf) return false;
|
|
177
|
-
return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
|
|
178
|
-
}}
|
|
179
|
-
/>
|
|
180
|
-
)}
|
|
181
|
-
|
|
182
|
-
{clipContextMenu && clipElement && (
|
|
183
|
-
<ClipContextMenu
|
|
184
|
-
x={clipContextMenu.x}
|
|
185
|
-
y={clipContextMenu.y}
|
|
186
|
-
element={clipElement}
|
|
187
|
-
currentTime={currentTime}
|
|
188
|
-
onClose={() => setClipContextMenu(null)}
|
|
189
|
-
onSplit={(_element, time) => {
|
|
190
|
-
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
191
|
-
if (element) onSplitElement?.(element, time);
|
|
192
|
-
}}
|
|
193
|
-
onDelete={() => {
|
|
194
|
-
const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
|
|
195
|
-
if (!element) return;
|
|
196
|
-
pinZoomBeforeEdit();
|
|
197
|
-
onDeleteElement?.(element);
|
|
198
|
-
}}
|
|
199
|
-
onCopy={onCopyClip}
|
|
200
|
-
onPaste={onPasteClip}
|
|
201
|
-
onDuplicate={onDuplicateClip}
|
|
202
|
-
canPaste={canPasteClip?.() ?? false}
|
|
203
|
-
/>
|
|
204
|
-
)}
|
|
205
|
-
|
|
206
|
-
{gapContextMenu && (
|
|
207
|
-
<TrackGapContextMenu
|
|
208
|
-
x={gapContextMenu.x}
|
|
209
|
-
y={gapContextMenu.y}
|
|
210
|
-
gapWidth={gapContextMenu.gapWidth}
|
|
211
|
-
canCloseGap={gapContextMenu.canCloseGap}
|
|
212
|
-
canCloseAllGaps={gapContextMenu.canCloseAllGaps}
|
|
213
|
-
hasAnyGaps={gapContextMenu.hasAnyGaps}
|
|
214
|
-
onClose={onDismissGapContextMenu}
|
|
215
|
-
onCloseGap={onCloseTrackGap}
|
|
216
|
-
onCloseAllGaps={onCloseAllTrackGaps}
|
|
217
|
-
onHoverAction={onHoverGapAction}
|
|
218
|
-
/>
|
|
219
|
-
)}
|
|
214
|
+
<TimelineShortcutHintOverlay />
|
|
215
|
+
<TimelineEditPopoverOverlay />
|
|
216
|
+
<TimelineKeyframeMenuOverlay />
|
|
217
|
+
<TimelineClipMenuOverlay />
|
|
218
|
+
<TimelineGapMenuOverlay />
|
|
220
219
|
</>
|
|
221
220
|
);
|
|
222
221
|
}
|