@hyperframes/studio 0.6.97 → 0.6.98
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-DgsMQSvV.js +418 -0
- package/dist/assets/index-B62bDCQv.css +1 -0
- package/dist/assets/index-Ce3pBm_I.js +252 -0
- package/dist/assets/{index-HveJ0MuV.js → index-D-ET9M0b.js} +1 -1
- package/dist/assets/index-D-bS9Dxx.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +7 -5
- package/src/App.tsx +182 -177
- package/src/captions/store.ts +11 -11
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -2
- package/src/components/StudioPreviewArea.tsx +225 -183
- package/src/components/StudioRightPanel.tsx +3 -3
- package/src/components/TimelineToolbar.tsx +25 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -5
- package/src/components/editor/EaseCurveSection.tsx +2 -3
- package/src/components/editor/GestureTrailOverlay.tsx +4 -3
- package/src/components/editor/LayersPanel.tsx +3 -9
- package/src/components/editor/PropertyPanel.tsx +20 -61
- package/src/components/editor/colorValue.ts +3 -1
- package/src/components/editor/domEditOverlayGestures.ts +54 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/gradientValue.ts +3 -3
- package/src/components/editor/keyframeMove.test.ts +101 -0
- package/src/components/editor/keyframeMove.ts +151 -0
- package/src/components/editor/manualEditsDom.ts +0 -12
- package/src/components/editor/propertyPanelHelpers.ts +10 -38
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
- package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
- package/src/components/editor/studioMotionOps.test.ts +1 -1
- package/src/components/editor/studioMotionOps.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
- package/src/components/nle/NLELayout.tsx +1 -24
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +134 -31
- package/src/contexts/StudioContext.tsx +90 -40
- package/src/contexts/TimelineEditContext.tsx +47 -0
- package/src/hooks/domEditCommitTypes.ts +14 -0
- package/src/hooks/gsapDragCommit.ts +9 -24
- package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
- package/src/hooks/gsapKeyframeCommit.ts +5 -15
- package/src/hooks/gsapRuntimeBridge.ts +18 -52
- package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
- package/src/hooks/gsapRuntimeReaders.ts +19 -26
- package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
- package/src/hooks/gsapScriptCommitTypes.ts +58 -0
- package/src/hooks/gsapShared.ts +157 -0
- package/src/hooks/timelineEditingHelpers.ts +63 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
- package/src/hooks/useAppHotkeys.ts +299 -377
- package/src/hooks/useConsoleErrorCapture.ts +33 -5
- package/src/hooks/useDomEditCommits.ts +35 -293
- package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
- package/src/hooks/useDomEditSession.ts +78 -249
- package/src/hooks/useDomEditTextCommits.ts +1 -1
- package/src/hooks/useDomEditWiring.ts +255 -0
- package/src/hooks/useDomGeometryCommits.ts +181 -0
- package/src/hooks/useDomSelection.ts +10 -27
- package/src/hooks/useEditorSave.ts +82 -0
- package/src/hooks/useElementLifecycleOps.ts +177 -0
- package/src/hooks/useEnableKeyframes.ts +10 -15
- package/src/hooks/useFileManager.ts +32 -114
- package/src/hooks/useFileTree.ts +80 -0
- package/src/hooks/useGestureCommit.ts +7 -5
- package/src/hooks/useGestureRecording.ts +1 -1
- package/src/hooks/useGsapAnimationOps.ts +122 -0
- package/src/hooks/useGsapArcPathOps.ts +61 -0
- package/src/hooks/useGsapAwareEditing.ts +242 -0
- package/src/hooks/useGsapKeyframeOps.ts +167 -0
- package/src/hooks/useGsapPropertyDebounce.ts +135 -0
- package/src/hooks/useGsapScriptCommits.ts +58 -570
- package/src/hooks/useGsapSelectionHandlers.ts +22 -9
- package/src/hooks/useGsapTweenCache.ts +35 -29
- package/src/hooks/useLintModal.ts +7 -0
- package/src/hooks/useMusicBeatAnalysis.ts +152 -0
- package/src/hooks/useRazorSplit.ts +1 -1
- package/src/hooks/useRenderClipContent.ts +46 -21
- package/src/hooks/useTimelineEditing.ts +48 -4
- package/src/player/components/AudioWaveform.tsx +29 -4
- package/src/player/components/BeatStrip.tsx +166 -0
- package/src/player/components/Timeline.tsx +39 -18
- package/src/player/components/TimelineCanvas.tsx +52 -12
- package/src/player/components/TimelineClipDiamonds.tsx +130 -20
- package/src/player/components/TimelinePropertyRows.tsx +8 -2
- package/src/player/components/TimelineRuler.tsx +36 -2
- package/src/player/components/timelineEditing.ts +30 -5
- package/src/player/components/useTimelineClipDrag.ts +155 -4
- package/src/player/components/useTimelinePlayhead.ts +30 -1
- package/src/player/hooks/useTimelinePlayer.ts +47 -45
- package/src/player/lib/mediaProbe.ts +46 -3
- package/src/player/lib/playbackScrub.ts +16 -0
- package/src/player/lib/timelineDOM.ts +10 -2
- package/src/player/lib/timelineIframeHelpers.ts +89 -0
- package/src/player/store/playerStore.ts +92 -33
- package/src/utils/beatEditActions.ts +109 -0
- package/src/utils/beatEditing.ts +136 -0
- package/src/utils/clipboardPayload.ts +3 -2
- package/src/utils/compositionPatterns.ts +2 -0
- package/src/utils/keyframeSelection.test.ts +45 -0
- package/src/utils/keyframeSelection.ts +29 -0
- package/src/utils/rounding.ts +9 -0
- package/src/utils/studioHelpers.ts +5 -2
- package/src/utils/studioUrlState.ts +2 -1
- package/src/utils/timelineAssetDrop.ts +6 -5
- package/src/utils/timelineInspector.ts +15 -100
- package/dist/assets/hyperframes-player-Daj5djxa.js +0 -418
- package/dist/assets/index-B0twsRu0.css +0 -1
- package/dist/assets/index-Cfye9xzo.js +0 -251
- package/src/components/editor/DopesheetStrip.tsx +0 -141
- package/src/components/editor/StaggerControls.tsx +0 -61
- package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
- package/src/components/editor/TimelineLayerPanel.tsx +0 -15
- package/src/components/nle/TimelineEditorNotice.tsx +0 -133
- package/src/hooks/gsapRuntimePreview.ts +0 -19
- package/src/player/components/timelineUtils.ts +0 -211
- package/src/utils/audioBeatDetection.ts +0 -58
- package/src/utils/keyframeSnapping.test.ts +0 -74
- package/src/utils/keyframeSnapping.ts +0 -63
- package/src/utils/timelineInspector.test.ts +0 -79
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { memo, useRef, useState } from "react";
|
|
2
|
+
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
|
|
3
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
4
|
+
import { CLIP_Y } from "./timelineLayout";
|
|
5
|
+
|
|
6
|
+
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
7
|
+
const BEAT_HIT_W = 12; // grab width per beat (px)
|
|
8
|
+
|
|
9
|
+
/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
|
|
10
|
+
function beatsTooDense(beatTimes: number[], pps: number): boolean {
|
|
11
|
+
if (beatTimes.length < 2) return true;
|
|
12
|
+
const avgInterval = (beatTimes[beatTimes.length - 1]! - beatTimes[0]!) / (beatTimes.length - 1);
|
|
13
|
+
return avgInterval * pps < 5;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Faint full-height beat lines painted into a track lane's background. Rendered
|
|
18
|
+
* behind the clips so they only show through the empty track area (the dots in
|
|
19
|
+
* BeatStrip mark beats on the clips themselves). Brightness scales with beat
|
|
20
|
+
* loudness. Drawn on every track lane for a global beat grid.
|
|
21
|
+
*/
|
|
22
|
+
export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
|
23
|
+
beatTimes,
|
|
24
|
+
beatStrengths,
|
|
25
|
+
pps,
|
|
26
|
+
highlightTime,
|
|
27
|
+
}: {
|
|
28
|
+
beatTimes: number[] | undefined;
|
|
29
|
+
beatStrengths: number[] | undefined;
|
|
30
|
+
pps: number;
|
|
31
|
+
/** Beat time a dragged clip will snap to — drawn as a bright neon line. */
|
|
32
|
+
highlightTime?: number | null;
|
|
33
|
+
}) {
|
|
34
|
+
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
35
|
+
return (
|
|
36
|
+
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
|
|
37
|
+
{beatTimes.map((t, i) => {
|
|
38
|
+
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
|
|
39
|
+
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
|
40
|
+
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
key={`${t}-${i}`}
|
|
44
|
+
className="absolute top-0 bottom-0"
|
|
45
|
+
style={{
|
|
46
|
+
left: t * pps,
|
|
47
|
+
width: isHighlight ? 2 : 1,
|
|
48
|
+
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
|
|
49
|
+
boxShadow: isHighlight ? "0 0 6px rgba(34,197,94,0.9)" : undefined,
|
|
50
|
+
zIndex: isHighlight ? 1 : undefined,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Green beat dots on the music track's row. Drag a dot to move its beat,
|
|
61
|
+
* double-click to delete; both scrub the audio. Dot size/brightness scale with
|
|
62
|
+
* beat loudness (gamma-curved for contrast).
|
|
63
|
+
*/
|
|
64
|
+
export const BeatStrip = memo(function BeatStrip({
|
|
65
|
+
beatTimes,
|
|
66
|
+
beatStrengths,
|
|
67
|
+
pps,
|
|
68
|
+
}: {
|
|
69
|
+
beatTimes: number[] | undefined;
|
|
70
|
+
beatStrengths: number[] | undefined;
|
|
71
|
+
pps: number;
|
|
72
|
+
}) {
|
|
73
|
+
// Active drag: which beat and how far (px) it's been dragged.
|
|
74
|
+
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
|
|
75
|
+
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
|
|
76
|
+
|
|
77
|
+
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
78
|
+
const cy = BEAT_BAND_H / 2;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="absolute left-0 right-0 pointer-events-none"
|
|
83
|
+
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
|
|
84
|
+
>
|
|
85
|
+
{beatTimes.map((t, i) => {
|
|
86
|
+
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
|
|
87
|
+
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
|
|
88
|
+
const r = 1.5 + strength * 2.5;
|
|
89
|
+
const opacity = 0.25 + strength * 0.75;
|
|
90
|
+
const dxPx = drag?.index === i ? drag.dx : 0;
|
|
91
|
+
const x = t * pps + dxPx;
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
key={`${t}-${i}`}
|
|
95
|
+
className="absolute select-none"
|
|
96
|
+
title="Drag to move · double-click to delete"
|
|
97
|
+
draggable={false}
|
|
98
|
+
style={{
|
|
99
|
+
left: x - BEAT_HIT_W / 2,
|
|
100
|
+
top: 0,
|
|
101
|
+
width: BEAT_HIT_W,
|
|
102
|
+
height: BEAT_BAND_H,
|
|
103
|
+
cursor: "ew-resize",
|
|
104
|
+
pointerEvents: "auto",
|
|
105
|
+
touchAction: "none",
|
|
106
|
+
}}
|
|
107
|
+
onPointerDown={(e) => {
|
|
108
|
+
// preventDefault stops the browser starting a native text/drag
|
|
109
|
+
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
e.stopPropagation();
|
|
112
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
113
|
+
dragRef.current = { index: i, startX: e.clientX, origTime: t };
|
|
114
|
+
setDrag({ index: i, dx: 0 });
|
|
115
|
+
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
|
|
116
|
+
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
|
|
117
|
+
}}
|
|
118
|
+
onPointerMove={(e) => {
|
|
119
|
+
const d = dragRef.current;
|
|
120
|
+
if (!d || d.index !== i) return;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const dx = e.clientX - d.startX;
|
|
123
|
+
setDrag({ index: i, dx });
|
|
124
|
+
// Scrub the audio (and move the playhead) to follow the dragged beat.
|
|
125
|
+
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
|
|
126
|
+
}}
|
|
127
|
+
onPointerUp={(e) => {
|
|
128
|
+
const d = dragRef.current;
|
|
129
|
+
dragRef.current = null;
|
|
130
|
+
setDrag(null);
|
|
131
|
+
usePlayerStore.getState().setBeatDragging(false);
|
|
132
|
+
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
|
|
133
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
134
|
+
}
|
|
135
|
+
if (!d || d.index !== i) return;
|
|
136
|
+
const dx = e.clientX - d.startX;
|
|
137
|
+
if (Math.abs(dx) > 2) {
|
|
138
|
+
const newTime = Math.max(0, d.origTime + dx / pps);
|
|
139
|
+
moveBeatCompositionTime(d.origTime, newTime);
|
|
140
|
+
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
|
|
141
|
+
}
|
|
142
|
+
}}
|
|
143
|
+
onDoubleClick={(e) => {
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
deleteBeatAtCompositionTime(t);
|
|
146
|
+
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
className="absolute"
|
|
151
|
+
style={{
|
|
152
|
+
left: BEAT_HIT_W / 2 - r,
|
|
153
|
+
top: cy - r,
|
|
154
|
+
width: r * 2,
|
|
155
|
+
height: r * 2,
|
|
156
|
+
borderRadius: "50%",
|
|
157
|
+
background: `rgba(34,197,94,${opacity.toFixed(3)})`,
|
|
158
|
+
pointerEvents: "none",
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
|
|
2
|
+
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
|
+
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
4
|
+
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
|
|
2
5
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
6
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
7
|
import { EditPopover } from "./EditModal";
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
type KeyframeDiamondContextMenuState,
|
|
17
20
|
} from "./KeyframeDiamondContextMenu";
|
|
18
21
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
22
|
+
import { snapKeyframePctToBeat } from "./timelineEditing";
|
|
19
23
|
import { ClipContextMenu } from "./ClipContextMenu";
|
|
20
24
|
import {
|
|
21
25
|
GUTTER,
|
|
@@ -23,7 +27,8 @@ import {
|
|
|
23
27
|
getTimelineCanvasHeight,
|
|
24
28
|
shouldShowTimelineShortcutHint,
|
|
25
29
|
} from "./timelineLayout";
|
|
26
|
-
import type {
|
|
30
|
+
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
31
|
+
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
27
32
|
|
|
28
33
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
29
34
|
export {
|
|
@@ -40,7 +45,7 @@ export {
|
|
|
40
45
|
getDefaultDroppedTrack,
|
|
41
46
|
} from "./timelineLayout";
|
|
42
47
|
|
|
43
|
-
interface TimelineProps extends
|
|
48
|
+
interface TimelineProps extends TimelineDropCallbacks {
|
|
44
49
|
onSeek?: (time: number) => void;
|
|
45
50
|
onDrillDown?: (element: TimelineElement) => void;
|
|
46
51
|
renderClipContent?: (
|
|
@@ -62,22 +67,32 @@ export const Timeline = memo(function Timeline({
|
|
|
62
67
|
onAssetDrop,
|
|
63
68
|
onBlockDrop,
|
|
64
69
|
onDeleteElement: _onDeleteElement,
|
|
65
|
-
onMoveElement,
|
|
66
|
-
onResizeElement,
|
|
67
|
-
onBlockedEditAttempt,
|
|
68
|
-
onSplitElement,
|
|
69
|
-
onRazorSplit,
|
|
70
|
-
onRazorSplitAll,
|
|
71
70
|
onSelectElement,
|
|
72
|
-
onDeleteKeyframe,
|
|
73
|
-
onDeleteAllKeyframes,
|
|
74
|
-
onChangeKeyframeEase,
|
|
75
|
-
onMoveKeyframe,
|
|
76
|
-
onToggleKeyframeAtPlayhead,
|
|
77
71
|
theme: themeOverrides,
|
|
78
72
|
}: TimelineProps = {}) {
|
|
73
|
+
const {
|
|
74
|
+
onMoveElement,
|
|
75
|
+
onResizeElement,
|
|
76
|
+
onBlockedEditAttempt,
|
|
77
|
+
onSplitElement,
|
|
78
|
+
onRazorSplitAll,
|
|
79
|
+
onDeleteKeyframe,
|
|
80
|
+
onDeleteAllKeyframes,
|
|
81
|
+
onChangeKeyframeEase,
|
|
82
|
+
onMoveKeyframe,
|
|
83
|
+
} = useTimelineEditContext();
|
|
79
84
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
85
|
+
useMusicBeatAnalysis();
|
|
80
86
|
const elements = usePlayerStore((s) => s.elements);
|
|
87
|
+
const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
|
|
88
|
+
const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null);
|
|
89
|
+
|
|
90
|
+
// Merge user edits + remap beats from audio-file → composition coordinates.
|
|
91
|
+
const beatEdits = usePlayerStore((s) => s.beatEdits);
|
|
92
|
+
const adjustedBeatAnalysis = useMemo(
|
|
93
|
+
() => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
|
|
94
|
+
[beatAnalysis, musicElement, beatEdits],
|
|
95
|
+
);
|
|
81
96
|
const duration = usePlayerStore((s) => s.duration);
|
|
82
97
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
83
98
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
@@ -423,8 +438,6 @@ export const Timeline = memo(function Timeline({
|
|
|
423
438
|
renderClipContent={renderClipContent}
|
|
424
439
|
renderClipOverlay={renderClipOverlay}
|
|
425
440
|
playheadRef={playheadRef}
|
|
426
|
-
onResizeElement={onResizeElement}
|
|
427
|
-
onMoveElement={onMoveElement}
|
|
428
441
|
onDrillDown={onDrillDown}
|
|
429
442
|
onSelectElement={onSelectElement}
|
|
430
443
|
setHoveredClip={setHoveredClip}
|
|
@@ -440,7 +453,7 @@ export const Timeline = memo(function Timeline({
|
|
|
440
453
|
keyframeCache={keyframeCache}
|
|
441
454
|
selectedKeyframes={selectedKeyframes}
|
|
442
455
|
currentTime={currentTime}
|
|
443
|
-
|
|
456
|
+
beatAnalysis={adjustedBeatAnalysis}
|
|
444
457
|
onClickKeyframe={(el, pct) => {
|
|
445
458
|
usePlayerStore.getState().clearSelectedKeyframes();
|
|
446
459
|
const elKey = el.key ?? el.id;
|
|
@@ -458,6 +471,16 @@ export const Timeline = memo(function Timeline({
|
|
|
458
471
|
onDragKeyframe={(el, oldPct, newPct) => {
|
|
459
472
|
onMoveKeyframe?.(el, oldPct, newPct);
|
|
460
473
|
}}
|
|
474
|
+
onSnapKeyframePct={(el, pct) =>
|
|
475
|
+
snapKeyframePctToBeat(el, pct, adjustedBeatAnalysis?.beatTimes, pps)
|
|
476
|
+
}
|
|
477
|
+
onPickKeyframeElement={(el) => {
|
|
478
|
+
const elKey = el.key ?? el.id;
|
|
479
|
+
if (selectedElementId !== elKey) {
|
|
480
|
+
setSelectedElementId(elKey);
|
|
481
|
+
onSelectElement?.(el);
|
|
482
|
+
}
|
|
483
|
+
}}
|
|
461
484
|
onContextMenuKeyframe={(e, elId, pct) => {
|
|
462
485
|
const el = elements.find((x) => (x.key ?? x.id) === elId);
|
|
463
486
|
if (el) {
|
|
@@ -483,8 +506,6 @@ export const Timeline = memo(function Timeline({
|
|
|
483
506
|
onSelectElement?.(el);
|
|
484
507
|
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
|
|
485
508
|
}}
|
|
486
|
-
onRazorSplit={onRazorSplit}
|
|
487
|
-
onRazorSplitAll={onRazorSplitAll}
|
|
488
509
|
/>
|
|
489
510
|
{activeTool === "razor" && razorGuideX !== null && (
|
|
490
511
|
<div
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { memo, type ReactNode } from "react";
|
|
2
|
+
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
2
3
|
import { TimelineClip } from "./TimelineClip";
|
|
3
4
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
4
5
|
import { TimelineRuler } from "./TimelineRuler";
|
|
6
|
+
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
5
7
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
6
8
|
import {
|
|
7
9
|
getTimelineEditCapabilities,
|
|
@@ -19,6 +21,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us
|
|
|
19
21
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
20
22
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
21
23
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
24
|
+
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
25
|
+
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
22
26
|
|
|
23
27
|
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
|
|
24
28
|
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
@@ -66,8 +70,6 @@ interface TimelineCanvasProps {
|
|
|
66
70
|
) => ReactNode;
|
|
67
71
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
68
72
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
69
|
-
onResizeElement?: unknown;
|
|
70
|
-
onMoveElement?: unknown;
|
|
71
73
|
onDrillDown?: (element: TimelineElement) => void;
|
|
72
74
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
73
75
|
setHoveredClip: (key: string | null) => void;
|
|
@@ -90,11 +92,13 @@ interface TimelineCanvasProps {
|
|
|
90
92
|
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
|
91
93
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
92
94
|
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
95
|
+
/** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */
|
|
96
|
+
onSnapKeyframePct?: (element: TimelineElement, pct: number) => number;
|
|
97
|
+
/** Select the element when a keyframe drag starts (loads its GSAP session). */
|
|
98
|
+
onPickKeyframeElement?: (element: TimelineElement) => void;
|
|
93
99
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
94
100
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
95
|
-
|
|
96
|
-
onRazorSplit?: (element: TimelineElement, splitTime: number) => void;
|
|
97
|
-
onRazorSplitAll?: (splitTime: number) => void;
|
|
101
|
+
beatAnalysis?: MusicBeatAnalysis | null;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
export const TimelineCanvas = memo(function TimelineCanvas({
|
|
@@ -122,8 +126,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
122
126
|
renderClipContent,
|
|
123
127
|
renderClipOverlay,
|
|
124
128
|
playheadRef,
|
|
125
|
-
onResizeElement,
|
|
126
|
-
onMoveElement,
|
|
127
129
|
onDrillDown,
|
|
128
130
|
onSelectElement,
|
|
129
131
|
setHoveredClip,
|
|
@@ -142,12 +144,15 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
142
144
|
onClickKeyframe,
|
|
143
145
|
onShiftClickKeyframe,
|
|
144
146
|
onDragKeyframe,
|
|
147
|
+
onSnapKeyframePct,
|
|
148
|
+
onPickKeyframeElement,
|
|
145
149
|
onContextMenuKeyframe,
|
|
146
150
|
onContextMenuClip,
|
|
147
|
-
|
|
148
|
-
onRazorSplit,
|
|
149
|
-
onRazorSplitAll,
|
|
151
|
+
beatAnalysis,
|
|
150
152
|
}: TimelineCanvasProps) {
|
|
153
|
+
const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } =
|
|
154
|
+
useTimelineEditContext();
|
|
155
|
+
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
151
156
|
const draggedElement = draggedClip?.element ?? null;
|
|
152
157
|
const activeDraggedElement =
|
|
153
158
|
draggedClip?.started === true && draggedElement
|
|
@@ -204,6 +209,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
204
209
|
shiftHeld={shiftHeld}
|
|
205
210
|
rangeSelection={rangeSelection}
|
|
206
211
|
theme={theme}
|
|
212
|
+
beatAnalysis={beatAnalysis}
|
|
207
213
|
/>
|
|
208
214
|
|
|
209
215
|
{displayTrackOrder.map((trackNum) => {
|
|
@@ -211,6 +217,14 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
211
217
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
212
218
|
const isPendingTrack =
|
|
213
219
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
220
|
+
// The beat-dot strip occupies the top of this track's lane (active track,
|
|
221
|
+
// or the music track when nothing is selected). When shown, keyframe
|
|
222
|
+
// diamonds shrink + drop to the bottom half so they don't collide with it.
|
|
223
|
+
const beatStripOnTrack =
|
|
224
|
+
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
|
|
225
|
+
(selectedElementId
|
|
226
|
+
? els.some((e) => (e.key ?? e.id) === selectedElementId)
|
|
227
|
+
: els.some(isMusicTrack));
|
|
214
228
|
return (
|
|
215
229
|
<div
|
|
216
230
|
key={trackNum}
|
|
@@ -244,6 +258,23 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
244
258
|
</div>
|
|
245
259
|
</div>
|
|
246
260
|
<div style={{ width: trackContentWidth }} className="relative">
|
|
261
|
+
{/* Faint beat lines in every track's background (behind the clips);
|
|
262
|
+
the active move-snap target is highlighted. */}
|
|
263
|
+
<BeatBackgroundLines
|
|
264
|
+
beatTimes={beatAnalysis?.beatTimes}
|
|
265
|
+
beatStrengths={beatAnalysis?.beatStrengths}
|
|
266
|
+
pps={pps}
|
|
267
|
+
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
|
|
268
|
+
/>
|
|
269
|
+
{/* Beat dots on the active track (the one holding the selection),
|
|
270
|
+
falling back to the music track when nothing is selected. */}
|
|
271
|
+
{beatStripOnTrack && (
|
|
272
|
+
<BeatStrip
|
|
273
|
+
beatTimes={beatAnalysis?.beatTimes}
|
|
274
|
+
beatStrengths={beatAnalysis?.beatStrengths}
|
|
275
|
+
pps={pps}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
247
278
|
{isPendingTrack && (
|
|
248
279
|
<div
|
|
249
280
|
className="absolute inset-0 flex items-center"
|
|
@@ -358,6 +389,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
358
389
|
pointerOffsetY: e.clientY - rect.top,
|
|
359
390
|
previewStart: el.start,
|
|
360
391
|
previewTrack: el.track,
|
|
392
|
+
snapBeatTime: null,
|
|
361
393
|
started: false,
|
|
362
394
|
});
|
|
363
395
|
syncClipDragAutoScroll(e.clientX, e.clientY);
|
|
@@ -402,6 +434,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
402
434
|
keyframesData={keyframeCache.get(elementKey)!}
|
|
403
435
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
404
436
|
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
|
437
|
+
beatsActive={beatStripOnTrack}
|
|
405
438
|
accentColor={clipStyle.accent}
|
|
406
439
|
isSelected={isSelected}
|
|
407
440
|
currentPercentage={
|
|
@@ -416,6 +449,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
416
449
|
onDragKeyframe={(oldPct, newPct) =>
|
|
417
450
|
onDragKeyframe?.(previewElement, oldPct, newPct)
|
|
418
451
|
}
|
|
452
|
+
snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct}
|
|
453
|
+
onPickForDrag={() => onPickKeyframeElement?.(previewElement)}
|
|
419
454
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
420
455
|
/>
|
|
421
456
|
)}
|
|
@@ -479,11 +514,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
479
514
|
/>
|
|
480
515
|
)}
|
|
481
516
|
|
|
482
|
-
{/* Playhead
|
|
517
|
+
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
|
518
|
+
track the scrub and clutter the beat being moved. */}
|
|
483
519
|
<div
|
|
484
520
|
ref={playheadRef}
|
|
485
521
|
className="absolute top-0 bottom-0 pointer-events-none"
|
|
486
|
-
style={{
|
|
522
|
+
style={{
|
|
523
|
+
left: `${GUTTER}px`,
|
|
524
|
+
zIndex: 100,
|
|
525
|
+
display: beatDragging ? "none" : undefined,
|
|
526
|
+
}}
|
|
487
527
|
>
|
|
488
528
|
<PlayheadIndicator />
|
|
489
529
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { memo, useRef } from "react";
|
|
1
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { BEAT_BAND_H } from "./BeatStrip";
|
|
2
3
|
|
|
3
4
|
interface KeyframeEntry {
|
|
4
5
|
percentage: number;
|
|
@@ -17,6 +18,9 @@ interface TimelineClipDiamondsProps {
|
|
|
17
18
|
keyframesData: KeyframeCacheEntry;
|
|
18
19
|
clipWidthPx: number;
|
|
19
20
|
clipHeightPx: number;
|
|
21
|
+
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
|
22
|
+
* the bottom half so they clear the strip at the top. */
|
|
23
|
+
beatsActive?: boolean;
|
|
20
24
|
accentColor: string;
|
|
21
25
|
isSelected: boolean;
|
|
22
26
|
currentPercentage: number;
|
|
@@ -26,14 +30,31 @@ interface TimelineClipDiamondsProps {
|
|
|
26
30
|
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
27
31
|
onDragKeyframe?: (percentage: number, newPercentage: number) => void;
|
|
28
32
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
33
|
+
/** Snap a clip-relative percentage to the nearest beat (returns it unchanged
|
|
34
|
+
* when no beat is within range). Drives live beat-snapping while dragging. */
|
|
35
|
+
snapPct?: (percentage: number) => number;
|
|
36
|
+
/** Select this element when a keyframe drag begins, so its GSAP session is
|
|
37
|
+
* loaded by the time the move commits (diamonds render on unselected clips
|
|
38
|
+
* too, and a drag suppresses the selecting click). */
|
|
39
|
+
onPickForDrag?: () => void;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
const DIAMOND_RATIO = 0.8;
|
|
43
|
+
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
|
44
|
+
// slightly outside [0, 100] (from rounding or stale cache during the async
|
|
45
|
+
// persist → reload cycle) are clamped to the clip edge rather than hidden.
|
|
46
|
+
export const KF_MIN_PCT = -5;
|
|
47
|
+
export const KF_MAX_PCT = 105;
|
|
48
|
+
|
|
49
|
+
function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number {
|
|
50
|
+
return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft));
|
|
51
|
+
}
|
|
32
52
|
|
|
33
53
|
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
34
54
|
keyframesData,
|
|
35
55
|
clipWidthPx,
|
|
36
56
|
clipHeightPx,
|
|
57
|
+
beatsActive,
|
|
37
58
|
accentColor,
|
|
38
59
|
isSelected,
|
|
39
60
|
currentPercentage,
|
|
@@ -43,14 +64,62 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
43
64
|
onShiftClickKeyframe,
|
|
44
65
|
onDragKeyframe,
|
|
45
66
|
onContextMenuKeyframe,
|
|
67
|
+
snapPct,
|
|
68
|
+
onPickForDrag,
|
|
46
69
|
}: TimelineClipDiamondsProps) {
|
|
47
|
-
|
|
70
|
+
// Live drag: which keyframe (by original %) is being dragged and its current
|
|
71
|
+
// (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
|
|
72
|
+
const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
|
|
73
|
+
const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
|
|
74
|
+
// Commit through the latest callback, not the one captured at pointer-down:
|
|
75
|
+
// selecting the element on drag-start loads its GSAP session asynchronously,
|
|
76
|
+
// and the commit must use the closure that sees the loaded session.
|
|
77
|
+
const onDragKeyframeRef = useRef(onDragKeyframe);
|
|
78
|
+
onDragKeyframeRef.current = onDragKeyframe;
|
|
79
|
+
// Optimistic hold: after a commit, keep the diamond at the dropped position
|
|
80
|
+
// until the cache reflects the change (the file round-trip rewrites
|
|
81
|
+
// keyframesData), so it doesn't flash back to the old spot in between.
|
|
82
|
+
const pendingRef = useRef(false);
|
|
83
|
+
const pendingHeldPctRef = useRef<number | null>(null);
|
|
84
|
+
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
|
+
// Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
|
|
86
|
+
// (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
|
|
87
|
+
const dragCleanupRef = useRef<(() => void) | null>(null);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!pendingRef.current) return;
|
|
91
|
+
// Only release the optimistic hold once the cache actually reflects the
|
|
92
|
+
// committed position (a keyframe near the held %). An unrelated cache
|
|
93
|
+
// rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
|
|
94
|
+
// percentages — releasing then would flash the diamond back to the old spot.
|
|
95
|
+
const held = pendingHeldPctRef.current;
|
|
96
|
+
if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
pendingRef.current = false;
|
|
100
|
+
pendingHeldPctRef.current = null;
|
|
101
|
+
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
|
|
102
|
+
setDrag(null);
|
|
103
|
+
}, [keyframesData]);
|
|
104
|
+
|
|
105
|
+
useEffect(
|
|
106
|
+
() => () => {
|
|
107
|
+
clearTimeout(pendingTimerRef.current ?? undefined);
|
|
108
|
+
dragCleanupRef.current?.();
|
|
109
|
+
},
|
|
110
|
+
[],
|
|
111
|
+
);
|
|
48
112
|
|
|
49
113
|
if (clipWidthPx < 20) return null;
|
|
50
114
|
|
|
51
|
-
|
|
115
|
+
// When the beat strip occupies the top band, shrink the diamonds and center
|
|
116
|
+
// them in the remaining bottom region so they don't collide with it.
|
|
117
|
+
const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO));
|
|
52
118
|
const half = diamondSize / 2;
|
|
53
|
-
const
|
|
119
|
+
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
|
120
|
+
const sorted = keyframesData.keyframes
|
|
121
|
+
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
|
122
|
+
.sort((a, b) => a.percentage - b.percentage);
|
|
54
123
|
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
|
55
124
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
56
125
|
|
|
@@ -66,47 +135,84 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
66
135
|
const handlePointerDown = (e: React.PointerEvent, pct: number) => {
|
|
67
136
|
if (e.button !== 0) return;
|
|
68
137
|
e.stopPropagation();
|
|
138
|
+
// Ignore a new drag while a prior drop is still settling: `pct` comes from
|
|
139
|
+
// props (the pre-drop position) but the diamond is held at its dropped spot
|
|
140
|
+
// via effPct(), so a re-grab would track from a stale origin and commit
|
|
141
|
+
// against the wrong tween. The hold clears on the cache round-trip (≤2s).
|
|
142
|
+
if (pendingRef.current) return;
|
|
143
|
+
// Select the element up front so its GSAP session loads during the drag and
|
|
144
|
+
// the commit (which resolves the animation from the selection) isn't a no-op.
|
|
145
|
+
onPickForDrag?.();
|
|
69
146
|
const startX = e.clientX;
|
|
147
|
+
dragRef.current = { origPct: pct, pct, moved: false };
|
|
70
148
|
|
|
71
149
|
const handleMove = (me: PointerEvent) => {
|
|
150
|
+
const d = dragRef.current;
|
|
151
|
+
if (!d) return;
|
|
72
152
|
const dx = me.clientX - startX;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
153
|
+
// 4px dead zone so a click doesn't register as a drag.
|
|
154
|
+
if (!d.moved && Math.abs(dx) <= 4) return;
|
|
155
|
+
d.moved = true;
|
|
156
|
+
const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
|
|
157
|
+
const snapped = snapPct ? snapPct(rawPct) : rawPct;
|
|
158
|
+
d.pct = snapped;
|
|
159
|
+
setDrag({ origPct: pct, pct: snapped });
|
|
76
160
|
};
|
|
77
161
|
|
|
78
|
-
const handleUp = (
|
|
162
|
+
const handleUp = () => {
|
|
79
163
|
document.removeEventListener("pointermove", handleMove);
|
|
80
164
|
document.removeEventListener("pointerup", handleUp);
|
|
81
|
-
|
|
165
|
+
dragCleanupRef.current = null;
|
|
166
|
+
const d = dragRef.current;
|
|
82
167
|
dragRef.current = null;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
168
|
+
const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
|
|
169
|
+
if (willCommit && d) {
|
|
170
|
+
// Hold the dropped position optimistically; the effect clears it once the
|
|
171
|
+
// cache round-trip lands (fallback timeout in case it never does).
|
|
172
|
+
pendingRef.current = true;
|
|
173
|
+
pendingHeldPctRef.current = d.pct;
|
|
174
|
+
setDrag({ origPct: d.origPct, pct: d.pct });
|
|
175
|
+
if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
|
|
176
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
177
|
+
pendingRef.current = false;
|
|
178
|
+
pendingHeldPctRef.current = null;
|
|
179
|
+
setDrag(null);
|
|
180
|
+
}, 2000);
|
|
181
|
+
onDragKeyframeRef.current?.(d.origPct, d.pct);
|
|
182
|
+
} else {
|
|
183
|
+
setDrag(null);
|
|
89
184
|
}
|
|
90
185
|
};
|
|
91
186
|
|
|
187
|
+
dragCleanupRef.current = () => {
|
|
188
|
+
document.removeEventListener("pointermove", handleMove);
|
|
189
|
+
document.removeEventListener("pointerup", handleUp);
|
|
190
|
+
};
|
|
191
|
+
|
|
92
192
|
document.addEventListener("pointermove", handleMove);
|
|
93
193
|
document.addEventListener("pointerup", handleUp);
|
|
94
194
|
};
|
|
95
195
|
|
|
196
|
+
const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
|
|
197
|
+
|
|
96
198
|
return (
|
|
97
199
|
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
|
98
200
|
{sorted.map((kf, i) => {
|
|
99
201
|
if (i === 0) return null;
|
|
100
202
|
const prev = sorted[i - 1]!;
|
|
101
|
-
const x1 = (
|
|
102
|
-
|
|
203
|
+
const x1 = Math.max(
|
|
204
|
+
0,
|
|
205
|
+
Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
|
|
206
|
+
);
|
|
207
|
+
const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
|
|
208
|
+
if (x2 - x1 < 1) return null;
|
|
103
209
|
return (
|
|
104
210
|
<div
|
|
105
211
|
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
|
106
212
|
className="absolute"
|
|
107
213
|
style={{
|
|
108
214
|
left: x1,
|
|
109
|
-
top:
|
|
215
|
+
top: centerY,
|
|
110
216
|
width: x2 - x1,
|
|
111
217
|
height: 2,
|
|
112
218
|
transform: "translateY(-1px)",
|
|
@@ -119,7 +225,11 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
119
225
|
})}
|
|
120
226
|
|
|
121
227
|
{sorted.map((kf, i) => {
|
|
122
|
-
const leftPx = (
|
|
228
|
+
const leftPx = clampDiamondLeft(
|
|
229
|
+
(effPct(kf.percentage) / 100) * clipWidthPx - half,
|
|
230
|
+
diamondSize,
|
|
231
|
+
clipWidthPx,
|
|
232
|
+
);
|
|
123
233
|
const kfKey = `${elementId}:${kf.percentage}`;
|
|
124
234
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
125
235
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
@@ -132,7 +242,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
132
242
|
className="absolute"
|
|
133
243
|
style={{
|
|
134
244
|
left: leftPx,
|
|
135
|
-
top:
|
|
245
|
+
top: centerY,
|
|
136
246
|
transform: "translateY(-50%)",
|
|
137
247
|
width: diamondSize,
|
|
138
248
|
height: diamondSize,
|