Skip to content

Commit 2dd814a

Browse files
committed
perf(studio): index timeline clip windows
1 parent e494530 commit 2dd814a

8 files changed

Lines changed: 399 additions & 22 deletions

File tree

packages/studio/src/player/components/BeatStrip.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { memo, useRef, useState } from "react";
22
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
33
import { usePlayerStore } from "../store/playerStore";
4-
import { CLIP_Y } from "./timelineLayout";
4+
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
5+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
56

67
export const BEAT_BAND_H = 14; // dark band height at top of track
78
const BEAT_HIT_W = 12; // grab width per beat (px)
@@ -24,23 +25,30 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
2425
beatStrengths,
2526
pps,
2627
highlightTime,
28+
renderTimeRange,
2729
}: {
2830
beatTimes: number[] | undefined;
2931
beatStrengths: number[] | undefined;
3032
pps: number;
3133
/** Snap guide time — drawn as a bright line even when it is not a beat. */
3234
highlightTime?: number | null;
35+
renderTimeRange?: TimelineTimeRange;
3336
}) {
3437
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
3538
const highlightIsBeat =
3639
highlightTime != null &&
3740
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
3841
if (!visibleBeatTimes && highlightTime == null) return null;
42+
const beatEntries = getTimelineBeatEntries(
43+
visibleBeatTimes ?? undefined,
44+
beatStrengths,
45+
renderTimeRange,
46+
);
3947
return (
4048
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
41-
{visibleBeatTimes?.map((t, i) => {
49+
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
4250
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
43-
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
51+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
4452
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
4553
return (
4654
<div
@@ -81,26 +89,34 @@ export const BeatStrip = memo(function BeatStrip({
8189
beatTimes,
8290
beatStrengths,
8391
pps,
92+
renderTimeRange,
8493
}: {
8594
beatTimes: number[] | undefined;
8695
beatStrengths: number[] | undefined;
8796
pps: number;
97+
renderTimeRange?: TimelineTimeRange;
8898
}) {
8999
// Active drag: which beat and how far (px) it's been dragged.
90100
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
91101
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
92102

93103
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
94104
const cy = BEAT_BAND_H / 2;
105+
const beatEntries = getTimelineBeatEntries(
106+
beatTimes,
107+
beatStrengths,
108+
renderTimeRange,
109+
drag ? new Set([drag.index]) : undefined,
110+
);
95111

96112
return (
97113
<div
98114
className="absolute left-0 right-0 pointer-events-none"
99115
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
100116
>
101-
{beatTimes.map((t, i) => {
117+
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
102118
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
103-
const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2);
119+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
104120
const r = 1.5 + strength * 2.5;
105121
const opacity = 0.25 + strength * 0.75;
106122
const dxPx = drag?.index === i ? drag.dx : 0;

packages/studio/src/player/components/TimelineRuler.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { memo } from "react";
22
import type { TimelineTheme } from "./timelineTheme";
3-
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
3+
import { RULER_H, formatTimelineTickLabel, getTimelineBeatEntries } from "./timelineLayout";
44
import { usePlayerStore } from "../store/playerStore";
55
import { secondsToFrame } from "../lib/time";
66
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
7+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
78

89
interface TimelineRulerProps {
910
major: number[];
@@ -16,6 +17,7 @@ interface TimelineRulerProps {
1617
theme: TimelineTheme;
1718
beatAnalysis?: MusicBeatAnalysis | null;
1819
contentOrigin: number;
20+
renderTimeRange?: TimelineTimeRange;
1921
}
2022

2123
export const TimelineRuler = memo(function TimelineRuler({
@@ -29,10 +31,12 @@ export const TimelineRuler = memo(function TimelineRuler({
2931
theme,
3032
beatAnalysis,
3133
contentOrigin,
34+
renderTimeRange,
3235
}: TimelineRulerProps) {
3336
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
3437
const beatTimes = beatAnalysis?.beatTimes ?? [];
3538
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
39+
const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);
3640

3741
// Only draw beat lines when they'd be at least 5px apart
3842
const avgBeatInterval =
@@ -51,13 +55,14 @@ export const TimelineRuler = memo(function TimelineRuler({
5155
height={totalH}
5256
>
5357
{showBeats &&
54-
beatTimes.map((t, i) => {
58+
beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
5559
const x = t * pps;
5660
// Louder beats → brighter line. Gamma curve widens the contrast.
57-
const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
61+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
5862
const opacity = 0.08 + strength * 0.62;
5963
return (
6064
<line
65+
data-timeline-grid-cell="beat"
6166
key={`b-${t}-${i}`}
6267
x1={x}
6368
y1={0}
@@ -103,13 +108,23 @@ export const TimelineRuler = memo(function TimelineRuler({
103108
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
104109
a tick spans [x, x+1) and its center is half a pixel right. */}
105110
{minor.map((t) => (
106-
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
111+
<div
112+
key={`m-${t}`}
113+
data-timeline-grid-cell="minor"
114+
className="absolute bottom-0"
115+
style={{ left: t * pps - 0.5 }}
116+
>
107117
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
108118
</div>
109119
))}
110120

111121
{major.map((t) => (
112-
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
122+
<div
123+
key={`M-${t}`}
124+
data-timeline-grid-cell="major"
125+
className="absolute top-0"
126+
style={{ left: t * pps - 0.5 }}
127+
>
113128
<span
114129
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
115130
style={{

packages/studio/src/player/components/timelineLayout.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,47 @@ import {
1919
getTimelineRowGeometry,
2020
trackHeights,
2121
resolveTimelineAssetDrop,
22+
generateTicks,
23+
getTimelineBeatEntries,
24+
getTimelineMajorTickInterval,
2225
} from "./timelineLayout";
26+
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
27+
28+
describe("horizontal timeline window", () => {
29+
it("adds the shared half-viewport overscan on each side and clamps to duration", () => {
30+
expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
31+
{ start: 0, end: 8.5 },
32+
);
33+
expect(
34+
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
35+
).toEqual({ start: 14.5, end: 20 });
36+
});
37+
38+
it("generates globally aligned ticks directly inside the bounded window", () => {
39+
const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
40+
const interval = getTimelineMajorTickInterval(10_000, 100);
41+
expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
42+
expect(
43+
ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
44+
).toBe(true);
45+
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
46+
});
47+
48+
it("slices beat records with original strength indexes and unions a pinned beat", () => {
49+
expect(
50+
getTimelineBeatEntries(
51+
[0, 1, 2, 3],
52+
[0.1, 0.2, 0.3, 0.4],
53+
{ start: 1, end: 3 },
54+
new Set([3]),
55+
),
56+
).toEqual([
57+
{ index: 1, time: 1, strength: 0.2 },
58+
{ index: 2, time: 2, strength: 0.3 },
59+
{ index: 3, time: 3, strength: 0.4 },
60+
]);
61+
});
62+
});
2363

2464
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
2565
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);

packages/studio/src/player/components/timelineLayout.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { ZoomMode } from "../store/playerStore";
2+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
23

3-
export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry";
4+
export {
5+
formatTimelineTickLabel,
6+
generateTicks,
7+
getTimelineMajorTickInterval,
8+
} from "./timelineRulerGeometry";
49

510
/* ── Layout constants ──────────────────────────────────────────────── */
611
export const GUTTER = 32;
@@ -11,6 +16,47 @@ export const RULER_H = 24;
1116
export const CLIP_Y = 3;
1217
export const CLIP_HANDLE_W = 18;
1318

19+
export interface TimelineBeatEntry {
20+
readonly index: number;
21+
readonly time: number;
22+
readonly strength: number | undefined;
23+
}
24+
25+
function findFirstTimeAtOrAfter(times: readonly number[], target: number): number {
26+
let low = 0;
27+
let high = times.length;
28+
while (low < high) {
29+
const mid = Math.floor((low + high) / 2);
30+
if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1;
31+
else high = mid;
32+
}
33+
return low;
34+
}
35+
36+
/** Slice sorted beat data without allocating entries outside the render window. */
37+
export function getTimelineBeatEntries(
38+
beatTimes: readonly number[] | undefined,
39+
beatStrengths: readonly number[] | undefined,
40+
range: TimelineTimeRange | undefined,
41+
pinnedIndexes: ReadonlySet<number> = new Set(),
42+
): readonly TimelineBeatEntry[] {
43+
if (!beatTimes?.length) return [];
44+
const start = range?.start ?? Number.NEGATIVE_INFINITY;
45+
const end = range?.end ?? Number.POSITIVE_INFINITY;
46+
const selected = new Set<number>();
47+
for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) {
48+
const time = beatTimes[index];
49+
if (time === undefined || time >= end) break;
50+
selected.add(index);
51+
}
52+
for (const index of pinnedIndexes) {
53+
if (index >= 0 && index < beatTimes.length) selected.add(index);
54+
}
55+
return [...selected]
56+
.sort((left, right) => left - right)
57+
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
58+
}
59+
1460
export function getTimelineLaneTop(laneIndex: number): number {
1561
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
1662
}

packages/studio/src/player/components/timelineRulerGeometry.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { formatTime } from "../lib/time";
2+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
23

34
// fallow-ignore-next-line complexity
4-
function getTimelineMajorTickInterval(
5+
export function getTimelineMajorTickInterval(
56
duration: number,
67
pixelsPerSecond?: number,
78
frameRate?: number,
@@ -49,28 +50,54 @@ function roundTickValue(time: number): number {
4950
return Math.round(time * 1e6) / 1e6;
5051
}
5152

53+
function isSupportedTickDuration(duration: number): boolean {
54+
return duration > 0 && Number.isFinite(duration) && duration <= 14400;
55+
}
56+
57+
function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange {
58+
return {
59+
start: Math.max(0, range?.start ?? 0),
60+
end: Math.min(duration, range?.end ?? duration),
61+
};
62+
}
63+
64+
function appendMinorTicks(
65+
minor: number[],
66+
majorTime: number,
67+
minorInterval: number,
68+
subdivisions: number,
69+
range: TimelineTimeRange,
70+
maxTicks: number,
71+
majorCount: number,
72+
): void {
73+
for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) {
74+
const time = majorTime + part * minorInterval;
75+
if (time >= range.start - 0.001 && time <= range.end + 0.001) {
76+
minor.push(roundTickValue(time));
77+
}
78+
}
79+
}
80+
5281
export function generateTicks(
5382
duration: number,
5483
pixelsPerSecond?: number,
5584
frameRate?: number,
85+
range?: TimelineTimeRange,
5686
): { major: number[]; minor: number[] } {
57-
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) {
58-
return { major: [], minor: [] };
59-
}
87+
if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
6088
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
6189
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
6290
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
6391
const major: number[] = [];
6492
const minor: number[] = [];
6593
const maxTicks = 2000;
66-
for (let index = 0; major.length < maxTicks; index++) {
94+
const tickRange = getTickRange(duration, range);
95+
const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval));
96+
for (let index = firstMajorIndex; major.length < maxTicks; index++) {
6797
const time = index * majorInterval;
68-
if (time > duration + 0.001) break;
69-
major.push(roundTickValue(time));
70-
for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) {
71-
const minorTime = time + part * minorInterval;
72-
if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime));
73-
}
98+
if (time > tickRange.end + 0.001) break;
99+
if (time >= tickRange.start - 0.001) major.push(roundTickValue(time));
100+
appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length);
74101
}
75102
return { major, minor };
76103
}

packages/studio/src/player/components/timelineViewportGeometry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
2+
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
3+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
24
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
35

6+
export function getTimelineRenderTimeRange(
7+
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
8+
pixelsPerSecond: number,
9+
contentOrigin: number,
10+
duration: number,
11+
): TimelineTimeRange {
12+
if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) {
13+
return { start: 0, end: 0 };
14+
}
15+
const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio;
16+
const startPx = viewport.scrollLeft - contentOrigin - overscanPx;
17+
const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx;
18+
return {
19+
start: Math.min(duration, Math.max(0, startPx / pixelsPerSecond)),
20+
end: Math.min(duration, Math.max(0, endPx / pixelsPerSecond)),
21+
};
22+
}
23+
424
export function getTimelineVisibleTimeRange(
525
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
626
pixelsPerSecond: number,

0 commit comments

Comments
 (0)