From af82757e2ef2c4a904d373ba81b1a1003ba10164 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:28:52 +0200 Subject: [PATCH] perf(studio): centralize timeline viewport geometry --- .../studio/src/components/nle/NLEContext.tsx | 5 +- .../src/components/nle/TimelinePane.tsx | 2 + .../src/hooks/useStudioTestHooks.test.tsx | 5 + .../studio/src/hooks/useStudioTestHooks.ts | 3 + .../src/player/components/Timeline.test.ts | 22 +++ .../studio/src/player/components/Timeline.tsx | 79 +++++++-- .../src/player/components/TimelineTypes.ts | 2 + .../src/player/components/timelineDragDrop.ts | 10 +- .../player/components/timelineLayout.test.ts | 20 +++ .../src/player/components/timelineLayout.ts | 160 +++++++++++++----- .../components/timelineViewportGeometry.ts | 31 ++++ .../components/useTimelineActiveClips.ts | 2 +- .../player/components/useTimelineClipDrag.ts | 26 ++- .../player/components/useTimelineGeometry.ts | 15 +- .../components/useTimelineRangeSelection.ts | 9 +- .../useTimelineScrollViewport.test.tsx | 78 +++++++++ .../components/useTimelineScrollViewport.ts | 89 +++++++++- .../components/useTimelineTrackLayout.test.ts | 2 + .../components/useTimelineTrackLayout.ts | 57 ++++--- .../src/player/hooks/useTimelinePlayer.ts | 4 + .../studio/src/player/lib/playbackScrub.ts | 4 +- .../player/lib/timelineElementIndexes.test.ts | 31 ++++ .../src/player/lib/timelineElementIndexes.ts | 43 +++++ .../player/lib/timelinePerformanceFixture.ts | 10 ++ .../src/player/store/playerStore.test.ts | 15 ++ .../studio/src/player/store/playerStore.ts | 79 +++++---- .../timeline-virtualization/index.html | 1 + .../tests/e2e/timeline-virtualization.mjs | 67 ++++++-- 28 files changed, 703 insertions(+), 168 deletions(-) create mode 100644 packages/studio/src/player/components/timelineViewportGeometry.ts create mode 100644 packages/studio/src/player/components/useTimelineScrollViewport.test.tsx create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.test.ts create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.ts diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 968fcafb75..6a7956fbb3 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(); }); @@ -101,9 +104,11 @@ describe("timeline performance fixture", () => { }); expect(usePlayerStore.getState().elements).toHaveLength(1_000); expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); + 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", () => { diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 3949c2d253..47467e2449 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -7,6 +7,7 @@ import { } from "../player/lib/timelinePerformanceDiagnostics"; import { createTimelinePerformanceFixture, + setTimelinePerformanceFixtureLease, type TimelinePerformanceFixtureSpec, type TimelinePerformanceFixtureSummary, } from "../player/lib/timelinePerformanceFixture"; @@ -70,6 +71,7 @@ export function useStudioTestHooks({ }, loadTimelinePerformanceFixture: (spec) => { const fixture = createTimelinePerformanceFixture(spec); + setTimelinePerformanceFixtureLease(true); usePlayerStore.setState({ currentTime: 0, duration: fixture.summary.duration, @@ -91,6 +93,7 @@ export function useStudioTestHooks({ }; window.__studioTest = api; return () => { + setTimelinePerformanceFixtureLease(false); window.__studioTest = undefined; }; }, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]); diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index ed0310814e..164dcf9fd1 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -17,6 +17,8 @@ import { shouldShowTimelineShortcutHint, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, + getTimelineVisibleTimeRange, + getTimelineScrollTopForGeometryChange, } from "./Timeline"; import { CLIP_Y, @@ -32,6 +34,7 @@ import { getTimelineDisplayContentWidth, getTimelineFitPps, getTimelineLaneTop, + createTimelineRowGeometry, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -44,6 +47,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}`); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e0ebe2c18b..6dc27f8660 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,6 +1,5 @@ -import { useRef, useMemo, useCallback, useState, memo } from "react"; +import { useRef, useMemo, useCallback, useState, useLayoutEffect, 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"; @@ -42,6 +41,8 @@ import { import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; import { useTimelineTicks } from "./useTimelineTicks"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -58,6 +59,11 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; +export { + getTimelineScrollTopForGeometryChange, + getTimelineVisibleTimeRange, +} from "./timelineViewportGeometry"; + export const Timeline = memo(function Timeline({ onSeek, onDrillDown, @@ -76,6 +82,7 @@ export const Timeline = memo(function Timeline({ onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, + sessionEpoch = 0, }: TimelineProps = {}) { const { onMoveElement, @@ -108,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), @@ -163,8 +170,20 @@ export const Timeline = memo(function Timeline({ 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; @@ -231,7 +250,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -250,7 +269,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, @@ -258,12 +277,39 @@ export const Timeline = memo(function Timeline({ onCompositionDrop: pinnedOnCompositionDrop, }); - const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); - const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ - timelineReady, - expandedElements.length, - displayLayout.totalH, - ]); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); + const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = + useTimelineScrollViewport(scrollRef, [ + timelineReady, + 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 selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = @@ -286,7 +332,7 @@ export const Timeline = memo(function Timeline({ zoomModeRef, manualZoomPercentRef, } = useTimelineGeometry({ - viewportWidth, + viewportWidth: viewport.clientWidth, effectiveDuration, zoomMode, manualZoomPercent, @@ -369,7 +415,7 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onSelectElement, contentOrigin, }); @@ -403,6 +449,7 @@ export const Timeline = memo(function Timeline({
{ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload + syncScrollViewport(e.currentTarget, true); }} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index 95313d617f..0838e8dccb 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks"; export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides { + /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */ + sessionEpoch?: number; onSeek?: (time: number) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 0d3985b7a7..8cd2bfbe78 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -5,7 +5,7 @@ import { TIMELINE_COMPOSITION_MIME, } from "../../utils/timelineCompositionDrop"; import { usePlayerStore } from "../store/playerStore"; -import { resolveTimelineAssetDrop } from "./timelineLayout"; +import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout"; import type { TimelineDropCallbacks } from "./timelineCallbacks"; interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { @@ -13,7 +13,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { ppsRef: RefObject; durationRef: RefObject; trackOrderRef: RefObject; - rowHeightsRef: RefObject; + rowGeometryRef: RefObject; contentOrigin: number; } @@ -56,7 +56,7 @@ export function useTimelineAssetDrop({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop, onAssetDrop, @@ -93,7 +93,7 @@ export function useTimelineAssetDrop({ pixelsPerSecond: ppsRef.current, duration: durationRef.current, clampStartToDuration: !usePointerStart, - rowHeights: rowHeightsRef.current, + rowHeights: rowGeometryRef.current.rowHeights, trackOrder: trackOrderRef.current, }, clientX, @@ -104,7 +104,7 @@ export function useTimelineAssetDrop({ track: pointer.track, }; }, - [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin], + [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin], ); const handleAssetDrop = useCallback( diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index a85215c0e1..ef9ee8726b 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -10,6 +10,8 @@ import { getTimelineRowFromY, getTimelineRowOffsets, getTimelineCanvasHeight, + createTimelineRowGeometry, + getTimelineRowGeometry, trackHeights, resolveTimelineAssetDrop, } from "./timelineLayout"; @@ -56,6 +58,24 @@ describe("variable timeline row geometry", () => { RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD, ); }); + + it("reuses one immutable geometry snapshot for one height array", () => { + const heights = trackHeights(tracks, new Set(["b"])); + const first = getTimelineRowGeometry(heights); + expect(getTimelineRowGeometry(heights)).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.rowOffsets)).toBe(true); + }); + + it("looks up row boundaries through the precomputed geometry", () => { + const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]); + expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry); + expect(geometry.getRowIndex(8)).toBe(1); + expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1); + expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2); + expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2); + expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD); + }); }); describe("collapsed timeline row geometry characterization", () => { diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index c5d6ea6576..302a84d60b 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -85,32 +85,127 @@ function validRowHeight(height: number | undefined): number { return height; } +export interface TimelineRowGeometry { + readonly rowKeys: readonly number[]; + readonly rowHeights: readonly number[]; + /** Cumulative row boundaries, including the final bottom boundary. */ + readonly rowOffsets: readonly number[]; + readonly rowsHeight: number; + readonly canvasHeight: number; + getRowIndex(rowKey: number): number; + getRowHeight(row: number): number; + getRowTop(row: number): number; + getRowFromY(contentY: number): number; + getRowPositionFromY(contentY: number): { + rowFloat: number; + row: number; + fraction: number; + rowHeight: number; + }; +} + +const rowGeometryCache = new WeakMap(); +const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]); + +/** Build the immutable row snapshot shared by rendering and hit testing. */ +export function createTimelineRowGeometry( + rowKeys: readonly number[], + rowHeights: readonly number[], +): TimelineRowGeometry { + const heights = Object.freeze(rowHeights.map(validRowHeight)); + const keys = Object.freeze( + heights.map((_, row) => { + const key = rowKeys[row]; + return key !== undefined && Number.isFinite(key) ? key : row; + }), + ); + const offsets = [0]; + for (const height of heights) offsets.push((offsets.at(-1) ?? 0) + height); + Object.freeze(offsets); + const rowIndexByKey = new Map(keys.map((key, row) => [key, row])); + + const getRowHeight = (row: number) => validRowHeight(heights[row]); + const getRowOffset = (row: number) => { + if (heights.length === 0) return row * TRACK_H; + if (row <= 0) return row * getRowHeight(0); + if (row >= heights.length) { + return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H; + } + const wholeRow = Math.floor(row); + return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow); + }; + const getRowFromY = (contentY: number) => { + const y = contentY - RULER_H - TRACKS_TOP_PAD; + if (heights.length === 0) return y / TRACK_H; + if (y < 0) return y / getRowHeight(0); + const rowsHeight = offsets[heights.length] ?? 0; + if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H; + + // First boundary strictly greater than y. Unlike the old linear scan this + // stays logarithmic for large timelines and uses the precomputed offsets. + let low = 1; + let high = heights.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((offsets[mid] ?? 0) > y) high = mid; + else low = mid + 1; + } + const row = low - 1; + return row + (y - (offsets[row] ?? 0)) / getRowHeight(row); + }; + const geometry: TimelineRowGeometry = { + rowKeys: keys, + rowHeights: heights, + rowOffsets: offsets, + rowsHeight: offsets.at(-1) ?? 0, + canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD, + getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1, + getRowHeight, + getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row), + getRowFromY, + getRowPositionFromY: (contentY) => { + const rowFloat = getRowFromY(contentY); + const row = Math.floor(rowFloat); + return { rowFloat, row, fraction: rowFloat - row, rowHeight: getRowHeight(row) }; + }, + }; + const frozenGeometry = Object.freeze(geometry); + rowGeometryCache.set(heights, frozenGeometry); + return frozenGeometry; +} + +/** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */ +export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry { + const cached = rowGeometryCache.get(rowHeights); + if (cached) return cached; + const geometry = createTimelineRowGeometry( + rowHeights.map((_, row) => row), + rowHeights, + ); + rowGeometryCache.set(rowHeights, geometry); + return geometry; +} + /** Cumulative top offsets, including the final bottom boundary. */ export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] { - const offsets = [0]; - for (const height of rowHeights) { - offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height)); - } - return offsets; + return [...getTimelineRowGeometry(rowHeights).rowOffsets]; } -export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number { +export function getTimelineRowHeight( + row: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { return validRowHeight(rowHeights[row]); } function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number { - if (rowHeights.length === 0) return row * TRACK_H; - const offsets = getTimelineRowOffsets(rowHeights); - if (row <= 0) return row * getTimelineRowHeight(0, rowHeights); - if (row >= rowHeights.length) { - return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H; - } - const wholeRow = Math.floor(row); - const fraction = row - wholeRow; - return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights); + return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD; } -export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number { +export function getTimelineRowTop( + row: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights); } @@ -119,34 +214,18 @@ export function getTimelineRowTop(row: number, rowHeights: readonly number[] = [ * space y (used for insert-row / drop-lane decisions). Locates the concrete row * from cumulative offsets, then returns its local fractional position. */ -export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number { - const y = contentY - RULER_H - TRACKS_TOP_PAD; - if (rowHeights.length === 0) return y / TRACK_H; - if (y < 0) return y / getTimelineRowHeight(0, rowHeights); - - const offsets = getTimelineRowOffsets(rowHeights); - for (let row = 0; row < rowHeights.length; row += 1) { - const bottom = offsets[row + 1] ?? 0; - if (y < bottom) { - const top = offsets[row] ?? 0; - return row + (y - top) / getTimelineRowHeight(row, rowHeights); - } - } - return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H; +export function getTimelineRowFromY( + contentY: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { + return getTimelineRowGeometry(rowHeights).getRowFromY(contentY); } export function getTimelineRowPositionFromY( contentY: number, - rowHeights: readonly number[] = [], + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, ): { rowFloat: number; row: number; fraction: number; rowHeight: number } { - const rowFloat = getTimelineRowFromY(contentY, rowHeights); - const row = Math.floor(rowFloat); - return { - rowFloat, - row, - fraction: rowFloat - row, - rowHeight: getTimelineRowHeight(row, rowHeights), - }; + return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY); } /** Fractional insert band for the concrete row under a pointer. */ @@ -324,8 +403,7 @@ export function getTimelineCanvasHeight(trackCountOrHeights: number | readonly n typeof trackCountOrHeights === "number" ? trackHeights(trackCountOrHeights) : trackCountOrHeights; - const rowsHeight = getTimelineRowOffsets(heights).at(-1) ?? 0; - return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD; + return getTimelineRowGeometry(heights).canvasHeight; } /* ── UI helpers ───────────────────────────────────────────────────── */ diff --git a/packages/studio/src/player/components/timelineViewportGeometry.ts b/packages/studio/src/player/components/timelineViewportGeometry.ts new file mode 100644 index 0000000000..ee86f834fa --- /dev/null +++ b/packages/studio/src/player/components/timelineViewportGeometry.ts @@ -0,0 +1,31 @@ +import { RULER_H, type TimelineRowGeometry } from "./timelineLayout"; +import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; + +export function getTimelineVisibleTimeRange( + viewport: Pick, + pixelsPerSecond: number, + contentOrigin: number, + duration: number, +): { start: number; end: number } { + if (!(pixelsPerSecond > 0) || !(duration > 0)) return { start: 0, end: 0 }; + const start = Math.max(0, (viewport.scrollLeft - contentOrigin) / pixelsPerSecond); + const end = Math.min( + duration, + Math.max(start, (viewport.scrollLeft + viewport.clientWidth - contentOrigin) / pixelsPerSecond), + ); + return { start: Math.min(start, duration), end }; +} + +export function getTimelineScrollTopForGeometryChange( + previous: TimelineRowGeometry, + next: TimelineRowGeometry, + scrollTop: number, +): number { + const anchor = previous.getRowPositionFromY(scrollTop + RULER_H); + if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop; + const anchorKey = previous.rowKeys[anchor.row]; + if (anchorKey === undefined) return scrollTop; + const nextRow = next.getRowIndex(anchorKey); + if (nextRow < 0) return scrollTop; + return Math.max(0, scrollTop + next.getRowTop(nextRow) - previous.getRowTop(anchor.row)); +} diff --git a/packages/studio/src/player/components/useTimelineActiveClips.ts b/packages/studio/src/player/components/useTimelineActiveClips.ts index 4971b88402..071ed267e4 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.ts @@ -13,7 +13,7 @@ interface ActiveClipRecord { interface UseTimelineActiveClipsInput { scrollRef: React.RefObject; currentTime: number; - clipStateVersion: string; + clipStateVersion: unknown; } function readFiniteNumber(value: string | undefined): number | null { diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index da366e6bdb..86ce7173e2 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -6,7 +6,6 @@ import { } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; -import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector"; import { mergeUserBeats } from "../../utils/beatEditing"; import { buildTimelineGroupResizeMembers, @@ -27,6 +26,8 @@ import type { BlockedClipState, } from "./timelineClipDragTypes"; import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import type { TimelineRowGeometry } from "./timelineLayout"; export type { DraggedClipState, @@ -42,7 +43,7 @@ interface UseTimelineClipDragInput { ppsRef: React.RefObject; durationRef: React.RefObject; trackOrderRef: React.RefObject; - rowHeightsRef?: React.RefObject; + rowGeometryRef?: React.RefObject; onMoveElement?: ( element: TimelineElement, updates: Pick, @@ -80,7 +81,7 @@ export function useTimelineClipDrag({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement, onMoveElements, onResizeElement, @@ -96,12 +97,11 @@ export function useTimelineClipDrag({ const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES); const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES); const beatEdits = usePlayerStore((s) => s.beatEdits); - const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0); - const musicPlaybackStart = usePlayerStore( - (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0, - ); - const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0); - const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null); + const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); + const musicStart = musicElement?.start ?? 0; + const musicPlaybackStart = musicElement?.playbackStart ?? 0; + const musicDuration = musicElement?.duration ?? 0; + const musicSrc = musicElement?.src ?? null; const adjustedBeatTimes = useMemo(() => { if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES; @@ -228,23 +228,21 @@ export function useTimelineClipDrag({ // Build the audio-track set once per gesture (see snapTargetsCacheRef): it // only feeds zone-aware drop placement and is frozen while dragging. if (!dragAudioTracksRef.current) { - dragAudioTracksRef.current = new Set( - elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track), - ); + dragAudioTracksRef.current = getTimelineElementIndexes(elementsRef.current).audioTracks; } return computeDragPreview(drag, clientX, clientY, { scroll: scrollRef.current, pps: ppsRef.current, duration: durationRef.current, trackOrder: trackOrderRef.current, - rowHeights: rowHeightsRef?.current, + rowHeights: rowGeometryRef?.current.rowHeights, elements: elementsRef.current, selectedKeys: usePlayerStore.getState().selectedElementIds, buildSnapTargets, audioTracks: dragAudioTracksRef.current, }); }, - [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets], + [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets], ); // Recompute the trim preview for a pointer x. Shared by the pointermove resize diff --git a/packages/studio/src/player/components/useTimelineGeometry.ts b/packages/studio/src/player/components/useTimelineGeometry.ts index 6241cda973..3c147718c3 100644 --- a/packages/studio/src/player/components/useTimelineGeometry.ts +++ b/packages/studio/src/player/components/useTimelineGeometry.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, type RefObject } from "react"; +import { useEffect, useRef, type RefObject } from "react"; import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore"; import { getTimelinePixelsPerSecond } from "./timelineZoom"; import { @@ -78,13 +78,6 @@ export function useTimelineGeometry({ resizeGhostEndPx, }); const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration; - const clipStateVersion = useMemo( - () => - expandedElements - .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`) - .join("|"), - [expandedElements], - ); const zoomModeRef = useRef(zoomMode); zoomModeRef.current = zoomMode; const manualZoomPercentRef = useRef(manualZoomPercent); @@ -92,7 +85,7 @@ export function useTimelineGeometry({ fitPpsRef.current = fitPps; // Restore the horizontal scroll offset after an edit re-derives the elements - // (clipStateVersion changes) so the reload doesn't jump the view. Only in manual + // (the immutable element snapshot changes) so the reload doesn't jump the view. Only in manual // (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and // never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout // so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep: @@ -109,7 +102,7 @@ export function useTimelineGeometry({ }); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clipStateVersion, zoomMode]); + }, [expandedElements, zoomMode]); // Publish the live scale so edit handlers OUTSIDE (the keyboard-delete // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry. // In a useEffect (not the render body) so React-18 concurrent replay — Suspense @@ -125,7 +118,7 @@ export function useTimelineGeometry({ fitPps, displayContentWidth, displayDuration, - clipStateVersion, + clipStateVersion: expandedElements, zoomModeRef, manualZoomPercentRef, }; diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index 796d8123cd..3016495f70 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -15,6 +15,7 @@ import { type MarqueeClipInput, } from "./timelineMarquee"; import type { Rect } from "../../utils/marqueeGeometry"; +import type { TimelineRowGeometry } from "./timelineLayout"; interface UseTimelineRangeSelectionInput { scrollRef: React.RefObject; @@ -29,7 +30,7 @@ interface UseTimelineRangeSelectionInput { setShowPopover: (v: boolean) => void; elementsRef: React.RefObject; trackOrderRef: React.RefObject; - rowHeightsRef: React.RefObject; + rowGeometryRef: React.RefObject; onSelectElement?: (element: TimelineElement | null) => void; contentOrigin: number; } @@ -106,7 +107,7 @@ export function useTimelineRangeSelection({ setShowPopover, elementsRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onSelectElement, contentOrigin, }: UseTimelineRangeSelectionInput) { @@ -175,12 +176,12 @@ export function useTimelineRangeSelection({ marquee, elementsRef.current ?? [], trackOrderRef.current ?? [], - rowHeightsRef.current, + rowGeometryRef.current.rowHeights, ppsRef.current, contentOrigin, ); }, - [toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin], + [toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin], ); const stopMarqueeAutoScroll = useCallback(() => { diff --git a/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx b/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx new file mode 100644 index 0000000000..f62c8e7ac0 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineScrollViewport.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import React, { act, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let resizeCallback: ResizeObserverCallback | null = null; +class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe() {} + disconnect() {} +} + +const originalResizeObserver = globalThis.ResizeObserver; + +beforeEach(() => { + vi.useFakeTimers(); + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + vi.useRealTimers(); + globalThis.ResizeObserver = originalResizeObserver; + resizeCallback = null; + document.body.innerHTML = ""; +}); + +describe("useTimelineScrollViewport", () => { + it("publishes resize, scroll, and settled snapshots", () => { + let hook: ReturnType | undefined; + function Probe() { + hook = useTimelineScrollViewport(useRef(null), []); + return null; + } + + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + const element = document.createElement("div"); + const values = { + left: 0, + top: 0, + width: 640, + height: 240, + scrollWidth: 1200, + scrollHeight: 800, + }; + Object.defineProperties(element, { + scrollLeft: { configurable: true, get: () => values.left }, + scrollTop: { configurable: true, get: () => values.top }, + clientWidth: { configurable: true, get: () => values.width }, + clientHeight: { configurable: true, get: () => values.height }, + scrollWidth: { configurable: true, get: () => values.scrollWidth }, + scrollHeight: { configurable: true, get: () => values.scrollHeight }, + }); + + act(() => hook?.setScrollRef(element)); + expect(hook?.viewport.clientWidth).toBe(640); + + values.width = 800; + act(() => resizeCallback?.([], {} as ResizeObserver)); + expect(hook?.viewport.clientWidth).toBe(800); + + values.left = 120; + values.top = 48; + act(() => hook?.syncScrollViewport(element, true)); + act(() => vi.advanceTimersByTime(16)); + expect(hook?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true }); + + act(() => vi.advanceTimersByTime(100)); + expect(hook?.viewport.isScrolling).toBe(false); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineScrollViewport.ts b/packages/studio/src/player/components/useTimelineScrollViewport.ts index 55d5e9a2df..f08f558968 100644 --- a/packages/studio/src/player/components/useTimelineScrollViewport.ts +++ b/packages/studio/src/player/components/useTimelineScrollViewport.ts @@ -2,6 +2,44 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react" import { useMountEffect } from "../../hooks/useMountEffect"; import { shouldShowTimelineShortcutHint } from "./timelineLayout"; +export interface TimelineScrollViewportSnapshot { + readonly scrollLeft: number; + readonly scrollTop: number; + readonly clientWidth: number; + readonly clientHeight: number; + readonly scrollWidth: number; + readonly scrollHeight: number; + readonly isScrolling: boolean; +} + +const EMPTY_VIEWPORT: TimelineScrollViewportSnapshot = Object.freeze({ + scrollLeft: 0, + scrollTop: 0, + clientWidth: 0, + clientHeight: 0, + scrollWidth: 0, + scrollHeight: 0, + isScrolling: false, +}); + +function readTimelineScrollViewport( + element: Pick< + HTMLElement, + "scrollLeft" | "scrollTop" | "clientWidth" | "clientHeight" | "scrollWidth" | "scrollHeight" + >, + isScrolling: boolean, +): TimelineScrollViewportSnapshot { + return { + scrollLeft: element.scrollLeft, + scrollTop: element.scrollTop, + clientWidth: element.clientWidth, + clientHeight: element.clientHeight, + scrollWidth: element.scrollWidth, + scrollHeight: element.scrollHeight, + isScrolling, + }; +} + /** * The timeline scroll container's viewport plumbing — extracted verbatim from * Timeline.tsx (600-line studio cap): the ResizeObserver-backed viewport width, @@ -14,14 +52,40 @@ export function useTimelineScrollViewport( scrollRef: RefObject, resyncShortcutHintOn: ReadonlyArray, ): { - viewportWidth: number; + viewport: TimelineScrollViewportSnapshot; showShortcutHint: boolean; setScrollRef: (el: HTMLDivElement | null) => void; + syncScrollViewport: (el: HTMLDivElement, isScrolling?: boolean) => void; } { - const [viewportWidth, setViewportWidth] = useState(0); + const [viewport, setViewport] = useState(EMPTY_VIEWPORT); const [showShortcutHint, setShowShortcutHint] = useState(true); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); + const viewportRafRef = useRef(0); + const scrollSettledTimerRef = useRef | null>(null); + const scrollingRef = useRef(false); + + const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => { + scrollingRef.current = isScrolling; + const publish = () => { + viewportRafRef.current = 0; + setViewport(readTimelineScrollViewport(el, scrollingRef.current)); + }; + if (isScrolling) { + if (!viewportRafRef.current) viewportRafRef.current = requestAnimationFrame(publish); + } else { + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + publish(); + return; + } + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); + scrollSettledTimerRef.current = setTimeout(() => { + scrollSettledTimerRef.current = null; + scrollingRef.current = false; + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + publish(); + }, 100); + }, []); const syncShortcutHintVisibility = useCallback(() => { const scroll = scrollRef.current; @@ -45,23 +109,30 @@ export function useTimelineScrollViewport( roRef.current = null; } scrollRef.current = el; - if (!el) return; + if (!el) { + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); + scrollSettledTimerRef.current = null; + scrollingRef.current = false; + return; + } - const syncScrollViewport = () => { - setViewportWidth(el.clientWidth); + const syncResize = () => { + syncScrollViewport(el, scrollingRef.current); scheduleShortcutHintVisibilitySync(); }; - syncScrollViewport(); - roRef.current = new ResizeObserver(syncScrollViewport); + syncResize(); + roRef.current = new ResizeObserver(syncResize); roRef.current.observe(el); }, - [scrollRef, scheduleShortcutHintVisibilitySync], + [scrollRef, scheduleShortcutHintVisibilitySync, syncScrollViewport], ); useMountEffect(() => () => { roRef.current?.disconnect(); if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); }); useEffect(() => { @@ -69,5 +140,5 @@ export function useTimelineScrollViewport( // eslint-disable-next-line react-hooks/exhaustive-deps }, [syncShortcutHintVisibility, ...resyncShortcutHintOn]); - return { viewportWidth, showShortcutHint, setScrollRef }; + return { viewport, showShortcutHint, setScrollRef, syncScrollViewport }; } diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index e2d1b6d293..af54b970bf 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -48,6 +48,8 @@ describe("useTimelineTrackLayout", () => { expect(layout?.laneCounts.get("clip-1")).toBe(1); expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]); + expect(layout?.rowGeometry.rowKeys).toEqual([0]); + expect(layout?.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index af28964d16..d73c68cb34 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -7,8 +7,8 @@ import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; import { TRACK_H, - getTimelineCanvasHeight, - getTimelineRowHeight, + createTimelineRowGeometry, + type TimelineRowGeometry, trackHeights, type TimelineTrackHeightClip, } from "./timelineLayout"; @@ -72,7 +72,7 @@ function useTimelineRowHeights( selectedElementIds: ReadonlySet, ) { const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); - const { laneCounts, rowHeights } = useMemo(() => { + const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); // Row height follows only the active keyframe clip, so a track with several // keyframed elements never reserves empty lanes for the ones not shown. @@ -87,17 +87,26 @@ function useTimelineRowHeights( const clipId = active.key ?? active.id; return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }]; }); + const rowHeights = trackHeights( + heightTracks, + STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined, + ); return { laneCounts, - rowHeights: trackHeights( - heightTracks, - STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined, + rowGeometry: createTimelineRowGeometry( + tracks.map(([track]) => track), + rowHeights, ), }; }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]); - const rowHeightsRef = useRef(rowHeights); - rowHeightsRef.current = rowHeights; - return { laneCounts, rowHeights, rowHeightsRef }; + const rowGeometryRef = useRef(rowGeometry); + rowGeometryRef.current = rowGeometry; + return { + laneCounts, + rowGeometry, + rowGeometryRef, + rowHeights: rowGeometry.rowHeights, + }; } export function useTimelineTrackLayout( @@ -109,7 +118,7 @@ export function useTimelineTrackLayout( const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); const trackOrderRef = useRef(trackOrder); trackOrderRef.current = trackOrder; - const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights( + const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, selectedElementId, @@ -122,23 +131,23 @@ export function useTimelineTrackLayout( trackOrder, trackOrderRef, laneCounts, + rowGeometry, + rowGeometryRef, rowHeights, - rowHeightsRef, }; } function useDisplayRowHeights( displayTrackOrder: readonly number[], - trackOrder: readonly number[], - rowHeights: readonly number[], + rowGeometry: TimelineRowGeometry, ) { return useMemo( () => displayTrackOrder.map((track) => { - const row = trackOrder.indexOf(track); - return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights); + const row = rowGeometry.getRowIndex(track); + return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row); }), - [displayTrackOrder, trackOrder, rowHeights], + [displayTrackOrder, rowGeometry], ); } @@ -152,10 +161,18 @@ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: export function useTimelineDisplayLayout( draggedClip: DraggedClipState | null, trackOrder: number[], - rowHeights: readonly number[], + rowGeometry: TimelineRowGeometry, ) { const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder); - const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights); - const totalH = getTimelineCanvasHeight(displayRowHeights); - return { displayTrackOrder, displayRowHeights, totalH }; + const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry); + const displayRowGeometry = useMemo( + () => createTimelineRowGeometry(displayTrackOrder, displayRowHeights), + [displayTrackOrder, displayRowHeights], + ); + return { + displayTrackOrder, + displayRowHeights: displayRowGeometry.rowHeights, + rowGeometry: displayRowGeometry, + totalH: displayRowGeometry.canvasHeight, + }; } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index bc1dbd7f37..67d2f2cccf 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -43,6 +43,7 @@ import { shouldMutePreviewAudio, } from "../lib/timelineIframeHelpers"; import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub"; +import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture"; import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe"; import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; @@ -66,8 +67,11 @@ export function useTimelinePlayer() { const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } = usePlayerStore.getState(); + // The fixture lease belongs at this shared synchronization boundary so every + // iframe discovery path has the same owner for deciding whether it may write. const syncTimelineElements = useCallback( (elements: TimelineElement[], nextDuration?: number) => { + if (hasTimelinePerformanceFixtureLease()) return; const state = usePlayerStore.getState(); const resolvedDuration = nextDuration ?? state.duration; // applyCachedSourceDurations re-applies the cached probe duration: re-derived diff --git a/packages/studio/src/player/lib/playbackScrub.ts b/packages/studio/src/player/lib/playbackScrub.ts index 227c2a78c1..ae789e410e 100644 --- a/packages/studio/src/player/lib/playbackScrub.ts +++ b/packages/studio/src/player/lib/playbackScrub.ts @@ -1,6 +1,6 @@ import { usePlayerStore } from "../store/playerStore"; -import { isMusicTrack } from "../../utils/timelineInspector"; import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers"; +import { getTimelineElementIndexes } from "./timelineElementIndexes"; export { stopScrubPreviewAudio }; @@ -8,7 +8,7 @@ export { stopScrubPreviewAudio }; // Skipped when audio is muted or the time falls outside the music clip. export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void { const s = usePlayerStore.getState(); - const music = s.elements.find(isMusicTrack); + const music = getTimelineElementIndexes(s.elements).musicElement; if (!music || s.audioMuted) return; const rel = nextTime - music.start; const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null; diff --git a/packages/studio/src/player/lib/timelineElementIndexes.test.ts b/packages/studio/src/player/lib/timelineElementIndexes.test.ts new file mode 100644 index 0000000000..c1c088352f --- /dev/null +++ b/packages/studio/src/player/lib/timelineElementIndexes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIndexes } from "./timelineElementIndexes"; + +describe("getTimelineElementIndexes", () => { + const elements: TimelineElement[] = [ + { id: "hero", tag: "img", src: "hero.png", start: 0, duration: 2, track: 0 }, + { + id: "bgm", + tag: "audio", + src: "music.wav", + start: 0, + duration: 10, + track: 3, + timelineRole: "music", + }, + ]; + + it("indexes media and music identities", () => { + const indexes = getTimelineElementIndexes(elements); + expect(indexes.byKey.get("hero")).toBe(elements[0]); + expect(indexes.musicElement).toBe(elements[1]); + expect(indexes.mediaElements).toEqual(elements); + expect(indexes.audioTracks).toEqual(new Set([3])); + }); + + it("reuses one index for the same immutable array snapshot", () => { + expect(getTimelineElementIndexes(elements)).toBe(getTimelineElementIndexes(elements)); + expect(getTimelineElementIndexes([...elements])).not.toBe(getTimelineElementIndexes(elements)); + }); +}); diff --git a/packages/studio/src/player/lib/timelineElementIndexes.ts b/packages/studio/src/player/lib/timelineElementIndexes.ts new file mode 100644 index 0000000000..9def5b73b4 --- /dev/null +++ b/packages/studio/src/player/lib/timelineElementIndexes.ts @@ -0,0 +1,43 @@ +import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; +import type { TimelineElement } from "../store/playerStore"; + +export interface TimelineElementIndexes { + readonly byKey: ReadonlyMap; + readonly musicElement: TimelineElement | null; + readonly mediaElements: readonly TimelineElement[]; + readonly audioTracks: ReadonlySet; +} + +const indexCache = new WeakMap(); + +/** + * Index a store element snapshot once. Playback-only Zustand updates keep the + * same array identity, so selectors can reuse this object without rescanning a + * large timeline or triggering a component render. + */ +export function getTimelineElementIndexes( + elements: readonly TimelineElement[], +): TimelineElementIndexes { + const cached = indexCache.get(elements); + if (cached) return cached; + + const byKey = new Map(); + const mediaElements: TimelineElement[] = []; + const audioTracks = new Set(); + let musicElement: TimelineElement | null = null; + for (const element of elements) { + byKey.set(element.key ?? element.id, element); + if (element.src) mediaElements.push(element); + if (isAudioTimelineElement(element)) audioTracks.add(element.track); + if (!musicElement && isMusicTrack(element)) musicElement = element; + } + + const indexes = Object.freeze({ + byKey, + musicElement, + mediaElements: Object.freeze(mediaElements), + audioTracks, + }); + indexCache.set(elements, indexes); + return indexes; +} diff --git a/packages/studio/src/player/lib/timelinePerformanceFixture.ts b/packages/studio/src/player/lib/timelinePerformanceFixture.ts index 12707ea33c..8aed51bf01 100644 --- a/packages/studio/src/player/lib/timelinePerformanceFixture.ts +++ b/packages/studio/src/player/lib/timelinePerformanceFixture.ts @@ -29,6 +29,7 @@ export interface TimelinePerformanceFixture { } const TRACK_COUNT = 1_000; +let fixtureLeaseActive = false; const PROFILE_GEOMETRY: Readonly< Record > = Object.freeze({ @@ -39,6 +40,15 @@ const PROFILE_GEOMETRY: Readonly< "remote-unsupported": { duration: 900, clipDuration: 12 }, }); +/** Prevent live iframe discovery from replacing an explicitly loaded dev fixture. */ +export function setTimelinePerformanceFixtureLease(active: boolean): void { + fixtureLeaseActive = active; +} + +export function hasTimelinePerformanceFixtureLease(): boolean { + return fixtureLeaseActive; +} + function validateFixtureSpec(spec: TimelinePerformanceFixtureSpec) { if (spec.elementCount !== 1_000 && spec.elementCount !== 50_000) { throw new RangeError("Timeline performance fixture elementCount must be 1000 or 50000"); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 1eb3f72c56..b9b1157b22 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -441,6 +441,21 @@ describe("usePlayerStore", () => { }); describe("reset", () => { + it("increments the session epoch only for a hard project switch", () => { + usePlayerStore.getState().beginTimelineSession("project-a"); + const firstEpoch = usePlayerStore.getState().timelineSessionEpoch; + + usePlayerStore.getState().reset(); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch); + + usePlayerStore.getState().beginTimelineSession("project-a"); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch); + + usePlayerStore.getState().beginTimelineSession("project-b"); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1); + expect(usePlayerStore.getState().timelineProjectId).toBe("project-b"); + }); + it("resets all state to defaults", () => { // Mutate everything const store = usePlayerStore.getState(); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index faf3776b5a..bd17f13389 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -101,6 +101,10 @@ interface PlayerState extends KeyframeSlice { currentTime: number; duration: number; timelineReady: boolean; + /** Increments exactly once when the Studio switches to a different project. */ + timelineSessionEpoch: number; + /** Project owning the current timeline session; null outside a project-scoped reset. */ + timelineProjectId: string | null; /** True while a beat dot is being dragged — hides the playhead guideline. */ beatDragging: boolean; elements: TimelineElement[]; @@ -201,6 +205,9 @@ interface PlayerState extends KeyframeSlice { bumpZEditVersion: () => void; setInPoint: (time: number | null) => void; setOutPoint: (time: number | null) => void; + /** Owns the hard project boundary; repeated calls for one project are no-ops. */ + beginTimelineSession: (projectId: string) => void; + /** Clears project data without creating a new hard-project session. */ reset: () => void; /** @@ -283,11 +290,42 @@ export const liveTime = { }, }; +function createTimelineResetState() { + return { + isPlaying: false, + currentTime: 0, + duration: 0, + timelineReady: false, + beatDragging: false, + elements: [], + selectedElementId: null, + inPoint: null, + outPoint: null, + activeTool: "select" as const, + selectedKeyframes: new Set(), + expandedClipIds: new Set(), + selectedElementIds: new Set(), + clipRevealRequest: null, + keyframeCache: new Map(), + gsapAnimations: new Map(), + beatAnalysis: null, + beatEdits: null, + beatUndo: [], + beatRedo: [], + beatPersist: null, + clipManifest: null, + clipParentMap: new Map(), + domClipChildren: [], + }; +} + export const usePlayerStore = create((set, get) => ({ isPlaying: false, currentTime: 0, duration: 0, timelineReady: false, + timelineSessionEpoch: 0, + timelineProjectId: null, beatDragging: false, elements: [], selectedElementId: null, @@ -514,36 +552,19 @@ export const usePlayerStore = create((set, get) => ({ (el.key ?? el.id) === elementId ? { ...el, ...updates } : el, ), })), - // Resets project-specific state when switching compositions. - // playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved - // because they are user preferences that should survive project switches. - reset: () => - set({ - isPlaying: false, - currentTime: 0, - duration: 0, - timelineReady: false, - beatDragging: false, - elements: [], - selectedElementId: null, - inPoint: null, - outPoint: null, - activeTool: "select", - selectedKeyframes: new Set(), - expandedClipIds: new Set(), - selectedElementIds: new Set(), - clipRevealRequest: null, - keyframeCache: new Map(), - gsapAnimations: new Map(), - beatAnalysis: null, - beatEdits: null, - beatUndo: [], - beatRedo: [], - beatPersist: null, - clipManifest: null, - clipParentMap: new Map(), - domClipChildren: [], + // playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are + // intentionally absent from createTimelineResetState because they are user + // preferences that survive both source refreshes and project switches. + beginTimelineSession: (projectId) => + set((state) => { + if (state.timelineProjectId === projectId) return state; + return { + ...createTimelineResetState(), + timelineSessionEpoch: state.timelineSessionEpoch + 1, + timelineProjectId: projectId, + }; }), + reset: () => set(createTimelineResetState()), })); // Bug-bash aid: expose the store so a reproduction can dump live state from the diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html index 2846170bf5..5363b13f5c 100644 --- a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html @@ -17,6 +17,7 @@
node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight, - ); + const scroller = root.querySelector("[data-timeline-scroll-viewport]"); if (!(scroller instanceof HTMLElement)) throw new Error("Timeline scroller not mounted"); return scroller; } @@ -192,24 +190,18 @@ try { if (TIER === "low-resource") { await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }); } - await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.goto(STUDIO_URL, { waitUntil: "networkidle0", timeout: 60_000 }); await page.waitForFunction( () => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function", { timeout: 30_000 }, ); + await waitForStudioTestHookSettle(page); - await page.evaluate((profile) => { - window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); - }, PROFILE); + await loadFixtureAndWait(page, 1_000, PROFILE); await client.send("HeapProfiler.collectGarbage"); const baselineHeapBytes = await collectHeapBytes(client); - const summary = await page.evaluate( - ({ elementCount, profile }) => - window.__studioTest.loadTimelinePerformanceFixture({ elementCount, profile }), - { elementCount: ELEMENT_COUNT, profile: PROFILE }, - ); - await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]')); + const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE); const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets); const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); @@ -232,9 +224,7 @@ try { run.diagnostics.mountedTimelineDescendants < budgets.maxMountedTimelineDescendants; } - await page.evaluate((profile) => { - window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); - }, PROFILE); + await loadFixtureAndWait(page, 1_000, PROFILE); await client.send("HeapProfiler.collectGarbage"); const returnedHeapBytes = await collectHeapBytes(client); const memoryReturned = @@ -296,3 +286,48 @@ try { await browser.close(); } process.exit(exitCode); + +async function waitForFixtureRender(page, elementCount) { + const deadline = Date.now() + 60_000; + let observed = null; + while (Date.now() < deadline) { + observed = await page.evaluate(() => ({ + modelCount: window.__playerStore?.getState().elements.length ?? null, + renderedCount: + document + .querySelector('[aria-label="Timeline"]') + ?.getAttribute("data-timeline-element-count") ?? null, + })); + if (observed.renderedCount === String(elementCount)) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timeline fixture ${elementCount} did not render: ${JSON.stringify(observed)}`); +} + +async function loadFixtureAndWait(page, elementCount, profile) { + const summary = await page.evaluate( + ({ count, fixtureProfile }) => + window.__studioTest.loadTimelinePerformanceFixture({ + elementCount: count, + profile: fixtureProfile, + }), + { count: elementCount, fixtureProfile: profile }, + ); + await waitForFixtureRender(page, elementCount); + return summary; +} + +async function waitForStudioTestHookSettle(page) { + await page.evaluate(async () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + for (;;) { + const candidate = window.__studioTest; + await nextFrame(); + await nextFrame(); + if (candidate === window.__studioTest) return; + } + }); +}