From f387e18f9b44c19a3361a777968395e6edc50461 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:33:49 +0200 Subject: [PATCH] perf(studio): index timeline clip windows --- .../src/player/components/BeatStrip.tsx | 26 +++- .../src/player/components/TimelineRuler.tsx | 25 +++- .../player/components/timelineLayout.test.ts | 40 ++++++ .../src/player/components/timelineLayout.ts | 48 ++++++- .../components/timelineRulerGeometry.ts | 49 +++++-- .../components/timelineViewportGeometry.ts | 20 +++ .../src/player/lib/timelineClipIndex.test.ts | 90 +++++++++++++ .../src/player/lib/timelineClipIndex.ts | 123 ++++++++++++++++++ 8 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 packages/studio/src/player/lib/timelineClipIndex.test.ts create mode 100644 packages/studio/src/player/lib/timelineClipIndex.ts diff --git a/packages/studio/src/player/components/BeatStrip.tsx b/packages/studio/src/player/components/BeatStrip.tsx index 0caa0e3263..a7558d56a8 100644 --- a/packages/studio/src/player/components/BeatStrip.tsx +++ b/packages/studio/src/player/components/BeatStrip.tsx @@ -1,7 +1,8 @@ import { memo, useRef, useState } from "react"; import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions"; import { usePlayerStore } from "../store/playerStore"; -import { CLIP_Y } from "./timelineLayout"; +import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; export const BEAT_BAND_H = 14; // dark band height at top of track const BEAT_HIT_W = 12; // grab width per beat (px) @@ -24,23 +25,30 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({ beatStrengths, pps, highlightTime, + renderTimeRange, }: { beatTimes: number[] | undefined; beatStrengths: number[] | undefined; pps: number; /** Snap guide time — drawn as a bright line even when it is not a beat. */ highlightTime?: number | null; + renderTimeRange?: TimelineTimeRange; }) { const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null; const highlightIsBeat = highlightTime != null && visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true; if (!visibleBeatTimes && highlightTime == null) return null; + const beatEntries = getTimelineBeatEntries( + visibleBeatTimes ?? undefined, + beatStrengths, + renderTimeRange, + ); return (
- {visibleBeatTimes?.map((t, i) => { + {beatEntries.map(({ time: t, index: i, strength: beatStrength }) => { const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3; - const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2); + const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2); const opacity = isHighlight ? 1 : 0.06 + strength * 0.16; return (
(null); @@ -92,15 +102,21 @@ export const BeatStrip = memo(function BeatStrip({ if (!beatTimes || beatsTooDense(beatTimes, pps)) return null; const cy = BEAT_BAND_H / 2; + const beatEntries = getTimelineBeatEntries( + beatTimes, + beatStrengths, + renderTimeRange, + drag ? new Set([drag.index]) : undefined, + ); return (
- {beatTimes.map((t, i) => { + {beatEntries.map(({ time: t, index: i, strength: beatStrength }) => { // Louder beats → larger, brighter dot. Gamma curve widens the contrast. - const strength = Math.pow(Math.min(1, beatStrengths?.[i] ?? 0.5), 2.2); + const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2); const r = 1.5 + strength * 2.5; const opacity = 0.25 + strength * 0.75; const dxPx = drag?.index === i ? drag.dx : 0; diff --git a/packages/studio/src/player/components/TimelineRuler.tsx b/packages/studio/src/player/components/TimelineRuler.tsx index 5356341bbe..206b64a4db 100644 --- a/packages/studio/src/player/components/TimelineRuler.tsx +++ b/packages/studio/src/player/components/TimelineRuler.tsx @@ -1,9 +1,10 @@ import { memo } from "react"; import type { TimelineTheme } from "./timelineTheme"; -import { RULER_H, formatTimelineTickLabel } from "./timelineLayout"; +import { RULER_H, formatTimelineTickLabel, getTimelineBeatEntries } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import { secondsToFrame } from "../lib/time"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; interface TimelineRulerProps { major: number[]; @@ -16,6 +17,7 @@ interface TimelineRulerProps { theme: TimelineTheme; beatAnalysis?: MusicBeatAnalysis | null; contentOrigin: number; + renderTimeRange?: TimelineTimeRange; } export const TimelineRuler = memo(function TimelineRuler({ @@ -29,10 +31,12 @@ export const TimelineRuler = memo(function TimelineRuler({ theme, beatAnalysis, contentOrigin, + renderTimeRange, }: TimelineRulerProps) { const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); const beatTimes = beatAnalysis?.beatTimes ?? []; const beatStrengths = beatAnalysis?.beatStrengths ?? []; + const beatEntries = getTimelineBeatEntries(beatTimes, beatStrengths, renderTimeRange); // Only draw beat lines when they'd be at least 5px apart const avgBeatInterval = @@ -51,13 +55,14 @@ export const TimelineRuler = memo(function TimelineRuler({ height={totalH} > {showBeats && - beatTimes.map((t, i) => { + beatEntries.map(({ time: t, index: i, strength: beatStrength }) => { const x = t * pps; // Louder beats → brighter line. Gamma curve widens the contrast. - const strength = Math.pow(Math.min(1, beatStrengths[i] ?? 0.5), 2.2); + const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2); const opacity = 0.08 + strength * 0.62; return ( ( -
+
))} {major.map((t) => ( -
+
{ + it("adds the shared half-viewport overscan on each side and clamps to duration", () => { + expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual( + { start: 0, end: 8.5 }, + ); + expect( + getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20), + ).toEqual({ start: 14.5, end: 20 }); + }); + + it("generates globally aligned ticks directly inside the bounded window", () => { + const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 }); + const interval = getTimelineMajorTickInterval(10_000, 100); + expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true); + expect( + ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6), + ).toBe(true); + expect(ticks.major.length + ticks.minor.length).toBeLessThan(100); + }); + + it("slices beat records with original strength indexes and unions a pinned beat", () => { + expect( + getTimelineBeatEntries( + [0, 1, 2, 3], + [0.1, 0.2, 0.3, 0.4], + { start: 1, end: 3 }, + new Set([3]), + ), + ).toEqual([ + { index: 1, time: 1, strength: 0.2 }, + { index: 2, time: 2, strength: 0.3 }, + { index: 3, time: 3, strength: 0.4 }, + ]); + }); +}); describe("variable timeline row geometry", () => { const tracks = [ diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 03688a8ec5..8fcc409ebc 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,6 +1,11 @@ import type { ZoomMode } from "../store/playerStore"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; -export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; +export { + formatTimelineTickLabel, + generateTicks, + getTimelineMajorTickInterval, +} from "./timelineRulerGeometry"; /* ── Layout constants ──────────────────────────────────────────────── */ export const GUTTER = 32; @@ -11,6 +16,47 @@ export const RULER_H = 24; export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; +export interface TimelineBeatEntry { + readonly index: number; + readonly time: number; + readonly strength: number | undefined; +} + +function findFirstTimeAtOrAfter(times: readonly number[], target: number): number { + let low = 0; + let high = times.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((times[mid] ?? Number.POSITIVE_INFINITY) < target) low = mid + 1; + else high = mid; + } + return low; +} + +/** Slice sorted beat data without allocating entries outside the render window. */ +export function getTimelineBeatEntries( + beatTimes: readonly number[] | undefined, + beatStrengths: readonly number[] | undefined, + range: TimelineTimeRange | undefined, + pinnedIndexes: ReadonlySet = new Set(), +): readonly TimelineBeatEntry[] { + if (!beatTimes?.length) return []; + const start = range?.start ?? Number.NEGATIVE_INFINITY; + const end = range?.end ?? Number.POSITIVE_INFINITY; + const selected = new Set(); + for (let index = findFirstTimeAtOrAfter(beatTimes, start); index < beatTimes.length; index++) { + const time = beatTimes[index]; + if (time === undefined || time >= end) break; + selected.add(index); + } + for (const index of pinnedIndexes) { + if (index >= 0 && index < beatTimes.length) selected.add(index); + } + return [...selected] + .sort((left, right) => left - right) + .map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] })); +} + export function getTimelineLaneTop(laneIndex: number): number { return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H; } diff --git a/packages/studio/src/player/components/timelineRulerGeometry.ts b/packages/studio/src/player/components/timelineRulerGeometry.ts index 59d1c4794c..32ccc50ac5 100644 --- a/packages/studio/src/player/components/timelineRulerGeometry.ts +++ b/packages/studio/src/player/components/timelineRulerGeometry.ts @@ -1,7 +1,8 @@ import { formatTime } from "../lib/time"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; // fallow-ignore-next-line complexity -function getTimelineMajorTickInterval( +export function getTimelineMajorTickInterval( duration: number, pixelsPerSecond?: number, frameRate?: number, @@ -49,28 +50,54 @@ function roundTickValue(time: number): number { return Math.round(time * 1e6) / 1e6; } +function isSupportedTickDuration(duration: number): boolean { + return duration > 0 && Number.isFinite(duration) && duration <= 14400; +} + +function getTickRange(duration: number, range?: TimelineTimeRange): TimelineTimeRange { + return { + start: Math.max(0, range?.start ?? 0), + end: Math.min(duration, range?.end ?? duration), + }; +} + +function appendMinorTicks( + minor: number[], + majorTime: number, + minorInterval: number, + subdivisions: number, + range: TimelineTimeRange, + maxTicks: number, + majorCount: number, +): void { + for (let part = 1; part < subdivisions && majorCount + minor.length < maxTicks; part++) { + const time = majorTime + part * minorInterval; + if (time >= range.start - 0.001 && time <= range.end + 0.001) { + minor.push(roundTickValue(time)); + } + } +} + export function generateTicks( duration: number, pixelsPerSecond?: number, frameRate?: number, + range?: TimelineTimeRange, ): { major: number[]; minor: number[] } { - if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) { - return { major: [], minor: [] }; - } + if (!isSupportedTickDuration(duration)) return { major: [], minor: [] }; const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate); const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate); const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0; const major: number[] = []; const minor: number[] = []; const maxTicks = 2000; - for (let index = 0; major.length < maxTicks; index++) { + const tickRange = getTickRange(duration, range); + const firstMajorIndex = Math.max(0, Math.floor(tickRange.start / majorInterval)); + for (let index = firstMajorIndex; major.length < maxTicks; index++) { const time = index * majorInterval; - if (time > duration + 0.001) break; - major.push(roundTickValue(time)); - for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) { - const minorTime = time + part * minorInterval; - if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime)); - } + if (time > tickRange.end + 0.001) break; + if (time >= tickRange.start - 0.001) major.push(roundTickValue(time)); + appendMinorTicks(minor, time, minorInterval, subdivisions, tickRange, maxTicks, major.length); } return { major, minor }; } diff --git a/packages/studio/src/player/components/timelineViewportGeometry.ts b/packages/studio/src/player/components/timelineViewportGeometry.ts index ee86f834fa..db0a964232 100644 --- a/packages/studio/src/player/components/timelineViewportGeometry.ts +++ b/packages/studio/src/player/components/timelineViewportGeometry.ts @@ -1,6 +1,26 @@ import { RULER_H, type TimelineRowGeometry } from "./timelineLayout"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; +import type { TimelineTimeRange } from "../lib/timelineClipIndex"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; +export function getTimelineRenderTimeRange( + viewport: Pick, + pixelsPerSecond: number, + contentOrigin: number, + duration: number, +): TimelineTimeRange { + if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) { + return { start: 0, end: 0 }; + } + const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio; + const startPx = viewport.scrollLeft - contentOrigin - overscanPx; + const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx; + return { + start: Math.min(duration, Math.max(0, startPx / pixelsPerSecond)), + end: Math.min(duration, Math.max(0, endPx / pixelsPerSecond)), + }; +} + export function getTimelineVisibleTimeRange( viewport: Pick, pixelsPerSecond: number, diff --git a/packages/studio/src/player/lib/timelineClipIndex.test.ts b/packages/studio/src/player/lib/timelineClipIndex.test.ts new file mode 100644 index 0000000000..eb0d5d4337 --- /dev/null +++ b/packages/studio/src/player/lib/timelineClipIndex.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { createTimelineClipIndex, queryTimelineClipIndex } from "./timelineClipIndex"; + +function clip( + id: string, + start: number, + duration: number, + track = 1, + hidden = false, +): TimelineElement { + return { id, tag: "div", start, duration, track, hidden }; +} + +function ids(elements: readonly TimelineElement[]): string[] { + return elements.map((element) => element.key ?? element.id); +} + +describe("timelineClipIndex", () => { + it("finds a long predecessor spanning the window", () => { + const index = createTimelineClipIndex([[1, [clip("long", 0, 100), clip("late", 80, 1)]]]); + expect(ids(queryTimelineClipIndex(index, 1, { start: 50, end: 51 }))).toEqual(["long"]); + }); + + it("uses half-open render boundaries and retains zero-duration points", () => { + const index = createTimelineClipIndex([ + [ + 1, + [ + clip("left", 0, 2), + clip("right", 2, 2), + clip("point", 3, 0), + clip("negative", 3.5, -2), + clip("micro", 4, 1e-9), + ], + ], + ]); + expect(ids(queryTimelineClipIndex(index, 1, { start: 2, end: 4 }))).toEqual([ + "right", + "point", + "negative", + ]); + expect(ids(queryTimelineClipIndex(index, 1, { start: 4, end: 5 }))).toEqual(["micro"]); + }); + + it("preserves projection order after overlap and pin union", () => { + const index = createTimelineClipIndex([ + [1, [clip("pinned", 10, 1), clip("visible-b", 2, 1), clip("visible-a", 1, 3)]], + ]); + expect( + ids(queryTimelineClipIndex(index, 1, { start: 1.5, end: 2.5 }, new Set(["pinned"]))), + ).toEqual(["pinned", "visible-b", "visible-a"]); + }); + + it("keeps duplicate identities deterministic and ignores stale pins", () => { + const index = createTimelineClipIndex([ + [1, [clip("duplicate", 10, 1), clip("duplicate", 20, 1), clip("hidden", 30, 1, 1, true)]], + [2, [clip("duplicate", 40, 1, 2)]], + ]); + expect( + ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }, new Set(["duplicate", "stale"]))), + ).toEqual(["duplicate", "duplicate"]); + expect( + ids(queryTimelineClipIndex(index, 2, { start: 0, end: 1 }, new Set(["duplicate"]))), + ).toEqual(["duplicate"]); + }); + + it("keeps an immutable interval snapshot when the source projection array changes", () => { + const source = [clip("first", 0, 2), clip("second", 5, 2)]; + const index = createTimelineClipIndex([[1, source]]); + source.splice(0, source.length, clip("replacement", 0, 20)); + expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual(["first"]); + }); + + it("excludes clips with non-finite timing", () => { + const index = createTimelineClipIndex([ + [1, [clip("bad-start", Number.NaN, 1), clip("bad-duration", 0, Number.POSITIVE_INFINITY)]], + ]); + expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual([]); + }); + + it("indexes synthetic fractional display rows independently", () => { + const index = createTimelineClipIndex([ + [1, [clip("host", 0, 1)]], + [1.5, [clip("child", 10, 1, 1.5)]], + ]); + expect(ids(queryTimelineClipIndex(index, 1.5, { start: 10, end: 11 }))).toEqual(["child"]); + expect(ids(queryTimelineClipIndex(index, 1, { start: 10, end: 11 }))).toEqual([]); + }); +}); diff --git a/packages/studio/src/player/lib/timelineClipIndex.ts b/packages/studio/src/player/lib/timelineClipIndex.ts new file mode 100644 index 0000000000..5d56311c12 --- /dev/null +++ b/packages/studio/src/player/lib/timelineClipIndex.ts @@ -0,0 +1,123 @@ +import type { TimelineElement } from "../store/playerStore"; + +export interface TimelineTimeRange { + readonly start: number; + readonly end: number; +} + +interface TimelineClipInterval { + readonly element: TimelineElement; + readonly identity: string; + readonly ordinal: number; + readonly start: number; + readonly end: number; +} + +interface TimelineClipRowIndex { + readonly byStart: readonly TimelineClipInterval[]; + readonly prefixMaxEnd: readonly number[]; + readonly byIdentity: ReadonlyMap; +} + +export interface TimelineClipIndex { + readonly rows: ReadonlyMap; +} + +function clipIdentity(element: TimelineElement): string { + return element.key ?? element.id; +} + +function clipInterval(element: TimelineElement, ordinal: number): TimelineClipInterval | null { + if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null; + const duration = Math.max(0, element.duration); + return Object.freeze({ + element, + identity: clipIdentity(element), + ordinal, + start: element.start, + end: element.start + duration, + }); +} + +/** Immutable render index for the exact display-track projection. */ +export function createTimelineClipIndex( + tracks: readonly (readonly [number, readonly TimelineElement[]])[], +): TimelineClipIndex { + const rows = new Map(); + for (const [rowKey, elements] of tracks) { + const intervals = elements + .map(clipInterval) + .filter((interval): interval is TimelineClipInterval => interval !== null); + const byStart = Object.freeze( + [...intervals].sort( + (left, right) => left.start - right.start || left.ordinal - right.ordinal, + ), + ); + let maxEnd = Number.NEGATIVE_INFINITY; + const prefixMaxEnd = Object.freeze( + byStart.map((interval) => { + maxEnd = Math.max(maxEnd, interval.end); + return maxEnd; + }), + ); + const mutableByIdentity = new Map(); + for (const interval of intervals) { + const matches = mutableByIdentity.get(interval.identity) ?? []; + matches.push(interval); + mutableByIdentity.set(interval.identity, matches); + } + const byIdentity = new Map( + [...mutableByIdentity].map(([identity, matches]) => [identity, Object.freeze(matches)]), + ); + rows.set(rowKey, Object.freeze({ byStart, prefixMaxEnd, byIdentity })); + } + return Object.freeze({ rows }); +} + +function upperBoundStart(intervals: readonly TimelineClipInterval[], end: number): number { + let low = 0; + let high = intervals.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((intervals[mid]?.start ?? Number.POSITIVE_INFINITY) < end) low = mid + 1; + else high = mid; + } + return low; +} + +function overlaps(interval: TimelineClipInterval, range: TimelineTimeRange): boolean { + if (interval.end <= interval.start) + return interval.start >= range.start && interval.start < range.end; + return interval.start < range.end && interval.end > range.start; +} + +/** + * Query one display row. The overlap set and explicit actor pins are returned + * in the row's original projection order, so windowing never changes z/DOM order. + */ +export function queryTimelineClipIndex( + index: TimelineClipIndex, + rowKey: number, + range: TimelineTimeRange, + pinnedIdentities: ReadonlySet = new Set(), +): readonly TimelineElement[] { + const row = index.rows.get(rowKey); + if (!row) return Object.freeze([]); + const selected = new Set(); + if (range.end > range.start) { + let cursor = upperBoundStart(row.byStart, range.end) - 1; + while (cursor >= 0 && (row.prefixMaxEnd[cursor] ?? Number.NEGATIVE_INFINITY) >= range.start) { + const interval = row.byStart[cursor]; + if (interval && overlaps(interval, range)) selected.add(interval); + cursor -= 1; + } + } + for (const identity of pinnedIdentities) { + for (const interval of row.byIdentity.get(identity) ?? []) selected.add(interval); + } + return Object.freeze( + [...selected] + .sort((left, right) => left.ordinal - right.ordinal) + .map((interval) => interval.element), + ); +}