Skip to content

Commit 029971e

Browse files
committed
perf(studio): add timeline clip-window index primitive
1 parent ca7d212 commit 029971e

9 files changed

Lines changed: 508 additions & 22 deletions

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,10 +1,11 @@
11
import { memo } from "react";
22
import type { TimelineTheme } from "./timelineTheme";
3-
import { RULER_H } from "./timelineLayout";
3+
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
44
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
55
import { usePlayerStore } from "../store/playerStore";
66
import { secondsToFrame } from "../lib/time";
77
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
8+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
89

910
interface TimelineRulerProps {
1011
major: number[];
@@ -17,6 +18,7 @@ interface TimelineRulerProps {
1718
theme: TimelineTheme;
1819
beatAnalysis?: MusicBeatAnalysis | null;
1920
contentOrigin: number;
21+
renderTimeRange?: TimelineTimeRange;
2022
}
2123

2224
export const TimelineRuler = memo(function TimelineRuler({
@@ -30,10 +32,12 @@ export const TimelineRuler = memo(function TimelineRuler({
3032
theme,
3133
beatAnalysis,
3234
contentOrigin,
35+
renderTimeRange,
3336
}: TimelineRulerProps) {
3437
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
3538
const beatTimes = beatAnalysis?.beatTimes ?? [];
3639
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
40+
const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange);
3741

3842
// Only draw beat lines when they'd be at least 5px apart
3943
const avgBeatInterval =
@@ -52,13 +56,14 @@ export const TimelineRuler = memo(function TimelineRuler({
5256
height={totalH}
5357
>
5458
{showBeats &&
55-
beatTimes.map((t, i) => {
59+
beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
5660
const x = t * pps;
5761
// Louder beats → brighter line. Gamma curve widens the contrast.
58-
const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2);
62+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
5963
const opacity = 0.08 + strength * 0.62;
6064
return (
6165
<line
66+
data-timeline-grid-cell="beat"
6267
key={`b-${t}-${i}`}
6368
x1={x}
6469
y1={0}
@@ -104,13 +109,23 @@ export const TimelineRuler = memo(function TimelineRuler({
104109
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
105110
a tick spans [x, x+1) and its center is half a pixel right. */}
106111
{minor.map((t) => (
107-
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
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+
>
108118
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
109119
</div>
110120
))}
111121

112122
{major.map((t) => (
113-
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
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+
>
114129
<span
115130
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
116131
style={{

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

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

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

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

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

34
/* ── Layout constants ──────────────────────────────────────────────── */
45
export const GUTTER = 32;
@@ -9,6 +10,47 @@ export const RULER_H = 24;
910
export const CLIP_Y = 3;
1011
export const CLIP_HANDLE_W = 18;
1112

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

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,
@@ -58,29 +59,55 @@ function roundTickValue(time: number): number {
5859
return Math.round(time * 1e6) / 1e6;
5960
}
6061

62+
function isSupportedTickDuration(duration: number): boolean {
63+
return duration > 0 && Number.isFinite(duration) && duration <= 14400;
64+
}
65+
66+
function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange {
67+
return {
68+
start: Math.max(0, range?.start ?? 0),
69+
end: Math.min(duration, range?.end ?? duration),
70+
};
71+
}
72+
73+
function appendMinorTicks(
74+
minor: number[],
75+
majorTime: number,
76+
minorInterval: number,
77+
subdivisions: number,
78+
range: TimelineTimeRange,
79+
maxTicks: number,
80+
majorCount: number,
81+
): void {
82+
for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) {
83+
const time = majorTime + part * minorInterval;
84+
if (time >= range.start - 0.001 && time <= range.end + 0.001) {
85+
minor.push(roundTickValue(time));
86+
}
87+
}
88+
}
89+
6190
export function generateTicks(
6291
duration: number,
6392
pixelsPerSecond?: number,
6493
frameRate?: number,
94+
range?: TimelineTimeRange,
6595
): { major: number[]; minor: number[] } {
66-
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) {
67-
return { major: [], minor: [] };
68-
}
96+
if (!isSupportedTickDuration(duration)) return { major: [], minor: [] };
6997
const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate);
7098
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
7199
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
72100
const major: number[] = [];
73101
const minor: number[] = [];
74102
// Safety cap prevents malformed inputs from creating an unbounded ruler.
75103
const maxTicks = 2000;
76-
for (let index = 0; major.length < maxTicks; index++) {
104+
const tickRange = getTickRange(duration, range);
105+
const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval));
106+
for (let index = firstMajorIndex; major.length < maxTicks; index++) {
77107
const time = index * majorInterval;
78-
if (time > duration + 0.001) break;
79-
major.push(roundTickValue(time));
80-
for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) {
81-
const minorTime = time + part * minorInterval;
82-
if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime));
83-
}
108+
if (time > tickRange.end + 0.001) break;
109+
if (time >= tickRange.start - 0.001) major.push(roundTickValue(time));
110+
appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length);
84111
}
85112
return { major, minor };
86113
}

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,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function extractTimelineVirtualRowRange(
2525
return [...indexes].sort((left, right) => left - right);
2626
}
2727

28-
export function extractTimelineVirtualRowSeed(
28+
function extractTimelineVirtualRowSeed(
2929
count: number,
3030
pinnedRowIndexes: readonly number[],
3131
): number[] {

0 commit comments

Comments
 (0)