diff --git a/bun.lock b/bun.lock index 3daf65a1c7..85a5cbd6ff 100644 --- a/bun.lock +++ b/bun.lock @@ -330,6 +330,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", @@ -1112,6 +1113,10 @@ "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.6", "", { "dependencies": { "@tanstack/virtual-core": "3.17.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.4", "", {}, "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw=="], + "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 060f5cf032..9094c8ad20 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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", diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 8d8dec2a37..f415c9fce0 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -12,6 +12,7 @@ import { getTimelineCanvasHeight, resolveTimelineAssetDrop, getTimelinePlayheadLeft, + getTimelinePlaybackFollowScrollLeft, getTimelineScrollLeftForZoomAnchor, getTimelineScrollLeftForZoomTransition, shouldShowTimelineShortcutHint, @@ -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('[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(); @@ -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( diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e3fe4b9c55..e3b79b95a0 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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"; @@ -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, @@ -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). @@ -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 } = @@ -468,6 +459,7 @@ export const Timeline = memo(function Timeline({
{ @@ -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} diff --git a/packages/studio/src/player/components/Timeline.virtualization.test.tsx b/packages/studio/src/player/components/Timeline.virtualization.test.tsx new file mode 100644 index 0000000000..149d01ab85 --- /dev/null +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -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('[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("button"); + expect(focusedControl).not.toBeNull(); + act(() => focusedControl?.focus()); + const scroller = host.querySelector("[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); +}); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 3cb0d52e64..18b0cfb63b 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -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). */} -