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..8873e51995 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,8 +49,10 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", - "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts" + "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", + "test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -70,6 +72,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(); + // The lease outlives the effect on purpose. Loading a fixture changes the + // player state this effect depends on, so the effect tears down right after + // the loader runs; releasing the lease there let live iframe discovery + // overwrite the fixture before anything could measure it. + expect(hasTimelinePerformanceFixtureLease()).toBe(true); + }); + + 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..88d4f1ca84 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,41 @@ 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 () => { + // The lease is deliberately NOT released here. Loading a fixture writes + // player state, which changes this effect's dependency identities and + // tears the effect down on the very next frame. Releasing on teardown + // therefore revoked the lease moments after it was taken, and live iframe + // discovery overwrote the fixture the loader had just installed. The + // lease belongs to the fixture, and a page reload clears it. // 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..28260a2752 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,14 +1,11 @@ -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"; -import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; @@ -16,14 +13,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 +32,25 @@ 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 { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; +import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; +import { useTimelineActiveClips } from "./useTimelineActiveClips"; -// Re-export pure utilities so existing imports from "./Timeline" still resolve. export { - generateTicks, - formatTimelineTickLabel, shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, getTimelineScrollLeftForZoomAnchor, + getTimelinePlaybackFollowScrollLeft, getTimelinePlayheadLeft, getTimelineCanvasHeight, shouldShowTimelineShortcutHint, @@ -52,6 +58,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 +83,7 @@ export const Timeline = memo(function Timeline({ onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, + sessionEpoch = 0, }: TimelineProps = {}) { const { onMoveElement, @@ -102,7 +115,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 +126,11 @@ 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 focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); 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; @@ -135,28 +142,13 @@ export const Timeline = memo(function Timeline({ const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); - const playheadRef = useRef(null); const containerRef = useRef(null); const scrollRef = useRef(null); 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); const [clipContextMenu, setClipContextMenu] = useState<{ @@ -164,34 +156,37 @@ export const Timeline = memo(function Timeline({ y: number; element: TimelineElement; } | null>(null); - const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; }, []); - - // 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; - const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; // Declared before the fitPps derivation so the edit-pin wrappers can close over it. const fitPpsRef = useRef(100); - const { pinZoomBeforeEdit, setRangeSelectionRef, @@ -215,11 +210,9 @@ export const Timeline = memo(function Timeline({ onBlockDrop, onCompositionDrop, }); - const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({ expandedElementsRef, }); - const { gapMenuModel, gapHighlight, @@ -249,7 +242,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -268,25 +261,41 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, 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 rowWindow = 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 { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow; const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = @@ -299,39 +308,53 @@ export const Timeline = memo(function Timeline({ setKfContextMenu, 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, - displayContentWidth, - displayDuration, - clipStateVersion, - zoomModeRef, - manualZoomPercentRef, - } = useTimelineGeometry({ - viewportWidth, - effectiveDuration, - zoomMode, - manualZoomPercent, - ppsRef, - fitPpsRef, - draggedClip, - resizingClip, - expandedElements, - isDragging, - scrollRef, - lastScrollLeftRef, + const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } = + useTimelineGeometry({ + viewportWidth: viewport.clientWidth, + effectiveDuration, + zoomMode, + manualZoomPercent, + ppsRef, + fitPpsRef, + draggedClip, + resizingClip, + expandedElements, + isDragging, + scrollRef, + lastScrollLeftRef, + contentOrigin, + }); + const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({ + tracks, + viewport, + pixelsPerSecond: pps, contentOrigin, + duration: displayDuration, + selectedElementId: selectedElementId ?? undefined, + draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined, + resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined, + revealElementId: clipRevealRequest?.elementId, + focusedEaseElementId: focusedEaseSegment?.elementId, + clipContextMenuElementId: clipContextMenu + ? getTimelineElementIdentity(clipContextMenu.element) + : undefined, + keyframeContextMenuElementId: kfContextMenu + ? getTimelineElementIdentity(kfContextMenu.element) + : undefined, + focusedElementId, + scrollRef, + elements: expandedElements, + rowGeometry: displayLayout.rowGeometry, + allowHorizontalReveal: zoomMode === "manual", + rowVirtualizationActive, + sessionEpoch, + }); + useTimelineActiveClips({ + scrollRef, + currentTime, + clipStateVersion: renderTimeRange, + elementStateVersion: expandedElements, }); - const laneGapStrips = useTimelineGapHighlights({ gapHighlight, tracks, @@ -364,11 +387,6 @@ export const Timeline = memo(function Timeline({ onSeek, contentOrigin, }); - useTimelineActiveClips({ - scrollRef, - currentTime, - clipStateVersion, - }); const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = useTimelineRazorInteraction({ active: activeTool === "razor", @@ -400,47 +418,25 @@ 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); - } - }); + useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => + 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], + const { major, minor, majorTickInterval } = useTimelineTicks( + displayDuration, + pps, + timeDisplayMode, + rowVirtualizationActive ? renderTimeRange : undefined, ); - 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 +456,7 @@ export const Timeline = memo(function Timeline({
{ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload recordTimelineScroll(e.currentTarget); + syncScrollViewport(e.currentTarget, true); }} + {...rowWindow.timelineFocusProps} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} onDrop={handleAssetDrop} @@ -507,6 +508,12 @@ export const Timeline = memo(function Timeline({ theme={theme} displayTrackOrder={displayLayout.displayTrackOrder} rowHeights={displayLayout.displayRowHeights} + rowGeometry={displayLayout.rowGeometry} + virtualRows={virtualRows} + rowsVirtualized={rowVirtualizationActive} + clipIndex={clipIndex} + renderTimeRange={renderTimeRange} + pinnedClipIdentities={pinnedClipIdentities} trackOrder={trackOrder} tracks={tracks} trackStyles={trackStyles} @@ -520,7 +527,10 @@ export const Timeline = memo(function Timeline({ blockedClipRef={blockedClipRef} suppressClickRef={suppressClickRef} scrollRef={scrollRef} - renderClipContent={renderClipContent} + // Windowing drops content to mount a row cheaply; unvirtualized it is pure cost. + renderClipContent={ + rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent + } renderClipOverlay={renderClipOverlay} playheadRef={playheadRef} onDrillDown={onDrillDown} 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..defd60b0f2 --- /dev/null +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -0,0 +1,429 @@ +// @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 = ""; +}); + +/** + * The virtualized list only mounts rows/clips after its ResizeObserver and the + * follow-up layout effect have both flushed, which is more than one React tick. + * A fixed number of flushes is a coin flip once the rest of the suite is + * competing for workers, so wait for the DOM the assertions actually need. + */ +async function settleUntil(predicate: () => boolean, tries = 60): Promise { + for (let attempt = 0; attempt < tries; attempt++) { + if (predicate()) return; + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + } +} + +async function advanceFrame(): Promise { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); +} + +async function scrollTimelineHorizontally( + scroller: HTMLElement, + scrollLeft: number, +): Promise { + scroller.scrollLeft = scrollLeft; + await act(async () => { + scroller.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); +} + +// These tests mount 500-10,000 timeline elements and settle the virtualizer, +// which lands right on the 5s default once the rest of the suite is competing +// for workers. The generous ceiling is a flake guard, not an expected runtime. +describe("Timeline row virtualization", { timeout: 30_000 }, () => { + 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(); + }); + + it("defers rich clip content while scrolling without replacing the clip shell", async () => { + const [{ Timeline }, { usePlayerStore }] = await Promise.all([ + import("./Timeline"), + import("../store/playerStore"), + ]); + usePlayerStore.setState({ + duration: 60, + timelineReady: true, + selectedElementId: "clip-0", + elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }], + }); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => + root.render( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ), + ); + await act(async () => {}); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 110)); + }); + + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + const clip = host.querySelector('[data-el-id="clip-0"]'); + expect(scroller).not.toBeNull(); + expect(clip).not.toBeNull(); + expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s"); + expect(host.querySelector("[data-rich-content]")).not.toBeNull(); + + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip); + expect(host.querySelector("[data-rich-content]")).toBeNull(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 110)); + }); + expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip); + expect(host.querySelector("[data-rich-content]")).not.toBeNull(); + } finally { + act(() => root.unmount()); + usePlayerStore.getState().reset(); + } + }); + + 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, + // Repeated fixture shape intentionally contrasts row and clip windowing scales. + // fallow-ignore-next-line code-duplication + 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 () => {}); + + await settleUntil( + () => + (host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) > + 0, + ); + 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(); + }); + + it("windows clips and ruler cells while retaining an off-window selected clip", async () => { + const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([ + import("./Timeline"), + import("../store/playerStore"), + import("../lib/timelineViewportBudgets"), + ]); + usePlayerStore.setState({ + duration: 1_000, + timelineReady: true, + zoomMode: "manual", + manualZoomPercent: 2_000, + selectedElementId: "clip-490", + selectedElementIds: new Set(["clip-490"]), + // Repeated fixture shape intentionally contrasts row and clip windowing scales. + // fallow-ignore-next-line code-duplication + elements: Array.from({ length: 500 }, (_, index) => ({ + id: `clip-${index}`, + tag: "div", + start: index * 2, + duration: 1, + track: 0, + })), + }); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 }))); + await act(async () => {}); + + await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1); + const initialClips = [...host.querySelectorAll("[data-clip]")]; + const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]"); + expect(initialClips.length).toBeGreaterThan(1); + expect(initialClips.length).toBeLessThanOrEqual( + TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1, + ); + expect(initialGridCells.length).toBeLessThan(100); + expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); + const initialWindowIds = initialClips.map((clip) => clip.dataset.elId); + + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + expect(scroller).not.toBeNull(); + if (scroller) await scrollTimelineHorizontally(scroller, 8_000); + + const scrolledClips = [...host.querySelectorAll("[data-clip]")]; + expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds); + expect(scrolledClips.length).toBeLessThanOrEqual( + TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1, + ); + expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); + expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100); + + await act(async () => usePlayerStore.getState().requestClipReveal("clip-300")); + await advanceFrame(); + await act(async () => {}); + expect(usePlayerStore.getState().clipRevealRequest).toBeNull(); + const focusedClip = host.querySelector('[data-el-id="clip-300"]'); + expect(document.activeElement).toBe(focusedClip); + await advanceFrame(); + expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip); + expect(document.activeElement).toBe(focusedClip); + expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull(); + + if (scroller) await scrollTimelineHorizontally(scroller, 0); + expect(host.querySelector('[data-el-id="clip-300"]')).toBe(focusedClip); + expect(document.activeElement).toBe(focusedClip); + + act(() => root.unmount()); + usePlayerStore.getState().reset(); + }); +}); + +/** + * The flag-off build is the one users get today. It mounts every clip, so the + * scroll-time concessions windowing makes are pure cost there: this block pins + * the timeline to doing no per-frame work at all while a gesture runs. + */ +describe("Timeline without row virtualization", { timeout: 30_000 }, () => { + async function renderUnvirtualizedTimeline() { + vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0"); + vi.resetModules(); + const [{ Timeline }, { usePlayerStore }] = await Promise.all([ + import("./Timeline"), + import("../store/playerStore"), + ]); + usePlayerStore.setState({ + duration: 60, + timelineReady: true, + selectedElementId: "clip-0", + elements: Array.from({ length: 40 }, (_, track) => ({ + id: `clip-${track}`, + label: `Clip ${track}`, + tag: "div", + start: 0, + duration: 10, + track, + })), + }); + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => + root.render( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ), + ); + await act(async () => {}); + return { + host, + dispose: () => { + act(() => root.unmount()); + usePlayerStore.getState().reset(); + vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1"); + vi.resetModules(); + }, + }; + } + + it("mounts every clip rather than a window", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + expect(host.querySelectorAll("[data-clip]").length).toBe(40); + } finally { + dispose(); + } + }); + + it("keeps clip content mounted across a scroll gesture", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + expect(scroller).not.toBeNull(); + const richBefore = host.querySelectorAll("[data-rich-content]").length; + expect(richBefore).toBe(40); + + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(host.querySelectorAll("[data-rich-content]").length).toBe(richBefore); + } finally { + dispose(); + } + }); + + it("does not swap clip content back in after the gesture settles", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + const clip = host.querySelector('[data-el-id="clip-0"]'); + await act(async () => { + scroller?.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip); + expect(host.querySelectorAll("[data-rich-content]").length).toBe(40); + } finally { + dispose(); + } + }); + + it("leaves the scroll position alone, so no snapshot round trip happens", async () => { + const { host, dispose } = await renderUnvirtualizedTimeline(); + try { + const scroller = host.querySelector("[data-timeline-scroll-viewport]"); + if (!scroller) throw new Error("Expected a timeline scroll viewport"); + scroller.scrollTop = 400; + await act(async () => { + scroller.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + + expect(scroller.scrollTop).toBe(400); + } finally { + dispose(); + } + }); +}); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 3cb0d52e64..edc94b171a 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -23,8 +23,9 @@ import { TimelineClip } from "./TimelineClip"; import { TimelineLanes } from "./TimelineLanes"; import type { TimelineLaneBaseProps } from "./timelineLaneProps"; import { renderClipChildren } from "./timelineClipChildren"; -import { useTimelineRevealClip } from "./useTimelineRevealClip"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; +import { isTimelineClipActive } from "./useTimelineActiveClips"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; interface TimelineCanvasProps extends TimelineLaneBaseProps { major: number[]; @@ -62,14 +63,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas onRazorSplitAll, } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); - // Scroll a clip into view when the sidebar (asset card) requests a reveal. - useTimelineRevealClip(scrollRef); const draggedElement = draggedClip?.element ?? null; + const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null; const activeDraggedElement = - draggedClip?.started === true && draggedElement + draggedClip?.started === true && draggedElement && draggedElementIdentity ? getRenderedTimelineElement({ element: draggedElement, - draggedElementId: draggedElement.key ?? draggedElement.id, + draggedElementId: draggedElementIdentity, previewStart: draggedClip.previewStart, previewTrack: draggedClip.previewTrack, }) @@ -85,10 +85,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas // the wall together and never deforms. Matches what the commit will do — see // timelineMultiDragPreview + commit. const multiDragPreview: MultiDragPreviewInput | null = - draggedClip?.started === true && draggedElement + draggedClip?.started === true && draggedElement && draggedElementIdentity ? { dragStarted: true, - draggedKey: draggedElement.key ?? draggedElement.id, + draggedKey: draggedElementIdentity, draggedOriginStart: draggedElement.start, draggedPreviewStart: draggedClip.previewStart, selectedKeys: selectedElementIds, @@ -126,11 +126,12 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas theme={props.theme} beatAnalysis={props.beatAnalysis} contentOrigin={props.contentOrigin} + renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined} /> {/* Breathing room between the sticky ruler and the first track lane — the top half of the CapCut-style padding (see TRACKS_TOP_PAD). */} -