Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@hyperframes/sdk": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.6",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
"gsap": "^3.13.0",
Expand Down
78 changes: 78 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getTimelineCanvasHeight,
resolveTimelineAssetDrop,
getTimelinePlayheadLeft,
getTimelinePlaybackFollowScrollLeft,
getTimelineScrollLeftForZoomAnchor,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
Expand Down Expand Up @@ -274,6 +275,33 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});

it("renders the complete track list while row virtualization is gated off", () => {
const host = createSizedTimelineHost(640);
usePlayerStore.setState({
duration: 4,
timelineReady: true,
elements: Array.from({ length: 12 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));

const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows).toHaveLength(12);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");

act(() => root.unmount());
});

// fallow-ignore-next-line code-duplication
it("renders the gutter without legacy icons or hue dots", () => {
const { host, root } = renderBasicTimeline();

Expand Down Expand Up @@ -976,6 +1004,56 @@ describe("getTimelinePlayheadLeft", () => {
});
});

describe("getTimelinePlaybackFollowScrollLeft", () => {
it("holds the viewport still while the playhead remains inside the comfort area", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 700,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(100);
});

it("follows forward playback at the right-side comfort line", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 1200,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(384);
});

it("returns to the matching earlier viewport after a playback loop", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 264,
currentScrollLeft: 900,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(0);
});

it("clamps at the end of the scrollable timeline", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 5000,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 1500,
}),
).toBe(1500);
});
});

describe("getTimelineCanvasHeight", () => {
it("includes bottom scroll buffer below the last track", () => {
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
Expand Down
51 changes: 23 additions & 28 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useMemo, useCallback, useState, useLayoutEffect, memo } from "react";
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
Expand Down Expand Up @@ -42,13 +42,14 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
shouldAutoScrollTimeline,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
getTimelinePlaybackFollowScrollLeft,
getTimelinePlayheadLeft,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
Expand Down Expand Up @@ -124,6 +125,7 @@ export const Timeline = memo(function Timeline({
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
Expand Down Expand Up @@ -290,32 +292,21 @@ export const Timeline = memo(function Timeline({
expandedElements.length,
displayLayout.totalH,
]);
const previousLayoutRef = useRef(displayLayout.rowGeometry);
const previousSessionEpochRef = useRef(sessionEpoch);
useLayoutEffect(() => {
const scroll = scrollRef.current;
const previousGeometry = previousLayoutRef.current;
if (previousSessionEpochRef.current !== sessionEpoch) {
previousSessionEpochRef.current = sessionEpoch;
lastScrollLeftRef.current = 0;
if (scroll) {
scroll.scrollLeft = 0;
scroll.scrollTop = 0;
syncScrollViewport(scroll);
}
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
const nextScrollTop = getTimelineScrollTopForGeometryChange(
previousGeometry,
displayLayout.rowGeometry,
scroll.scrollTop,
);
if (nextScrollTop !== scroll.scrollTop) {
scroll.scrollTop = nextScrollTop;
syncScrollViewport(scroll);
}
}
previousLayoutRef.current = displayLayout.rowGeometry;
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({
scrollRef,
viewport,
rowGeometry: displayLayout.rowGeometry,
sessionEpoch,
elements: expandedElements,
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingRowKey: resizingClip?.element.track,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
Expand Down Expand Up @@ -468,6 +459,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setScrollRef}
data-timeline-scroll-viewport
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
tabIndex={-1}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
onScroll={(e) => {
Expand Down Expand Up @@ -505,6 +497,9 @@ export const Timeline = memo(function Timeline({
theme={theme}
displayTrackOrder={displayLayout.displayTrackOrder}
rowHeights={displayLayout.displayRowHeights}
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
Expand Down
147 changes: 147 additions & 0 deletions packages/studio/src/player/components/Timeline.virtualization.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

class MockResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
this.callback(
[
{
target,
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
} as unknown as ResizeObserverEntry,
],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
}

const originalResizeObserver = globalThis.ResizeObserver;
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
let clientWidth = 900;
let clientHeight = 240;

beforeAll(() => {
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get: () => clientWidth,
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get: () => clientHeight,
});
});

beforeEach(() => {
clientWidth = 900;
clientHeight = 240;
});

afterAll(() => {
vi.unstubAllEnvs();
globalThis.ResizeObserver = originalResizeObserver;
if (originalClientWidth)
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
if (originalClientHeight)
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
document.body.innerHTML = "";
});

describe("Timeline row virtualization", () => {
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
clientWidth = 0;
clientHeight = 0;
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: Array.from({ length: 10_000 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
start: 0,
duration: 1,
track,
})),
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 2 })));
await act(async () => {});

const rows = host.querySelectorAll('[role="listitem"]');
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);

act(() => root.unmount());
usePlayerStore.getState().reset();
}, 10_000);

it("mounts a bounded list range over the full geometry height", async () => {
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("./timelineLayout"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: Array.from({ length: 1_000 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
start: 0,
duration: 1,
track,
})),
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
await act(async () => {});

const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
expect(list?.parentElement?.style.height).toBe(
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
);

const firstRow = rows[0] as HTMLElement;
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
expect(focusedControl).not.toBeNull();
act(() => focusedControl?.focus());
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) {
scroller.scrollTop = 500 * 48;
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
});
}
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);

act(() => root.unmount());
usePlayerStore.getState().reset();
}, 10_000);
});
4 changes: 2 additions & 2 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas

{/* Breathing room between the sticky ruler and the first track lane — the
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />

<TimelineLanes
{...props}
Expand All @@ -147,7 +147,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
{/* Breathing room below the last track lane (~1.5 track heights) — a real
scrollable surface, so a clip can be dragged into the void to create a
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />

{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
menu row would collapse; a quiet tint for every gap on the selected
Expand Down
Loading
Loading