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 1f5bf769a6..0d36d9ea90 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,6 +49,7 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts" }, @@ -70,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/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5bdece6459..8874acb80e 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -48,6 +48,7 @@ export interface NLEContextValue { compositionLoading: boolean; setCompositionLoading: (loading: boolean) => void; timelineDisabled: boolean; + timelineSessionEpoch: number; hasLoadedOnceRef: React.MutableRefObject; // preview composition size (for preview block drop) previewCompositionSize: { width: number; height: number } | null; @@ -103,7 +104,7 @@ export function NLEProvider({ // project would otherwise keep rendering (and re-fetching from) the old project // after switching. useEffect(() => { - usePlayerStore.getState().reset(); + usePlayerStore.getState().beginTimelineSession(projectId); useAssetPreviewStore.getState().clearPreviewAsset(); }, [projectId]); @@ -289,6 +290,7 @@ export function NLEProvider({ setCompositionLoadingRaw(loading); }, []); const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading); + const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch); useEffect(() => { onCompositionLoadingChange?.(compositionLoading); @@ -319,6 +321,7 @@ export function NLEProvider({ compositionLoading, setCompositionLoading, timelineDisabled, + timelineSessionEpoch, hasLoadedOnceRef, previewCompositionSize, setPreviewCompositionSize, diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 52f10ae87d..ca40802056 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -127,6 +127,7 @@ export function TimelinePane({ persistTimelineH, containerRef, timelineDisabled, + timelineSessionEpoch, } = useNLEContext(); // Move/resize/split come from the timeline edit context, not props — the @@ -271,6 +272,7 @@ export function TimelinePane({ >
{timelineToolbar}
{ + afterEach(() => { + setTimelinePerformanceFixtureLease(false); + window.__studioTest = undefined; + usePlayerStore.getState().reset(); + }); + + it("generates the expected dense-short 50k distribution", () => { + const first = createTimelinePerformanceFixture({ + elementCount: 50_000, + profile: "dense-short", + }); + + expect(first.summary).toEqual({ + elementCount: 50_000, + profile: "dense-short", + duration: 120, + trackCount: 1_000, + keyframedElementCount: 0, + expandedElementCount: 0, + }); + expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000); + const perTrack = new Map(); + for (const element of first.elements) { + perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1); + } + expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128); + }); + + it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => { + const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile }); + const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile }); + expect(second).toEqual(first); + }); + + it.each(PROFILES)("builds the %s 1k scale profile", (profile) => { + const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile }); + expect(fixture.elements).toHaveLength(1_000); + expect(fixture.summary.elementCount).toBe(1_000); + expect(fixture.summary.duration).toBeGreaterThan(0); + expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000); + if (profile === "keyframe-heavy-expanded") { + expect(fixture.keyframeCache.size).toBe(1_000); + expect(fixture.gsapAnimations.size).toBe(1_000); + expect(fixture.expandedClipIds.size).toBe(1_000); + } + }); + + it("publishes one dev-only loader that replaces fixture state atomically", () => { + const host = document.createElement("div"); + const root = createRoot(host); + act(() => root.render()); + const api = window.__studioTest; + expect(api).toBeDefined(); + if (!api) throw new Error("Expected dev Studio test API"); + let notifications = 0; + usePlayerStore.setState({ + isPlaying: true, + requestedSeekTime: 42, + clipRevealRequest: { elementId: "stale", nonce: 7 }, + clipManifest: [], + lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]), + }); + const unsubscribe = usePlayerStore.subscribe(() => { + notifications += 1; + }); + + const summary = api.loadTimelinePerformanceFixture({ + elementCount: 1_000, + profile: "keyframe-heavy-expanded", + }); + + expect(summary.elementCount).toBe(1_000); + expect(notifications).toBe(1); + expect(usePlayerStore.getState()).toMatchObject({ + isPlaying: false, + requestedSeekTime: null, + clipRevealRequest: null, + clipManifest: null, + duration: 600, + timelineReady: true, + }); + expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0); + expect(usePlayerStore.getState().elements).toHaveLength(1_000); + expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); + expect(hasTimelinePerformanceFixtureLease()).toBe(true); + + api.resetTimelinePerformanceFixture(); + expect(notifications).toBe(2); + expect(usePlayerStore.getState()).toMatchObject({ + isPlaying: false, + duration: 0, + timelineReady: false, + elements: [], + }); + expect(hasTimelinePerformanceFixtureLease()).toBe(true); + unsubscribe(); + act(() => root.unmount()); + expect(window.__studioTest).toBeUndefined(); + expect(hasTimelinePerformanceFixtureLease()).toBe(false); + }); + + it("does not mutate state when the fixture request is invalid", () => { + const host = document.createElement("div"); + const root = createRoot(host); + act(() => root.render()); + const api = window.__studioTest; + if (!api) throw new Error("Expected dev Studio test API"); + const before = usePlayerStore.getState().elements; + + expect(() => + Reflect.apply(api.loadTimelinePerformanceFixture, api, [ + { elementCount: 999, profile: "dense-short" }, + ]), + ).toThrow("elementCount must be 1000 or 50000"); + expect(usePlayerStore.getState().elements).toBe(before); + expect(() => + Reflect.apply(api.loadTimelinePerformanceFixture, api, [ + { elementCount: 1_000, profile: "constructor" }, + ]), + ).toThrow("Unknown timeline performance fixture profile"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index a434677c4a..1124b22904 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -1,5 +1,17 @@ import { useEffect } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; +import { createTimelineResetState, usePlayerStore } from "../player/store/playerStore"; +import { + readTimelinePerformanceDiagnostics, + type TimelinePerformanceDiagnostics, +} from "../player/lib/timelinePerformanceDiagnostics"; +import { + createTimelinePerformanceFixture, + setTimelinePerformanceFixtureLease, + type TimelinePerformanceFixtureSpec, + type TimelinePerformanceFixtureSummary, +} from "../player/lib/timelinePerformanceFixture"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets"; interface StudioTestHookDeps { previewIframeRef: React.MutableRefObject; @@ -12,6 +24,12 @@ interface StudioTestHookDeps { interface StudioTestApi { selectByDomId: (id: string) => Promise; + loadTimelinePerformanceFixture: ( + spec: TimelinePerformanceFixtureSpec, + ) => TimelinePerformanceFixtureSummary; + resetTimelinePerformanceFixture: () => void; + readTimelinePerformanceDiagnostics: () => Readonly; + timelineViewportBudgets: typeof TIMELINE_VIEWPORT_BUDGETS; } declare global { @@ -52,9 +70,36 @@ export function useStudioTestHooks({ applyDomSelection(selection, { revealPanel: true }); return true; }, + loadTimelinePerformanceFixture: (spec) => { + const fixture = createTimelinePerformanceFixture(spec); + setTimelinePerformanceFixtureLease(true); + usePlayerStore.setState({ + ...createTimelineResetState(), + currentTime: 0, + duration: fixture.summary.duration, + timelineReady: true, + loopEnabled: false, + zoomMode: "manual", + manualZoomPercent: 2_000, + elements: fixture.elements, + selectedElementId: null, + selectedElementIds: new Set(), + selectedKeyframes: new Set(), + keyframeCache: fixture.keyframeCache, + gsapAnimations: fixture.gsapAnimations, + expandedClipIds: fixture.expandedClipIds, + }); + return fixture.summary; + }, + resetTimelinePerformanceFixture: () => { + usePlayerStore.getState().reset(); + }, + readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(), + timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS, }; window.__studioTest = api; return () => { + setTimelinePerformanceFixtureLease(false); // delete, not `= undefined`: an own key holding undefined keeps // `"__studioTest" in window` true, which defeats feature detection. delete window.__studioTest; 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/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 7335a0fadf..f415c9fce0 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -12,11 +12,14 @@ import { getTimelineCanvasHeight, resolveTimelineAssetDrop, getTimelinePlayheadLeft, + getTimelinePlaybackFollowScrollLeft, getTimelineScrollLeftForZoomAnchor, getTimelineScrollLeftForZoomTransition, shouldShowTimelineShortcutHint, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, + getTimelineVisibleTimeRange, + getTimelineScrollTopForGeometryChange, } from "./Timeline"; import { CLIP_Y, @@ -32,6 +35,7 @@ import { getTimelineDisplayContentWidth, getTimelineFitPps, getTimelineLaneTop, + createTimelineRowGeometry, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -44,6 +48,25 @@ afterEach(() => { usePlayerStore.getState().reset(); }); +describe("timeline viewport geometry", () => { + it("derives a clamped visible time range from the raw viewport", () => { + expect( + getTimelineVisibleTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20), + ).toEqual({ start: 1, end: 6 }); + expect(getTimelineVisibleTimeRange({ scrollLeft: 0, clientWidth: 100 }, 100, 200, 20)).toEqual({ + start: 0, + end: 0, + }); + }); + + it("keeps the same row anchored when a row above it expands", () => { + const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]); + const next = createTimelineRowGeometry([1, 2, 3], [104, 48, 48]); + const scrollTop = previous.getRowTop(2) - RULER_H + 6; + expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56); + }); +}); + function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { const clip = host.querySelector(`[data-el-id="${clipId}"]`); if (!clip) throw new Error(`Missing timeline clip ${clipId}`); @@ -252,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(); @@ -954,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 1c3022c858..e3b79b95a0 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,10 +1,8 @@ -import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react"; +import { useRef, useMemo, useCallback, useState, memo } from "react"; import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; -import { isMusicTrack } from "../../utils/timelineInspector"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; -import { useMountEffect } from "../../hooks/useMountEffect"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; @@ -16,14 +14,12 @@ import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays } from "./TimelineOverlays"; -import { animationContributesLane } from "./TimelinePropertyLanes"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; -import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout"; +import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; -import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; import { @@ -37,14 +33,23 @@ import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; +import { + getEffectiveTimelineDuration, + getTimelinePreviewElement, + hasKeyframedTimelineClips, +} from "./timelineViewModel"; +import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; +import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; +import { useTimelineTicks } from "./useTimelineTicks"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { - generateTicks, - formatTimelineTickLabel, shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, getTimelineScrollLeftForZoomAnchor, + getTimelinePlaybackFollowScrollLeft, getTimelinePlayheadLeft, getTimelineCanvasHeight, shouldShowTimelineShortcutHint, @@ -52,6 +57,12 @@ export { shouldHandleTimelineDeleteKey, getDefaultDroppedTrack, } from "./timelineLayout"; +export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; + +export { + getTimelineScrollTopForGeometryChange, + getTimelineVisibleTimeRange, +} from "./timelineViewportGeometry"; export const Timeline = memo(function Timeline({ onSeek, @@ -71,6 +82,7 @@ export const Timeline = memo(function Timeline({ onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, + sessionEpoch = 0, }: TimelineProps = {}) { const { onMoveElement, @@ -102,7 +114,7 @@ export const Timeline = memo(function Timeline({ const rawElements = usePlayerStore((s) => s.elements); const expandedElements = useExpandedTimelineElements(); const beatAnalysis = usePlayerStore((s) => s.beatAnalysis); - const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null); + const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); const beatEdits = usePlayerStore((s) => s.beatEdits); const adjustedBeatAnalysis = useMemo( () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits), @@ -113,17 +125,12 @@ 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). const hasKeyframedClips = useMemo( - () => - Array.from(gsapAnimations.values()).some((list) => - // Same lane-contribution predicate the layout uses: real keyframes OR a - // synthesizable flat tween. Checking animation.keyframes alone left a - // flat-tween-only comp without its reserved label column. - list.some((animation) => animationContributesLane(animation)), - ), + () => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations], ); const labelMode = hasKeyframedClips; @@ -142,20 +149,7 @@ export const Timeline = memo(function Timeline({ const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); - const [shiftHeld, setShiftHeld] = useState(false); - - useMountEffect(() => { - const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); - const blur = () => setShiftHeld(false); - window.addEventListener("keydown", key); - window.addEventListener("keyup", key); - window.addEventListener("blur", blur); - return () => { - window.removeEventListener("keydown", key); - window.removeEventListener("keyup", key); - window.removeEventListener("blur", blur); - }; - }); + const shiftHeld = useTimelineShiftModifier(); const [showPopover, setShowPopover] = useState(false); const [kfContextMenu, setKfContextMenu] = useState(null); @@ -172,17 +166,27 @@ export const Timeline = memo(function Timeline({ // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom). const lastScrollLeftRef = useRef(0); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - if (rawElements.length === 0) return safeDur; - const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration)); - return Number.isFinite(result) ? result : safeDur; - }, [rawElements, duration]); + const effectiveDuration = useMemo( + () => getEffectiveTimelineDuration(duration, rawElements), + [duration, rawElements], + ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); - const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } = - useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds); + const { + tracks, + trackStyles, + trackOrder, + trackOrderRef, + laneCounts, + rowGeometry, + rowGeometryRef, + } = useTimelineTrackLayout( + expandedElements, + gsapAnimations, + selectedElementId, + selectedElementIds, + ); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -249,7 +253,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -268,7 +272,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, @@ -276,17 +280,33 @@ export const Timeline = memo(function Timeline({ onCompositionDrop: pinnedOnCompositionDrop, }); - const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ totalClipCount: expandedElements.length, totalRowCount: displayLayout.displayTrackOrder.length, zoomMode, }); - const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ - timelineReady, - expandedElements.length, - displayLayout.totalH, - ]); + const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = + useTimelineScrollViewport(scrollRef, [ + timelineReady, + expandedElements.length, + displayLayout.totalH, + ]); + 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 } = @@ -300,14 +320,6 @@ export const Timeline = memo(function Timeline({ toggleSelectedKeyframe, }); - const selectedElement = useMemo( - () => - expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, - [expandedElements, selectedElementId], - ); - const selectedElementRef = useRef(selectedElement); - selectedElementRef.current = selectedElement; - const { pps, fitPps, @@ -317,7 +329,7 @@ export const Timeline = memo(function Timeline({ zoomModeRef, manualZoomPercentRef, } = useTimelineGeometry({ - viewportWidth, + viewportWidth: viewport.clientWidth, effectiveDuration, zoomMode, manualZoomPercent, @@ -400,47 +412,21 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onSelectElement, contentOrigin, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag - const prevSelectedRef = useRef(selectedElementRef.current); - // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps - useEffect(() => { - const prev = prevSelectedRef.current; - const curr = selectedElementRef.current; - prevSelectedRef.current = curr; - if (prev && !curr) { - setShowPopover(false); - setRangeSelection(null); - } - }); - - // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames. - const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; - const { major, minor } = useMemo( - () => generateTicks(displayDuration, pps, tickFps), - [displayDuration, pps, tickFps], + useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => + setRangeSelection(null), ); + + const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode); const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration; const getPreviewElement = useCallback( - (element: TimelineElement): TimelineElement => { - if ( - resizingClip && - (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id) - ) { - return { - ...element, - start: resizingClip.previewStart, - duration: resizingClip.previewDuration, - playbackStart: resizingClip.previewPlaybackStart, - }; - } - return element; - }, + (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), [resizingClip], ); @@ -460,6 +446,7 @@ export const Timeline = memo(function Timeline({
{ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload recordTimelineScroll(e.currentTarget); + syncScrollViewport(e.currentTarget, true); }} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} @@ -507,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). */} -