@hyperframes/studio 0.8.139 → 0.8.140
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-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
- package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.html +1 -1
- package/dist/index.js +6498 -5813
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { memo, useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
|
1
|
+
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { fadeGain } from "@hyperframes/core/audio-fade";
|
|
3
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
3
|
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
|
4
|
+
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
|
5
5
|
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
6
6
|
import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
|
|
7
7
|
import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
|
|
@@ -30,6 +30,8 @@ export const WAVEFORM_LAYER_Z = 10;
|
|
|
30
30
|
|
|
31
31
|
type BarGeometry = { x: number; width: number; height: number; gain: number };
|
|
32
32
|
|
|
33
|
+
type ClipSpan = { from: number; to: number };
|
|
34
|
+
const WHOLE_CLIP: ClipSpan = { from: 0, to: 1 };
|
|
33
35
|
function paintWaveformBars(
|
|
34
36
|
context: CanvasRenderingContext2D,
|
|
35
37
|
bars: readonly BarGeometry[],
|
|
@@ -61,6 +63,7 @@ export function drawWaveformCanvas(
|
|
|
61
63
|
trimStartFraction: number,
|
|
62
64
|
trimEndFraction: number,
|
|
63
65
|
fades: ClipFadeShape | null = null,
|
|
66
|
+
span: ClipSpan = WHOLE_CLIP,
|
|
64
67
|
) {
|
|
65
68
|
const width = Math.max(1, canvas.clientWidth);
|
|
66
69
|
const height = Math.max(1, canvas.clientHeight);
|
|
@@ -71,19 +74,20 @@ export function drawWaveformCanvas(
|
|
|
71
74
|
if (!context) return;
|
|
72
75
|
context.scale(scale, scale);
|
|
73
76
|
context.clearRect(0, 0, width, height);
|
|
77
|
+
const trim = trimEndFraction - trimStartFraction;
|
|
74
78
|
const amplitudes = decimatePeaks(
|
|
75
79
|
peaks,
|
|
76
|
-
trimStartFraction,
|
|
77
|
-
|
|
80
|
+
trimStartFraction + trim * span.from,
|
|
81
|
+
trimStartFraction + trim * span.to,
|
|
78
82
|
Math.max(1, Math.ceil(width / BAR_STEP)),
|
|
79
83
|
);
|
|
84
|
+
const clipFraction = (index: number) =>
|
|
85
|
+
span.from + ((index + 0.5) / amplitudes.length) * (span.to - span.from);
|
|
80
86
|
const bars = amplitudes.map((amplitude, index) => ({
|
|
81
87
|
x: (index * width) / amplitudes.length,
|
|
82
88
|
width: Math.max(1, width / amplitudes.length),
|
|
83
89
|
height: Math.max(3, amplitude * height),
|
|
84
|
-
gain: fades
|
|
85
|
-
? fadeGain(((index + 0.5) / amplitudes.length) * fades.duration, fades.duration, fades)
|
|
86
|
-
: 1,
|
|
90
|
+
gain: fades ? fadeGain(clipFraction(index) * fades.duration, fades.duration, fades) : 1,
|
|
87
91
|
}));
|
|
88
92
|
const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
|
|
89
93
|
const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
|
|
@@ -115,11 +119,8 @@ async function loadWaveform(
|
|
|
115
119
|
waveformUrl: string | undefined,
|
|
116
120
|
signal: AbortSignal,
|
|
117
121
|
): Promise<number[]> {
|
|
118
|
-
// Failures propagate
|
|
119
|
-
//
|
|
120
|
-
// fabrication is indistinguishable from the real thing while being wrong.
|
|
121
|
-
// The scheduler caches the failure (metadataFailureTtlMs) so the degraded
|
|
122
|
-
// state neither refetch-loops nor pins itself past a transient error.
|
|
122
|
+
// Failures propagate: authors trim and beat-align against this, so made-up peaks beat no gap.
|
|
123
|
+
// The scheduler caches the failure (metadataFailureTtlMs): no refetch loop, no stuck state.
|
|
123
124
|
return waveformUrl
|
|
124
125
|
? await fetchWaveformPeaks(waveformUrl, signal)
|
|
125
126
|
: await decodeWaveformPeaks(audioUrl, signal);
|
|
@@ -157,6 +158,32 @@ async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<nu
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
/** Bounded waveform subscriber; cache, cancellation and dedupe live in one scheduler. */
|
|
161
|
+
const NEAR_SCREEN: IntersectionObserverInit & { scrollMargin: string } = {
|
|
162
|
+
rootMargin: "50% 100%",
|
|
163
|
+
scrollMargin: "50% 100%",
|
|
164
|
+
};
|
|
165
|
+
const nearScreenListeners = new Map<Element, (near: boolean) => void>();
|
|
166
|
+
let nearScreen: IntersectionObserver | null = null;
|
|
167
|
+
|
|
168
|
+
function useNearScreen(element: Element | null): boolean {
|
|
169
|
+
const [near, setNear] = useState(() => typeof IntersectionObserver === "undefined");
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!element || typeof IntersectionObserver === "undefined") return;
|
|
172
|
+
nearScreen ??= new IntersectionObserver(
|
|
173
|
+
(entries) =>
|
|
174
|
+
entries.forEach((entry) => nearScreenListeners.get(entry.target)?.(entry.isIntersecting)),
|
|
175
|
+
NEAR_SCREEN,
|
|
176
|
+
);
|
|
177
|
+
nearScreenListeners.set(element, setNear);
|
|
178
|
+
nearScreen.observe(element);
|
|
179
|
+
return () => {
|
|
180
|
+
nearScreenListeners.delete(element);
|
|
181
|
+
nearScreen?.unobserve(element);
|
|
182
|
+
};
|
|
183
|
+
}, [element]);
|
|
184
|
+
return near;
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
export const AudioWaveform = memo(function AudioWaveform({
|
|
161
188
|
audioUrl,
|
|
162
189
|
waveformUrl,
|
|
@@ -170,9 +197,8 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
170
197
|
muted = false,
|
|
171
198
|
labelInset = 16,
|
|
172
199
|
}: AudioWaveformProps) {
|
|
173
|
-
const
|
|
200
|
+
const [root, setRoot] = useState<HTMLDivElement | null>(null);
|
|
174
201
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
175
|
-
const observerRef = useRef<ResizeObserver | null>(null);
|
|
176
202
|
const cacheKey = waveformUrl ?? audioUrl;
|
|
177
203
|
const request = useMemo(
|
|
178
204
|
() => ({
|
|
@@ -197,23 +223,31 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
197
223
|
snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null;
|
|
198
224
|
|
|
199
225
|
const fades = useContext(ClipFadesContext);
|
|
226
|
+
const [strip, setStripRef, watchGap] = useThumbnailStripSize();
|
|
227
|
+
const from = strip.width > 0 ? strip.inViewStart / strip.width : 0;
|
|
228
|
+
const to = strip.width > 0 ? strip.inViewEnd / strip.width : 1;
|
|
200
229
|
const draw = useCallback(() => {
|
|
201
230
|
const canvas = canvasRef.current;
|
|
202
231
|
if (!canvas || !peaks) return;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
232
|
+
const span = { from, to };
|
|
233
|
+
// Placed in fractions of the clip, so a zoom preview stretches the drawn bars with time.
|
|
234
|
+
canvas.style.left = `${span.from * 100}%`;
|
|
235
|
+
canvas.style.width = `${(span.to - span.from) * 100}%`;
|
|
236
|
+
if (span.to <= span.from) return;
|
|
237
|
+
drawWaveformCanvas(
|
|
238
|
+
canvas,
|
|
239
|
+
peaks,
|
|
240
|
+
muted,
|
|
241
|
+
trimStartFraction ?? 0,
|
|
242
|
+
trimEndFraction ?? 1,
|
|
243
|
+
fades,
|
|
244
|
+
span,
|
|
245
|
+
);
|
|
246
|
+
}, [fades, from, muted, peaks, to, trimEndFraction, trimStartFraction]);
|
|
247
|
+
const near = useNearScreen(root);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (near) draw();
|
|
250
|
+
}, [draw, near, strip.width, strip.height]);
|
|
217
251
|
|
|
218
252
|
useEffect(() => {
|
|
219
253
|
const root = document.documentElement;
|
|
@@ -225,23 +259,45 @@ export const AudioWaveform = memo(function AudioWaveform({
|
|
|
225
259
|
return () => observer.disconnect();
|
|
226
260
|
}, [draw]);
|
|
227
261
|
|
|
228
|
-
useMountEffect(() => () => observerRef.current?.disconnect());
|
|
229
|
-
|
|
230
262
|
useEffect(() => {
|
|
231
|
-
const clip =
|
|
263
|
+
const clip = root?.closest(".timeline-clip");
|
|
232
264
|
if (!(clip instanceof HTMLElement)) return;
|
|
233
265
|
if (muted) clip.setAttribute("data-audio-muted", "true");
|
|
234
266
|
else clip.removeAttribute("data-audio-muted");
|
|
235
267
|
return () => clip.removeAttribute("data-audio-muted");
|
|
236
|
-
}, [muted]);
|
|
268
|
+
}, [muted, root]);
|
|
237
269
|
|
|
238
270
|
return (
|
|
239
|
-
<div ref={
|
|
240
|
-
<div
|
|
271
|
+
<div ref={setRoot} className="absolute inset-0">
|
|
272
|
+
<div
|
|
273
|
+
ref={setStripRef}
|
|
274
|
+
className="absolute inset-0 overflow-hidden"
|
|
275
|
+
style={{ zIndex: WAVEFORM_LAYER_Z }}
|
|
276
|
+
>
|
|
277
|
+
{/* The undrawn ends: one coming near the screen, as a move carries the clip, re-measures. */}
|
|
278
|
+
{from > 0 && (
|
|
279
|
+
<div
|
|
280
|
+
ref={watchGap}
|
|
281
|
+
className="pointer-events-none absolute inset-y-0 left-0"
|
|
282
|
+
style={{ width: `${from * 100}%` }}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
{to < 1 && (
|
|
286
|
+
<div
|
|
287
|
+
ref={watchGap}
|
|
288
|
+
className="pointer-events-none absolute inset-y-0 right-0"
|
|
289
|
+
style={{ left: `${to * 100}%` }}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
241
292
|
<canvas
|
|
242
|
-
ref={
|
|
243
|
-
className="absolute
|
|
244
|
-
style={{
|
|
293
|
+
ref={canvasRef}
|
|
294
|
+
className="absolute bottom-0"
|
|
295
|
+
style={{
|
|
296
|
+
left: 0,
|
|
297
|
+
width: "100%",
|
|
298
|
+
top: labelInset,
|
|
299
|
+
height: `calc(100% - ${labelInset}px)`,
|
|
300
|
+
}}
|
|
245
301
|
/>
|
|
246
302
|
{snapshot.status === "loading" && (
|
|
247
303
|
<div
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, expect, it } from "vitest";
|
|
5
|
+
import { mountThreeTrackTimeline } from "./timelineMountFixtures";
|
|
6
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
7
|
+
import { isTimelineMoving } from "./timelineMotion";
|
|
8
|
+
|
|
9
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
installTimelineMountEnv();
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps the timeline at rest through a scroll, as playback follow makes every frame", async () => {
|
|
18
|
+
const { root, viewport } = await mountThreeTrackTimeline();
|
|
19
|
+
act(() => viewport.dispatchEvent(new Event("scroll")));
|
|
20
|
+
expect(isTimelineMoving()).toBe(false);
|
|
21
|
+
act(() => root.unmount());
|
|
22
|
+
});
|
|
@@ -208,7 +208,9 @@ describe("Timeline provider boundary", () => {
|
|
|
208
208
|
renderTimelineGeometry("clip-1");
|
|
209
209
|
|
|
210
210
|
expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
|
211
|
-
|
|
211
|
+
// 10 s at 100 px/s: 1000% of a one-second layer 100px wide.
|
|
212
|
+
expect(clip.style.left).toBe("1000%");
|
|
213
|
+
expect((clip.closest("[data-timeline-time-layer]") as HTMLElement).style.width).toBe("100px");
|
|
212
214
|
expect(clip.style.height).toBe("");
|
|
213
215
|
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
|
214
216
|
expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
|
|
@@ -116,6 +116,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
116
116
|
return visibleIntervals.map((gap) => (
|
|
117
117
|
<div
|
|
118
118
|
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
|
119
|
+
data-timeline-zoom-scale={-gap.start * props.pps}
|
|
119
120
|
className="pointer-events-none absolute"
|
|
120
121
|
style={{
|
|
121
122
|
top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
|
|
@@ -246,6 +247,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
246
247
|
{/* Range highlight */}
|
|
247
248
|
{props.rangeSelection && (
|
|
248
249
|
<div
|
|
250
|
+
data-timeline-zoom-scale={
|
|
251
|
+
-Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps
|
|
252
|
+
}
|
|
249
253
|
className="absolute pointer-events-none"
|
|
250
254
|
style={{
|
|
251
255
|
left:
|
|
@@ -17,6 +17,7 @@ import { linkLabelColor } from "./linkLabelColor";
|
|
|
17
17
|
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
18
18
|
import { clipSpeedSuffix } from "./clipToolAttrs";
|
|
19
19
|
import { ClipPeakTooltip } from "./ClipPeakTooltip";
|
|
20
|
+
import { timeLayerPercent } from "./TimelineTimeLayer";
|
|
20
21
|
|
|
21
22
|
interface TimelineClipProps {
|
|
22
23
|
el: TimelineElement;
|
|
@@ -44,6 +45,20 @@ interface TimelineClipProps {
|
|
|
44
45
|
children?: ReactNode;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
const HANDLES_MIN_PX = 32;
|
|
49
|
+
|
|
50
|
+
const CLIP_MIN_WIDTH_PX = 4;
|
|
51
|
+
export const clipWidthPx = (el: TimelineElement, pps: number) =>
|
|
52
|
+
Math.max(el.duration * pps, CLIP_MIN_WIDTH_PX);
|
|
53
|
+
|
|
54
|
+
/** Zoom-dependent drawing as one value: width tier and trim fit, or the scale while fades show. */
|
|
55
|
+
export function clipZoomKey(el: TimelineElement, pps: number, interacting: boolean) {
|
|
56
|
+
const fadesLive = interacting || (el.fadeIn ?? 0) > 0 || (el.fadeOut ?? 0) > 0;
|
|
57
|
+
if (fadesLive) return pps;
|
|
58
|
+
const widthPx = clipWidthPx(el, pps);
|
|
59
|
+
return `${clipWidthLadder(widthPx)}${widthPx >= HANDLES_MIN_PX ? "+handles" : ""}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// fallow-ignore-next-line complexity
|
|
48
63
|
export const TimelineClip = memo(function TimelineClip({
|
|
49
64
|
el,
|
|
@@ -70,13 +85,12 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
70
85
|
onContextMenu,
|
|
71
86
|
children,
|
|
72
87
|
}: TimelineClipProps) {
|
|
73
|
-
const
|
|
74
|
-
const widthPx = Math.max(el.duration * pps, 4);
|
|
88
|
+
const widthPx = clipWidthPx(el, pps);
|
|
75
89
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
76
90
|
const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`;
|
|
77
91
|
const isAudioClip = isAudioTimelineElement(el);
|
|
78
92
|
const ladder = clipWidthLadder(widthPx);
|
|
79
|
-
const showHandles = handleOpacity > 0.01 && (widthPx >=
|
|
93
|
+
const showHandles = handleOpacity > 0.01 && (widthPx >= HANDLES_MIN_PX || isSelected);
|
|
80
94
|
const showLabel = !isAudioClip || ladder === "labeled";
|
|
81
95
|
const showDefaultText = !hasCustomContent && ladder === "labeled";
|
|
82
96
|
const startLabel = el.start.toFixed(1);
|
|
@@ -111,14 +125,14 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
111
125
|
.filter((className) => className.length > 0)
|
|
112
126
|
.join(" ");
|
|
113
127
|
const style: CSSProperties = {
|
|
114
|
-
left:
|
|
115
|
-
width:
|
|
128
|
+
left: timeLayerPercent(el.start),
|
|
129
|
+
width: timeLayerPercent(el.duration),
|
|
130
|
+
minWidth: CLIP_MIN_WIDTH_PX,
|
|
116
131
|
top: clipY,
|
|
117
132
|
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
|
|
118
133
|
borderRadius: isAudioClip ? theme.audioClipRadius : theme.clipRadius,
|
|
119
134
|
...themeVariables,
|
|
120
135
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
121
|
-
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
|
122
136
|
cursor: "default",
|
|
123
137
|
appearance: "none",
|
|
124
138
|
color: "inherit",
|
|
@@ -129,6 +129,17 @@ describe("TimelineClipFades", () => {
|
|
|
129
129
|
act(() => root.unmount());
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it("measures the clip only while its handles show, so a zoom step forces no layout", () => {
|
|
133
|
+
const measure = vi.spyOn(window, "getComputedStyle");
|
|
134
|
+
const { rerender } = render(clip, { showHandles: false });
|
|
135
|
+
rerender({ ...clip, duration: 11 });
|
|
136
|
+
expect(measure).not.toHaveBeenCalled();
|
|
137
|
+
act(() => mounted[0]?.unmount());
|
|
138
|
+
render(clip, { showHandles: true });
|
|
139
|
+
expect(measure).toHaveBeenCalled();
|
|
140
|
+
measure.mockRestore();
|
|
141
|
+
});
|
|
142
|
+
|
|
132
143
|
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
133
144
|
const { host, root } = render(
|
|
134
145
|
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
@@ -139,10 +139,12 @@ export function TimelineClipFades({
|
|
|
139
139
|
radius: 0,
|
|
140
140
|
toolsLeft: null,
|
|
141
141
|
});
|
|
142
|
+
const handlesVisible = showHandles || dragging !== null || focused !== null;
|
|
143
|
+
// Measured only while the handles show: a zoom step must not force a layout per clip.
|
|
142
144
|
useLayoutEffect(() => {
|
|
143
145
|
const root = rootRef.current;
|
|
144
146
|
const clip = root?.parentElement;
|
|
145
|
-
if (!root || !clip) return;
|
|
147
|
+
if (!handlesVisible || !root || !clip) return;
|
|
146
148
|
const radius = parseFloat(getComputedStyle(clip).borderTopLeftRadius) || 0;
|
|
147
149
|
const { clientHeight: height, clientWidth: width } = root;
|
|
148
150
|
const fx = clip.querySelector('[data-badge="fx"]');
|
|
@@ -157,7 +159,7 @@ export function TimelineClipFades({
|
|
|
157
159
|
? box
|
|
158
160
|
: { height, width, radius, toolsLeft },
|
|
159
161
|
);
|
|
160
|
-
}, [widthPx,
|
|
162
|
+
}, [widthPx, handlesVisible]);
|
|
161
163
|
|
|
162
164
|
const gesture = useRef<{
|
|
163
165
|
edge: FadeEdge;
|
|
@@ -205,7 +207,6 @@ export function TimelineClipFades({
|
|
|
205
207
|
const g = gesture.current;
|
|
206
208
|
if (!g) return 0;
|
|
207
209
|
const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
|
|
208
|
-
// Fade-in grows to the right, fade-out grows to the left.
|
|
209
210
|
const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
|
|
210
211
|
const limit = dragLimit(g);
|
|
211
212
|
const clamped = Math.min(limit, Math.max(0, raw));
|
|
@@ -342,7 +343,6 @@ export function TimelineClipFades({
|
|
|
342
343
|
|
|
343
344
|
const showIn = fades.fadeIn > 0;
|
|
344
345
|
const showOut = fades.fadeOut > 0;
|
|
345
|
-
const handlesVisible = showHandles || dragging !== null || focused !== null;
|
|
346
346
|
if (!visible) return null;
|
|
347
347
|
|
|
348
348
|
// A handle with no 0.01 s step to move is not drawn, unless in use: its twin owns the spot.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CLIP_Y } from "./timelineLayout";
|
|
2
2
|
import { sortedLaneClips, TRACK_GAP_EPSILON_S } from "./timelineGaps";
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { TimelineTimeLayer, timeLayerPercent } from "./TimelineTimeLayer";
|
|
4
5
|
|
|
5
6
|
/** Times where one clip on a row ends exactly where the next begins. */
|
|
6
7
|
export function deriveTimelineClipJoins(elements: readonly TimelineElement[]): number[] {
|
|
@@ -31,22 +32,26 @@ export function TimelineClipJoins({
|
|
|
31
32
|
color,
|
|
32
33
|
}: TimelineClipJoinsProps) {
|
|
33
34
|
const height = clipBarHeight ?? rowHeight - 2 * CLIP_Y;
|
|
34
|
-
return
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
return (
|
|
36
|
+
<TimelineTimeLayer pixelsPerSecond={pixelsPerSecond}>
|
|
37
|
+
{deriveTimelineClipJoins(elements).map((time) => (
|
|
38
|
+
<div
|
|
39
|
+
key={time}
|
|
40
|
+
data-timeline-clip-join=""
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
className="absolute pointer-events-none"
|
|
43
|
+
style={{
|
|
44
|
+
left: timeLayerPercent(time),
|
|
45
|
+
top: CLIP_Y,
|
|
46
|
+
width: 1,
|
|
47
|
+
height,
|
|
48
|
+
transform: "translateX(-50%)",
|
|
49
|
+
background: color,
|
|
50
|
+
// Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
|
|
51
|
+
zIndex: 5,
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
))}
|
|
55
|
+
</TimelineTimeLayer>
|
|
56
|
+
);
|
|
52
57
|
}
|
|
@@ -10,6 +10,7 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
|
10
10
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
11
11
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
12
12
|
import type { TimelineProps } from "./TimelineTypes";
|
|
13
|
+
import { TimelineTimeLayer } from "./TimelineTimeLayer";
|
|
13
14
|
|
|
14
15
|
interface TimelineGestureOverlayProps {
|
|
15
16
|
drag: DraggedClipState | null;
|
|
@@ -103,33 +104,35 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
|
|
|
103
104
|
+ New track<span className="sr-only">, before track {insertRow + 1}</span>
|
|
104
105
|
</span>
|
|
105
106
|
)}
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
107
|
+
<TimelineTimeLayer pixelsPerSecond={pixelsPerSecond}>
|
|
108
|
+
<TimelineClip
|
|
109
|
+
el={{ ...element, start: 0 }}
|
|
110
|
+
pps={pixelsPerSecond}
|
|
111
|
+
clipY={0}
|
|
112
|
+
isSelected={selectedElementId === (element.key ?? element.id)}
|
|
113
|
+
isHovered={false}
|
|
114
|
+
isDragging
|
|
115
|
+
isGestureActor
|
|
116
|
+
isActive={isTimelineClipActive(element, currentTime)}
|
|
117
|
+
hasCustomContent={!!renderClipContent}
|
|
118
|
+
capabilities={getTimelineEditCapabilities(element)}
|
|
119
|
+
theme={theme}
|
|
120
|
+
isComposition={!!element.compositionSrc}
|
|
121
|
+
onHoverStart={() => {}}
|
|
122
|
+
onHoverEnd={() => {}}
|
|
123
|
+
onResizeStart={() => {}}
|
|
124
|
+
onClick={() => {}}
|
|
125
|
+
onDoubleClick={() => {}}
|
|
126
|
+
>
|
|
127
|
+
{renderClipChildren(
|
|
128
|
+
element,
|
|
129
|
+
getTrackStyle(element.tag),
|
|
130
|
+
renderClipContent,
|
|
131
|
+
renderClipOverlay,
|
|
132
|
+
{ priority: "interaction", rich: false },
|
|
133
|
+
)}
|
|
134
|
+
</TimelineClip>
|
|
135
|
+
</TimelineTimeLayer>
|
|
133
136
|
</div>
|
|
134
137
|
)}
|
|
135
138
|
</div>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { memo, type CSSProperties, type RefObject } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { ThumbnailPriority } from "../lib/thumbnailScheduler";
|
|
4
|
+
import { TimelineClip, clipZoomKey } from "./TimelineClip";
|
|
5
|
+
import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
|
|
6
|
+
import { renderClipChildren } from "./timelineClipChildren";
|
|
7
|
+
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
|
|
8
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
9
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
10
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
11
|
+
import type { TimelineProps } from "./TimelineTypes";
|
|
12
|
+
|
|
13
|
+
/** The lane's callbacks as of its last render, read when a clip is pressed rather than drawn. */
|
|
14
|
+
export interface TimelineLaneClipActions {
|
|
15
|
+
gestures: ClipGestureDeps;
|
|
16
|
+
setHoveredClip(key: string | null): void;
|
|
17
|
+
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
18
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TimelineLaneClipProps {
|
|
22
|
+
el: TimelineElement;
|
|
23
|
+
previewElement: TimelineElement;
|
|
24
|
+
elementKey: string;
|
|
25
|
+
capabilities: TimelineEditCapabilities;
|
|
26
|
+
pps: number;
|
|
27
|
+
passengerStyle?: CSSProperties;
|
|
28
|
+
clipBarHeight?: number;
|
|
29
|
+
isSelected: boolean;
|
|
30
|
+
isHovered: boolean;
|
|
31
|
+
isActive: boolean;
|
|
32
|
+
tabIndex: 0 | -1;
|
|
33
|
+
priority: ThumbnailPriority;
|
|
34
|
+
rich: boolean;
|
|
35
|
+
theme: TimelineTheme;
|
|
36
|
+
clipStyle: TrackVisualStyle;
|
|
37
|
+
renderClipContent: TimelineProps["renderClipContent"];
|
|
38
|
+
renderClipOverlay: TimelineProps["renderClipOverlay"];
|
|
39
|
+
actions: RefObject<TimelineLaneClipActions>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sameCapabilities = (a: TimelineEditCapabilities, b: TimelineEditCapabilities) =>
|
|
43
|
+
a.canMove === b.canMove &&
|
|
44
|
+
a.canTrimStart === b.canTrimStart &&
|
|
45
|
+
a.canTrimEnd === b.canTrimEnd &&
|
|
46
|
+
a.readOnly === b.readOnly;
|
|
47
|
+
|
|
48
|
+
const zoomKey = (p: TimelineLaneClipProps) =>
|
|
49
|
+
clipZoomKey(p.previewElement, p.pps, p.isSelected || p.isHovered);
|
|
50
|
+
|
|
51
|
+
/** A zoom step re-renders only the clips whose drawing it changes. */
|
|
52
|
+
function sameClip(prev: TimelineLaneClipProps, next: TimelineLaneClipProps): boolean {
|
|
53
|
+
for (const key of Object.keys(next) as (keyof TimelineLaneClipProps)[]) {
|
|
54
|
+
if (key === "pps" || key === "capabilities" || prev[key] === next[key]) continue;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return sameCapabilities(prev.capabilities, next.capabilities) && zoomKey(prev) === zoomKey(next);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One clip bar in a lane; its handlers read the lane's current callbacks when pressed. */
|
|
61
|
+
export const TimelineLaneClip = memo(function TimelineLaneClip({
|
|
62
|
+
el,
|
|
63
|
+
previewElement,
|
|
64
|
+
elementKey,
|
|
65
|
+
capabilities,
|
|
66
|
+
pps,
|
|
67
|
+
passengerStyle,
|
|
68
|
+
clipBarHeight,
|
|
69
|
+
isSelected,
|
|
70
|
+
isHovered,
|
|
71
|
+
isActive,
|
|
72
|
+
tabIndex,
|
|
73
|
+
priority,
|
|
74
|
+
rich,
|
|
75
|
+
theme,
|
|
76
|
+
clipStyle,
|
|
77
|
+
renderClipContent,
|
|
78
|
+
renderClipOverlay,
|
|
79
|
+
actions,
|
|
80
|
+
}: TimelineLaneClipProps) {
|
|
81
|
+
const gestures = () =>
|
|
82
|
+
createClipGestureHandlers(
|
|
83
|
+
el,
|
|
84
|
+
elementKey,
|
|
85
|
+
previewElement,
|
|
86
|
+
capabilities,
|
|
87
|
+
actions.current.gestures,
|
|
88
|
+
);
|
|
89
|
+
const isComposition = !!el.compositionSrc;
|
|
90
|
+
return (
|
|
91
|
+
<TimelineClip
|
|
92
|
+
onContextMenu={(e: React.MouseEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
actions.current.onContextMenuClip?.(e, el);
|
|
95
|
+
}}
|
|
96
|
+
el={previewElement}
|
|
97
|
+
pps={pps}
|
|
98
|
+
passengerStyle={passengerStyle}
|
|
99
|
+
clipY={CLIP_Y}
|
|
100
|
+
clipHeight={clipBarHeight}
|
|
101
|
+
isSelected={isSelected}
|
|
102
|
+
isHovered={isHovered}
|
|
103
|
+
isDragging={false}
|
|
104
|
+
isActive={isActive}
|
|
105
|
+
hasCustomContent={!!renderClipContent}
|
|
106
|
+
capabilities={capabilities}
|
|
107
|
+
theme={theme}
|
|
108
|
+
isComposition={isComposition}
|
|
109
|
+
tabIndex={tabIndex}
|
|
110
|
+
onHoverStart={() => actions.current.setHoveredClip(elementKey)}
|
|
111
|
+
onHoverEnd={() => actions.current.setHoveredClip(null)}
|
|
112
|
+
onResizeStart={(edge, e) => gestures().onResizeStart(edge, e)}
|
|
113
|
+
onPointerDown={(e) => gestures().onPointerDown(e)}
|
|
114
|
+
onClick={(e) => gestures().onClick(e)}
|
|
115
|
+
onDoubleClick={(e) => {
|
|
116
|
+
e.stopPropagation();
|
|
117
|
+
if (actions.current.gestures.suppressClickRef.current) return;
|
|
118
|
+
if (isComposition) actions.current.onDrillDown?.(el);
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{renderClipChildren(previewElement, clipStyle, renderClipContent, renderClipOverlay, {
|
|
122
|
+
priority,
|
|
123
|
+
rich,
|
|
124
|
+
})}
|
|
125
|
+
</TimelineClip>
|
|
126
|
+
);
|
|
127
|
+
}, sameClip);
|