+
{
+ 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),
+ );
+}