@hyperframes/studio 0.7.86 → 0.7.87
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-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6265 -4922
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- package/dist/assets/index-D379YOKT.css +0 -1
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
|
9
9
|
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
10
10
|
import { LANE_H } from "./timelineLayout";
|
|
11
|
+
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
11
12
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
12
13
|
import {
|
|
13
14
|
DIAMOND_RATIO,
|
|
@@ -22,11 +23,10 @@ import {
|
|
|
22
23
|
|
|
23
24
|
export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
|
24
25
|
|
|
25
|
-
// Floor for a diamond's clickable width. The
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// below this width; each diamond still owns the half-gap around its own centre.
|
|
26
|
+
// Floor for a diamond's clickable width. The visible diamond stays full-size
|
|
27
|
+
// even when neighbours overlap; shrinking recorded-gesture keyframes into dots
|
|
28
|
+
// makes the timeline unreadable. The hit box still follows the neighbour gap
|
|
29
|
+
// and stops at this floor so nearby timestamps remain separately targetable.
|
|
30
30
|
//
|
|
31
31
|
// Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
|
|
32
32
|
// own spacing exception: at normal keyframe density the neighbour gap is under
|
|
@@ -40,10 +40,36 @@ function roundPct(percentage: number): number {
|
|
|
40
40
|
return Math.round(percentage * 1000) / 1000;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Find the closest authored keyframe without scanning the full lane on every playhead tick. */
|
|
44
|
+
function nearestKeyframeWithin(
|
|
45
|
+
sorted: TimelineDiamondKeyframe[],
|
|
46
|
+
percentage: number,
|
|
47
|
+
tolerance: number,
|
|
48
|
+
): TimelineDiamondKeyframe | null {
|
|
49
|
+
if (!Number.isFinite(percentage) || tolerance <= 0 || sorted.length === 0) return null;
|
|
50
|
+
let low = 0;
|
|
51
|
+
let high = sorted.length;
|
|
52
|
+
while (low < high) {
|
|
53
|
+
const mid = (low + high) >>> 1;
|
|
54
|
+
if (sorted[mid]!.percentage < percentage) low = mid + 1;
|
|
55
|
+
else high = mid;
|
|
56
|
+
}
|
|
57
|
+
const before = sorted[low - 1];
|
|
58
|
+
const after = sorted[low];
|
|
59
|
+
const nearest =
|
|
60
|
+
before && after
|
|
61
|
+
? percentage - before.percentage <= after.percentage - percentage
|
|
62
|
+
? before
|
|
63
|
+
: after
|
|
64
|
+
: (before ?? after);
|
|
65
|
+
return nearest && Math.abs(nearest.percentage - percentage) <= tolerance ? nearest : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
44
69
|
keyframesData,
|
|
45
70
|
clipWidthPx,
|
|
46
71
|
clipHeightPx,
|
|
72
|
+
clipDuration,
|
|
47
73
|
beatsActive,
|
|
48
74
|
accentColor,
|
|
49
75
|
isSelected,
|
|
@@ -197,14 +223,24 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
197
223
|
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
|
|
198
224
|
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
|
|
199
225
|
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
|
200
|
-
const
|
|
226
|
+
const hitWidth = Math.min(diamondSize, nearestGap);
|
|
201
227
|
return {
|
|
202
228
|
keyframe,
|
|
203
229
|
centerX,
|
|
204
|
-
hitWidth: Math.max(KF_MIN_HIT_W,
|
|
205
|
-
visualSize:
|
|
230
|
+
hitWidth: Math.max(KF_MIN_HIT_W, hitWidth),
|
|
231
|
+
visualSize: diamondSize,
|
|
206
232
|
};
|
|
207
233
|
});
|
|
234
|
+
// The playhead is a cursor, not a selection. Resolve one nearest keyframe per
|
|
235
|
+
// rendered property lane and only while it is within half an output frame.
|
|
236
|
+
// A fixed clip-% tolerance grows with duration and lit whole clusters of
|
|
237
|
+
// gesture-recorded keyframes at once on long clips.
|
|
238
|
+
const halfFramePct =
|
|
239
|
+
clipDuration && clipDuration > 0 ? 50 / STUDIO_PREVIEW_FPS / clipDuration : 0;
|
|
240
|
+
const playheadKeyframe =
|
|
241
|
+
isSelected && halfFramePct > 0
|
|
242
|
+
? nearestKeyframeWithin(sorted, currentPercentage, halfFramePct)
|
|
243
|
+
: null;
|
|
208
244
|
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
|
209
245
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
210
246
|
const canDrag = isSelected && !!onMoveKeyframe;
|
|
@@ -251,9 +287,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
251
287
|
// fully visible instead of being clipped by the sticky label column.
|
|
252
288
|
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
|
|
253
289
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
254
|
-
const atPlayhead =
|
|
290
|
+
const atPlayhead = kf === playheadKeyframe;
|
|
255
291
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
256
|
-
const color =
|
|
292
|
+
const color = isKfSelected ? accentColor : "#a3a3a3";
|
|
257
293
|
|
|
258
294
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
259
295
|
if (e.button !== 0) return;
|
|
@@ -417,6 +453,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
417
453
|
data-keyframe-percentage={
|
|
418
454
|
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
|
419
455
|
}
|
|
456
|
+
data-keyframe-at-playhead={String(atPlayhead)}
|
|
457
|
+
data-keyframe-selected={String(isKfSelected)}
|
|
458
|
+
aria-current={atPlayhead ? "time" : undefined}
|
|
459
|
+
aria-pressed={isKfSelected}
|
|
420
460
|
style={{
|
|
421
461
|
left: leftPx,
|
|
422
462
|
top: centerY,
|
|
@@ -461,19 +501,19 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
461
501
|
viewBox="0 0 10 10"
|
|
462
502
|
style={{ flexShrink: 0, pointerEvents: "none" }}
|
|
463
503
|
>
|
|
464
|
-
{isKfSelected && (
|
|
504
|
+
{(isKfSelected || atPlayhead) && (
|
|
465
505
|
<path
|
|
466
506
|
d="M5 0L10 5L5 10L0 5Z"
|
|
467
507
|
fill="none"
|
|
468
508
|
stroke={accentColor}
|
|
469
509
|
strokeWidth="0.8"
|
|
470
|
-
opacity={0.5}
|
|
510
|
+
opacity={isKfSelected ? 0.5 : 1}
|
|
471
511
|
/>
|
|
472
512
|
)}
|
|
473
513
|
<path
|
|
474
514
|
d="M5 1L9 5L5 9L1 5Z"
|
|
475
515
|
fill={color}
|
|
476
|
-
opacity={isKfSelected
|
|
516
|
+
opacity={isKfSelected ? 1 : atPlayhead ? 0.9 : 0.55}
|
|
477
517
|
/>
|
|
478
518
|
</svg>
|
|
479
519
|
</button>
|
|
@@ -7,7 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
7
7
|
import { TimelineLanes } from "./TimelineLanes";
|
|
8
8
|
import { getTrackStyle } from "./timelineIcons";
|
|
9
9
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
|
-
import { TRACK_H } from "./timelineLayout";
|
|
10
|
+
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
|
|
11
|
+
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
11
12
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
12
13
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
13
14
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
@@ -78,6 +79,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
78
79
|
const laneCounts = new Map(
|
|
79
80
|
elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
|
|
80
81
|
);
|
|
82
|
+
const rowHeights = displayTrackOrder.map(() => TRACK_H);
|
|
81
83
|
act(() => {
|
|
82
84
|
usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
|
|
83
85
|
root.render(
|
|
@@ -88,7 +90,13 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
88
90
|
trackContentWidth={800}
|
|
89
91
|
theme={defaultTimelineTheme}
|
|
90
92
|
displayTrackOrder={displayTrackOrder}
|
|
91
|
-
rowHeights={
|
|
93
|
+
rowHeights={rowHeights}
|
|
94
|
+
rowGeometry={getTimelineRowGeometry(rowHeights)}
|
|
95
|
+
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
|
|
96
|
+
rowsVirtualized={false}
|
|
97
|
+
clipIndex={createTimelineClipIndex(tracks)}
|
|
98
|
+
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
|
|
99
|
+
pinnedClipIdentities={new Set()}
|
|
92
100
|
trackOrder={displayTrackOrder}
|
|
93
101
|
tracks={tracks}
|
|
94
102
|
trackStyles={new Map()}
|
|
@@ -144,6 +152,7 @@ describe("TimelineLanes track numbering", () => {
|
|
|
144
152
|
});
|
|
145
153
|
|
|
146
154
|
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
|
|
155
|
+
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
|
|
147
156
|
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
148
157
|
act(() => view.root.unmount());
|
|
149
158
|
});
|
|
@@ -171,8 +180,10 @@ describe("TimelineLanes track numbering", () => {
|
|
|
171
180
|
onContextMenuLane,
|
|
172
181
|
});
|
|
173
182
|
|
|
174
|
-
// Row children: [sticky header column, time-mapped track content].
|
|
175
|
-
|
|
183
|
+
// Row children: [sticky header column, time-mapped track content]. The rows
|
|
184
|
+
// sit inside the lanes list, which is what carries the virtualization
|
|
185
|
+
// positioning context.
|
|
186
|
+
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
|
|
176
187
|
const secondTrackContent = rows[1]?.children.item(1);
|
|
177
188
|
act(() => {
|
|
178
189
|
secondTrackContent?.dispatchEvent(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useId } from "react";
|
|
1
|
+
import { Fragment, useId } from "react";
|
|
2
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
3
3
|
import { TimelineClip } from "./TimelineClip";
|
|
4
4
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
@@ -8,12 +8,14 @@ import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
|
8
8
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
9
9
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
10
10
|
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
|
11
|
-
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H
|
|
11
|
+
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
|
|
12
12
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
13
13
|
import {
|
|
14
|
+
isMultiDragActive,
|
|
14
15
|
isMultiDragPassenger,
|
|
15
|
-
|
|
16
|
+
multiDragDeltaSeconds,
|
|
16
17
|
type MultiDragPreviewInput,
|
|
18
|
+
multiDragPassengerOffsetPx,
|
|
17
19
|
} from "./timelineMultiDragPreview";
|
|
18
20
|
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
19
21
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
@@ -21,6 +23,10 @@ import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
|
21
23
|
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
22
24
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
23
25
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
26
|
+
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
27
|
+
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
28
|
+
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
29
|
+
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
24
30
|
|
|
25
31
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
26
32
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
@@ -41,7 +47,12 @@ export function TimelineLanes({
|
|
|
41
47
|
trackContentWidth,
|
|
42
48
|
theme,
|
|
43
49
|
displayTrackOrder,
|
|
44
|
-
|
|
50
|
+
rowGeometry,
|
|
51
|
+
virtualRows,
|
|
52
|
+
rowsVirtualized,
|
|
53
|
+
clipIndex,
|
|
54
|
+
renderTimeRange,
|
|
55
|
+
pinnedClipIdentities,
|
|
45
56
|
trackOrder,
|
|
46
57
|
tracks,
|
|
47
58
|
trackStyles,
|
|
@@ -102,19 +113,45 @@ export function TimelineLanes({
|
|
|
102
113
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
103
114
|
toggleClipExpanded(key);
|
|
104
115
|
};
|
|
116
|
+
const multiDragDelta =
|
|
117
|
+
multiDragPreview && isMultiDragActive(multiDragPreview)
|
|
118
|
+
? multiDragDeltaSeconds(multiDragPreview)
|
|
119
|
+
: 0;
|
|
120
|
+
const actorWindows =
|
|
121
|
+
rowsVirtualized && multiDragPreview && multiDragDelta !== 0
|
|
122
|
+
? [
|
|
123
|
+
{
|
|
124
|
+
range: {
|
|
125
|
+
start: renderTimeRange.start - multiDragDelta,
|
|
126
|
+
end: renderTimeRange.end - multiDragDelta,
|
|
127
|
+
},
|
|
128
|
+
identities: multiDragPreview.selectedKeys,
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
: [];
|
|
105
132
|
return (
|
|
106
|
-
|
|
133
|
+
<div
|
|
134
|
+
role="list"
|
|
135
|
+
aria-label="Timeline tracks"
|
|
136
|
+
className={rowsVirtualized ? "absolute inset-0" : undefined}
|
|
137
|
+
>
|
|
107
138
|
{
|
|
108
|
-
// NOTE (deliberate no-virtualization): lanes and their clips render via a
|
|
109
|
-
// plain `.map()` inside the scroll container rather than a windowing/virtualized
|
|
110
|
-
// list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
|
|
111
|
-
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
|
|
112
|
-
// in a virtualizer if editorial workflows ever push very high clip counts.
|
|
113
139
|
// fallow-ignore-next-line complexity
|
|
114
|
-
|
|
140
|
+
virtualRows.map(({ index: row, rowKey }) => {
|
|
141
|
+
const trackNum = displayTrackOrder[row];
|
|
142
|
+
if (trackNum === undefined) return null;
|
|
115
143
|
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
116
|
-
const rowHeight =
|
|
144
|
+
const rowHeight = rowGeometry.getRowHeight(row);
|
|
117
145
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
146
|
+
const renderElements = rowsVirtualized
|
|
147
|
+
? queryTimelineClipIndex(
|
|
148
|
+
clipIndex,
|
|
149
|
+
trackNum,
|
|
150
|
+
renderTimeRange,
|
|
151
|
+
pinnedClipIdentities,
|
|
152
|
+
actorWindows,
|
|
153
|
+
)
|
|
154
|
+
: els;
|
|
118
155
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
119
156
|
const isPendingTrack =
|
|
120
157
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
@@ -148,14 +185,16 @@ export function TimelineLanes({
|
|
|
148
185
|
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
149
186
|
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
150
187
|
return (
|
|
151
|
-
<
|
|
152
|
-
key={
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
188
|
+
<TimelineTrackRow
|
|
189
|
+
key={rowKey}
|
|
190
|
+
index={row}
|
|
191
|
+
rowKey={rowKey}
|
|
192
|
+
rowCount={displayTrackOrder.length}
|
|
193
|
+
top={rowGeometry.getRowTop(row)}
|
|
194
|
+
height={rowHeight}
|
|
195
|
+
virtualized={rowsVirtualized}
|
|
196
|
+
background={rowBackground}
|
|
197
|
+
borderColor={theme.rowBorder}
|
|
159
198
|
>
|
|
160
199
|
<TimelineTrackHeader
|
|
161
200
|
trackNumber={trackNum}
|
|
@@ -218,6 +257,7 @@ export function TimelineLanes({
|
|
|
218
257
|
? draggedClip.snapTime
|
|
219
258
|
: null
|
|
220
259
|
}
|
|
260
|
+
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
221
261
|
/>
|
|
222
262
|
{/* Beat dots on the active track (the one holding the selection),
|
|
223
263
|
falling back to the music track when nothing is selected. */}
|
|
@@ -226,6 +266,7 @@ export function TimelineLanes({
|
|
|
226
266
|
beatTimes={beatAnalysis?.beatTimes}
|
|
227
267
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
228
268
|
pps={pps}
|
|
269
|
+
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
229
270
|
/>
|
|
230
271
|
)}
|
|
231
272
|
{isPendingTrack && (
|
|
@@ -245,9 +286,9 @@ export function TimelineLanes({
|
|
|
245
286
|
)}
|
|
246
287
|
{
|
|
247
288
|
// fallow-ignore-next-line complexity
|
|
248
|
-
|
|
289
|
+
renderElements.map((el) => {
|
|
249
290
|
const clipStyle = getTrackStyle(el.tag);
|
|
250
|
-
const elementKey = el
|
|
291
|
+
const elementKey = getTimelineElementIdentity(el);
|
|
251
292
|
// Only the track's active keyframe clip shows expanded lanes;
|
|
252
293
|
// other clips (incl. siblings on a shared track) show compact
|
|
253
294
|
// diamonds on their own bar instead.
|
|
@@ -263,7 +304,8 @@ export function TimelineLanes({
|
|
|
263
304
|
const clipKey = elementKey;
|
|
264
305
|
const isDraggingClip =
|
|
265
306
|
draggedClip?.started === true &&
|
|
266
|
-
|
|
307
|
+
draggedElement != null &&
|
|
308
|
+
getTimelineElementIdentity(draggedElement) === elementKey;
|
|
267
309
|
if (isDraggingClip) return null;
|
|
268
310
|
const previewElement = getPreviewElement(el);
|
|
269
311
|
// Passenger of a live multi-drag: slide by the SAME formation
|
|
@@ -290,6 +332,7 @@ export function TimelineLanes({
|
|
|
290
332
|
isSelected={isSelected}
|
|
291
333
|
isHovered={hoveredClip === clipKey}
|
|
292
334
|
isDragging={false}
|
|
335
|
+
isActive={isTimelineClipActive(previewElement, currentTime)}
|
|
293
336
|
hasCustomContent={!!renderClipContent}
|
|
294
337
|
capabilities={capabilities}
|
|
295
338
|
theme={theme}
|
|
@@ -434,6 +477,7 @@ export function TimelineLanes({
|
|
|
434
477
|
keyframesData={keyframeCache.get(elementKey)!}
|
|
435
478
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
436
479
|
clipHeightPx={rowHeight - 2 * CLIP_Y}
|
|
480
|
+
clipDuration={previewElement.duration}
|
|
437
481
|
beatsActive={beatStripOnTrack}
|
|
438
482
|
accentColor={clipStyle.accent}
|
|
439
483
|
isSelected={isSelected}
|
|
@@ -499,7 +543,18 @@ export function TimelineLanes({
|
|
|
499
543
|
suppressClickRef={suppressClickRef}
|
|
500
544
|
/>
|
|
501
545
|
);
|
|
502
|
-
|
|
546
|
+
// Keep one keyed top-level child per element. Returning an
|
|
547
|
+
// array here makes React reconcile the outer array by
|
|
548
|
+
// position, so a window shift remounts otherwise stable
|
|
549
|
+
// clip keys and can tear down focus mid-reveal.
|
|
550
|
+
if (!isPassenger) {
|
|
551
|
+
return (
|
|
552
|
+
<Fragment key={clipKey}>
|
|
553
|
+
{clip}
|
|
554
|
+
{propertyLanes}
|
|
555
|
+
</Fragment>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
503
558
|
return (
|
|
504
559
|
<div
|
|
505
560
|
key={clipKey}
|
|
@@ -518,10 +573,10 @@ export function TimelineLanes({
|
|
|
518
573
|
})
|
|
519
574
|
}
|
|
520
575
|
</div>
|
|
521
|
-
</
|
|
576
|
+
</TimelineTrackRow>
|
|
522
577
|
);
|
|
523
578
|
})
|
|
524
579
|
}
|
|
525
|
-
|
|
580
|
+
</div>
|
|
526
581
|
);
|
|
527
582
|
}
|
|
@@ -126,6 +126,19 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
|
|
126
126
|
{ percentage: 50, properties: { x: 50 } },
|
|
127
127
|
]);
|
|
128
128
|
|
|
129
|
+
function boundaryKeyframeAnimations(): GsapAnimation[] {
|
|
130
|
+
return [
|
|
131
|
+
animation("position-tween", "position", [
|
|
132
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
133
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
134
|
+
]),
|
|
135
|
+
animation("visual-tween", "visual", [
|
|
136
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
137
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
138
|
+
]),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
/** A tween the parser leaves unclassified because it spans several groups. */
|
|
130
143
|
function ungroupedAnimation(
|
|
131
144
|
id: string,
|
|
@@ -306,16 +319,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
306
319
|
});
|
|
307
320
|
|
|
308
321
|
it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
|
|
309
|
-
const animations =
|
|
310
|
-
animation("position-tween", "position", [
|
|
311
|
-
{ percentage: 0, properties: { x: 0 } },
|
|
312
|
-
{ percentage: 100, properties: { x: 100 } },
|
|
313
|
-
]),
|
|
314
|
-
animation("visual-tween", "visual", [
|
|
315
|
-
{ percentage: 0, properties: { opacity: 0 } },
|
|
316
|
-
{ percentage: 100, properties: { opacity: 1 } },
|
|
317
|
-
]),
|
|
318
|
-
];
|
|
322
|
+
const animations = boundaryKeyframeAnimations();
|
|
319
323
|
|
|
320
324
|
const { host, root } = renderPropertyLanes({ animations });
|
|
321
325
|
|
|
@@ -512,16 +516,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
512
516
|
// COLUMN whose children are all absolutely positioned: it computed to 0x0 and
|
|
513
517
|
// held no diamonds. The real lanes had no wrapper at all to point at.
|
|
514
518
|
it("wraps the lanes in the identified element so aria-controls resolves to the diamonds", () => {
|
|
515
|
-
const animations =
|
|
516
|
-
animation("position-tween", "position", [
|
|
517
|
-
{ percentage: 0, properties: { x: 0 } },
|
|
518
|
-
{ percentage: 100, properties: { x: 100 } },
|
|
519
|
-
]),
|
|
520
|
-
animation("visual-tween", "visual", [
|
|
521
|
-
{ percentage: 0, properties: { opacity: 0 } },
|
|
522
|
-
{ percentage: 100, properties: { opacity: 1 } },
|
|
523
|
-
]),
|
|
524
|
-
];
|
|
519
|
+
const animations = boundaryKeyframeAnimations();
|
|
525
520
|
const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations });
|
|
526
521
|
const wrapper = host.querySelector("#timeline-lanes-track-0");
|
|
527
522
|
|
|
@@ -594,6 +589,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
594
589
|
}}
|
|
595
590
|
clipWidthPx={200}
|
|
596
591
|
clipHeightPx={48}
|
|
592
|
+
clipDuration={10}
|
|
597
593
|
accentColor="#4ba3d2"
|
|
598
594
|
isSelected
|
|
599
595
|
currentPercentage={-10}
|
|
@@ -240,6 +240,7 @@ export function TimelinePropertyLanes({
|
|
|
240
240
|
keyframesData={keyframesData}
|
|
241
241
|
clipWidthPx={clipWidthPx}
|
|
242
242
|
clipHeightPx={LANE_H}
|
|
243
|
+
clipDuration={clipDuration}
|
|
243
244
|
accentColor={accentColor}
|
|
244
245
|
isSelected={isSelected}
|
|
245
246
|
currentPercentage={currentPercentage}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
-
import { RULER_H,
|
|
3
|
+
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
|
|
4
|
+
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
|
|
4
5
|
import { usePlayerStore } from "../store/playerStore";
|
|
5
6
|
import { secondsToFrame } from "../lib/time";
|
|
6
7
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
8
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
7
9
|
|
|
8
10
|
interface TimelineRulerProps {
|
|
9
11
|
major: number[];
|
|
@@ -16,6 +18,7 @@ interface TimelineRulerProps {
|
|
|
16
18
|
theme: TimelineTheme;
|
|
17
19
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
18
20
|
contentOrigin: number;
|
|
21
|
+
renderTimeRange?: TimelineTimeRange;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export const TimelineRuler = memo(function TimelineRuler({
|
|
@@ -29,10 +32,12 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
29
32
|
theme,
|
|
30
33
|
beatAnalysis,
|
|
31
34
|
contentOrigin,
|
|
35
|
+
renderTimeRange,
|
|
32
36
|
}: TimelineRulerProps) {
|
|
33
37
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
34
38
|
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
|
35
39
|
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
|
|
40
|
+
const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);
|
|
36
41
|
|
|
37
42
|
// Only draw beat lines when they'd be at least 5px apart
|
|
38
43
|
const avgBeatInterval =
|
|
@@ -51,13 +56,14 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
51
56
|
height={totalH}
|
|
52
57
|
>
|
|
53
58
|
{showBeats &&
|
|
54
|
-
|
|
59
|
+
beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
|
55
60
|
const x = t * pps;
|
|
56
61
|
// Louder beats → brighter line. Gamma curve widens the contrast.
|
|
57
|
-
const strength = Math.pow(Math.min(1,
|
|
62
|
+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
|
58
63
|
const opacity = 0.08 + strength * 0.62;
|
|
59
64
|
return (
|
|
60
65
|
<line
|
|
66
|
+
data-timeline-grid-cell="beat"
|
|
61
67
|
key={`b-${t}-${i}`}
|
|
62
68
|
x1={x}
|
|
63
69
|
y1={0}
|
|
@@ -103,13 +109,23 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
103
109
|
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
|
|
104
110
|
a tick spans [x, x+1) and its center is half a pixel right. */}
|
|
105
111
|
{minor.map((t) => (
|
|
106
|
-
<div
|
|
112
|
+
<div
|
|
113
|
+
key={`m-${t}`}
|
|
114
|
+
data-timeline-grid-cell="minor"
|
|
115
|
+
className="absolute bottom-0"
|
|
116
|
+
style={{ left: t * pps - 0.5 }}
|
|
117
|
+
>
|
|
107
118
|
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
|
108
119
|
</div>
|
|
109
120
|
))}
|
|
110
121
|
|
|
111
122
|
{major.map((t) => (
|
|
112
|
-
<div
|
|
123
|
+
<div
|
|
124
|
+
key={`M-${t}`}
|
|
125
|
+
data-timeline-grid-cell="major"
|
|
126
|
+
className="absolute top-0"
|
|
127
|
+
style={{ left: t * pps - 0.5 }}
|
|
128
|
+
>
|
|
113
129
|
<span
|
|
114
130
|
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
|
115
131
|
style={{
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface TimelineTrackRowProps {
|
|
4
|
+
index: number;
|
|
5
|
+
rowKey: number;
|
|
6
|
+
rowCount: number;
|
|
7
|
+
top: number;
|
|
8
|
+
height: number;
|
|
9
|
+
virtualized: boolean;
|
|
10
|
+
background: string;
|
|
11
|
+
borderColor: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Accessible row shell; edit geometry owns its exact top and height. */
|
|
16
|
+
export function TimelineTrackRow({
|
|
17
|
+
index,
|
|
18
|
+
rowKey,
|
|
19
|
+
rowCount,
|
|
20
|
+
top,
|
|
21
|
+
height,
|
|
22
|
+
virtualized,
|
|
23
|
+
background,
|
|
24
|
+
borderColor,
|
|
25
|
+
children,
|
|
26
|
+
}: TimelineTrackRowProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
role="listitem"
|
|
30
|
+
aria-posinset={index + 1}
|
|
31
|
+
aria-setsize={rowCount}
|
|
32
|
+
data-index={index}
|
|
33
|
+
data-timeline-row={index}
|
|
34
|
+
data-timeline-row-key={rowKey}
|
|
35
|
+
className={`${virtualized ? "absolute left-0 right-0" : "relative"} flex`}
|
|
36
|
+
style={{
|
|
37
|
+
top: virtualized ? top : undefined,
|
|
38
|
+
height,
|
|
39
|
+
background,
|
|
40
|
+
borderBottom: `1px solid ${borderColor}`,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme";
|
|
|
5
5
|
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
|
|
6
6
|
|
|
7
7
|
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
|
|
8
|
+
/** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
|
|
9
|
+
sessionEpoch?: number;
|
|
8
10
|
onSeek?: (time: number) => void;
|
|
9
11
|
onDrillDown?: (element: TimelineElement) => void;
|
|
10
12
|
renderClipContent?: (
|