From 723d3381c4cabb3c238d883adf4fca487ead9df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 31 Jul 2026 18:05:22 +0200 Subject: [PATCH] fix(studio): keep dense keyframes readable (#2925) * perf(studio): define timeline viewport budgets and fixtures * test(studio): gate timeline viewport performance in Chromium * refactor(studio): isolate clip drag lifecycle * refactor(studio): extract timeline render contracts * perf(studio): centralize timeline viewport geometry * perf(studio): follow playhead across virtualized rows * perf(studio): add timeline clip-window index primitive * perf(studio): virtualize timeline clip windows * perf(studio): stop timeline scroll work when row virtualization is off The row virtualization stack made the timeline publish a viewport snapshot on every scroll frame and swap `renderClipContent` across every mounted clip at gesture start and settle. Both are windowing concessions, and neither was gated on the flag, so the build users actually run paid for them while mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247. Gate both on the row virtualization flag. The scroll path now stops at the door when the flag is off, so `isScrolling` stays false and resize-driven and programmatic syncs still publish through the immediate path. The flag moves into its own module: the scroll-viewport hook needs to read it, and the virtualization hook already imports the viewport snapshot type back, which would have closed an import cycle. Also release the perf fixture lease from the fixture rather than from the test-hook effect. Loading a fixture writes player state, which changed that effect's dependency identities and tore it down on the next frame, so the lease was revoked moments after it was taken and live iframe discovery overwrote the fixture before the gate could measure it. The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements) next to the existing flag-on one. It refuses the 50,000-element combination, verifies from the mounted DOM that the server under test matches the requested flag, and skips the DOM-size budgets for the unvirtualized build rather than relaxing them, so a skipped budget never reads as a passed one. Verified against a live Studio dev server on the fixture project: flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass flag off, after: interactionP95 33.6ms, longest task 0ms, 5/5 runs pass flag on, after: interactionP95 33.2ms, 4/5 runs pass, exit 0 The flag-on arm's fourth run reproducibly reports a 55-58ms long task against a 50ms budget. That is the residual tail of the window swap itself, tracked separately and not addressed here. * ci(studio): run the timeline viewport gate on studio changes The gate has existed since the row virtualization stack landed but nothing under `.github/` referenced it, so it only ever ran when someone ran it by hand. That is how the flag-off scroll regression reached eight merged-ready PRs without anything noticing. Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one per flag state, and runs both arms of the gate against them. Two servers are needed because row virtualization is read from `import.meta.env` at module load, so one process cannot serve both builds. Scoped to a new `studio` paths filter rather than the broad `code` one: the gate only says anything about `packages/studio`, `packages/core` and `packages/studio-server`. Adds a `ci` tier. It applies the constrained budgets without any emulation, because a hosted runner is already slower and noisier than the machine the strict numbers were recorded on, while the existing `low-resource` tier would throttle it a further 4x and measure the throttle rather than the build. The fixture composition is tracked under `tests/e2e/fixtures` but Studio resolves projects from the gitignored `data/projects`, so the job copies it into place instead of a project directory being committed. Both arms run in about 7 seconds each locally, so the job cost is almost entirely dependency install and the workspace build it shares with `studio-load-smoke`. * fix(ci): preserve both timeline gate evidence arms * ci(studio): report timeline gate arm statuses * ci(studio): require timeline gate evidence artifacts * fix(studio): keep dense keyframes readable * fix(ci): resolve timeline stack audit findings --- .fallowrc.jsonc | 5 + .github/workflows/ci.yml | 98 ++++ bun.lock | 5 + packages/studio/package.json | 5 +- .../studio/src/components/TimelineToolbar.tsx | 19 +- .../studio/src/components/nle/NLEContext.tsx | 5 +- .../src/components/nle/TimelinePane.tsx | 2 + .../src/hooks/useGestureRecording.test.tsx | 54 +- .../studio/src/hooks/useGestureRecording.ts | 14 +- .../src/hooks/useStudioTestHooks.test.tsx | 160 ++++++ .../studio/src/hooks/useStudioTestHooks.ts | 50 ++ .../src/player/components/BeatStrip.tsx | 26 +- .../src/player/components/Timeline.test.ts | 100 ++++ .../studio/src/player/components/Timeline.tsx | 272 +++++----- .../Timeline.virtualization.test.tsx | 400 +++++++++++++++ .../src/player/components/TimelineCanvas.tsx | 28 +- .../src/player/components/TimelineClip.tsx | 4 + .../components/TimelineClipDiamonds.test.tsx | 90 +++- .../components/TimelineClipDiamonds.tsx | 66 ++- .../player/components/TimelineLanes.test.tsx | 19 +- .../src/player/components/TimelineLanes.tsx | 107 +++- .../components/TimelinePropertyLanes.test.tsx | 36 +- .../components/TimelinePropertyLanes.tsx | 1 + .../src/player/components/TimelineRuler.tsx | 26 +- .../player/components/TimelineTrackRow.tsx | 46 ++ .../src/player/components/TimelineTypes.ts | 2 + .../timelineClipDragGestureLifecycle.test.ts | 97 ++++ .../timelineClipDragGestureLifecycle.ts | 261 ++++++++++ .../player/components/timelineDiamondTypes.ts | 5 + .../src/player/components/timelineDragDrop.ts | 10 +- .../player/components/timelineEditing.test.ts | 32 ++ .../src/player/components/timelineEditing.ts | 52 +- .../components/timelineFocusIdentity.test.ts | 21 + .../components/timelineFocusIdentity.ts | 21 + .../player/components/timelineLaneProps.ts | 9 + .../player/components/timelineLayout.test.ts | 59 +++ .../src/player/components/timelineLayout.ts | 378 +++++++------- .../timelineRowVirtualizationFlag.ts | 11 + .../components/timelineRulerGeometry.ts | 142 ++++++ .../player/components/timelineViewModel.ts | 44 ++ .../components/timelineViewportGeometry.ts | 51 ++ .../player/components/timelineZoom.test.ts | 70 +-- .../src/player/components/timelineZoom.ts | 69 ++- .../components/useTimelineActiveClips.test.ts | 167 ++++--- .../components/useTimelineActiveClips.ts | 104 ++-- .../player/components/useTimelineClipDrag.ts | 270 ++-------- .../components/useTimelineClipRenderWindow.ts | 95 ++++ .../player/components/useTimelineGeometry.ts | 15 +- .../player/components/useTimelinePlayhead.ts | 44 +- .../components/useTimelineRangeSelection.ts | 9 +- .../useTimelineRangeSelectionScrub.test.tsx | 5 +- .../components/useTimelineRevealClip.test.tsx | 139 ++++++ .../components/useTimelineRevealClip.ts | 216 ++++++-- .../useTimelineRowVirtualization.ts | 140 ++++++ .../useTimelineScrollViewport.test.tsx | 202 ++++++++ .../components/useTimelineScrollViewport.ts | 96 +++- .../useTimelineSelectionLifecycle.ts | 29 ++ .../components/useTimelineShiftModifier.ts | 20 + .../src/player/components/useTimelineTicks.ts | 22 + .../components/useTimelineTrackLayout.test.ts | 58 ++- .../components/useTimelineTrackLayout.ts | 53 +- .../useTimelineVirtualRows.test.tsx | 174 +++++++ .../components/useTimelineVirtualRows.ts | 126 +++++ .../src/player/hooks/timelinePlayerSync.ts | 20 + .../hooks/useTimelinePlayer.seek.test.ts | 141 ++++-- .../src/player/hooks/useTimelinePlayer.ts | 28 +- .../studio/src/player/lib/playbackScrub.ts | 4 +- .../src/player/lib/timelineClipIndex.test.ts | 169 +++++++ .../src/player/lib/timelineClipIndex.ts | 208 ++++++++ .../player/lib/timelineElementIndexes.test.ts | 31 ++ .../src/player/lib/timelineElementIndexes.ts | 44 ++ .../timelinePerformanceDiagnostics.test.ts | 100 ++++ .../lib/timelinePerformanceDiagnostics.ts | 120 +++++ .../player/lib/timelinePerformanceFixture.ts | 167 +++++++ .../lib/timelineViewportBudgets.test.ts | 58 +++ .../src/player/lib/timelineViewportBudgets.ts | 139 ++++++ .../src/player/store/playerStore.test.ts | 23 +- .../studio/src/player/store/playerStore.ts | 118 +++-- .../studio/src/utils/studioUiPreferences.ts | 4 +- .../timeline-virtualization/hyperframes.json | 8 + .../timeline-virtualization/index.html | 40 ++ .../tests/e2e/timeline-virtualization.mjs | 466 ++++++++++++++++++ 82 files changed, 5757 insertions(+), 1087 deletions(-) create mode 100644 packages/studio/src/hooks/useStudioTestHooks.test.tsx create mode 100644 packages/studio/src/player/components/Timeline.virtualization.test.tsx create mode 100644 packages/studio/src/player/components/TimelineTrackRow.tsx create mode 100644 packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts create mode 100644 packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts create mode 100644 packages/studio/src/player/components/timelineFocusIdentity.test.ts create mode 100644 packages/studio/src/player/components/timelineFocusIdentity.ts create mode 100644 packages/studio/src/player/components/timelineRowVirtualizationFlag.ts create mode 100644 packages/studio/src/player/components/timelineRulerGeometry.ts create mode 100644 packages/studio/src/player/components/timelineViewModel.ts create mode 100644 packages/studio/src/player/components/timelineViewportGeometry.ts create mode 100644 packages/studio/src/player/components/useTimelineClipRenderWindow.ts create mode 100644 packages/studio/src/player/components/useTimelineRevealClip.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineRowVirtualization.ts create mode 100644 packages/studio/src/player/components/useTimelineScrollViewport.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineSelectionLifecycle.ts create mode 100644 packages/studio/src/player/components/useTimelineShiftModifier.ts create mode 100644 packages/studio/src/player/components/useTimelineTicks.ts create mode 100644 packages/studio/src/player/components/useTimelineVirtualRows.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineVirtualRows.ts create mode 100644 packages/studio/src/player/hooks/timelinePlayerSync.ts create mode 100644 packages/studio/src/player/lib/timelineClipIndex.test.ts create mode 100644 packages/studio/src/player/lib/timelineClipIndex.ts create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.test.ts create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.ts create mode 100644 packages/studio/src/player/lib/timelinePerformanceDiagnostics.test.ts create mode 100644 packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts create mode 100644 packages/studio/src/player/lib/timelinePerformanceFixture.ts create mode 100644 packages/studio/src/player/lib/timelineViewportBudgets.test.ts create mode 100644 packages/studio/src/player/lib/timelineViewportBudgets.ts create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html create mode 100644 packages/studio/tests/e2e/timeline-virtualization.mjs diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 8982222c9..b3f221dbe 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -631,6 +631,11 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // useGestureRecording.ts: readBasePosition/connectGsapRuntime/tick are + // inherited gesture-runtime control flow. This stack only changes + // recordSample to coalesce display-rate events onto authored frames; + // the import and helper insertion shift the untouched functions' lines. + "packages/studio/src/hooks/useGestureRecording.ts", // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / // patch*InTag pre-date this PR; only the PatchOperation type gained two // optional fields, but the line-shift fingerprint re-flags the inherited diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54cc5cd97..becb5b710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: skills: ${{ steps.filter.outputs.skills }} codex_plugin: ${{ steps.filter.outputs.codex_plugin }} gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }} + studio: ${{ steps.filter.outputs.studio }} steps: # Force git-based change detection instead of the pull_request REST API. # The API path can fail the whole workflow on transient listFiles @@ -77,6 +78,12 @@ jobs: - "scripts/package-codex-plugin.mjs" - "package.json" - ".github/workflows/ci.yml" + studio: + - "packages/studio/**" + - "packages/core/**" + - "packages/studio-server/**" + - "bun.lock" + - ".github/workflows/ci.yml" gcp_beginframe: - "packages/gcp-cloud-run/Dockerfile" - "packages/aws-lambda/scripts/probe-beginframe.ts" @@ -496,6 +503,97 @@ jobs: kill $SERVER_PID 2>/dev/null || true + studio-timeline-viewport: + name: "Studio: timeline viewport gate" + needs: [changes] + if: needs.changes.outputs.studio == 'true' + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile + # Same reason as studio-load-smoke: vite.config.ts is loaded by Node and + # resolves the workspace packages through their "node" export condition. + - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build + - run: bun run --cwd packages/core build + - run: bun run --cwd packages/core build:hyperframes-runtime + - name: Install the fixture as a Studio project + # Studio resolves projects from packages/studio/data/projects, which is + # gitignored. The fixture composition is tracked under tests/e2e, so + # copy it into place rather than committing a project directory. + run: | + mkdir -p packages/studio/data/projects + cp -R packages/studio/tests/e2e/fixtures/timeline-virtualization \ + packages/studio/data/projects/timeline-virtualization + - name: Run both arms of the timeline viewport gate + run: | + set -euo pipefail + + # Two servers, because row virtualization is read from import.meta.env + # at module load: one process cannot serve both builds. + bun run --cwd packages/studio dev -- --port 5313 --strictPort & + DEFAULT_PID=$! + VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=1 \ + bun run --cwd packages/studio dev -- --port 5314 --strictPort & + VIRTUALIZED_PID=$! + trap 'kill $DEFAULT_PID $VIRTUALIZED_PID 2>/dev/null || true' EXIT + + for i in $(seq 1 60); do + if curl -sf http://localhost:5313/ >/dev/null 2>&1 \ + && curl -sf http://localhost:5314/ >/dev/null 2>&1; then break; fi + sleep 1 + done + if ! curl -sf http://localhost:5313/ >/dev/null 2>&1 \ + || ! curl -sf http://localhost:5314/ >/dev/null 2>&1; then + echo "FAIL: studio dev servers did not start" + exit 1 + fi + + # The default build first. It is the one users get, and the arm that + # caught the regression this gate exists for. + # Capture both statuses so either failure still leaves two evidence files. + DEFAULT_STATUS=0 + STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \ + TIMELINE_ROW_VIRTUALIZATION=off \ + TIMELINE_ELEMENT_COUNT=1000 \ + TIMELINE_TIER=ci \ + node packages/studio/tests/e2e/timeline-virtualization.mjs \ + | tee /tmp/timeline-gate-default.json \ + || DEFAULT_STATUS=$? + + VIRTUALIZED_STATUS=0 + STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \ + TIMELINE_ROW_VIRTUALIZATION=on \ + TIMELINE_ELEMENT_COUNT=50000 \ + TIMELINE_TIER=ci \ + node packages/studio/tests/e2e/timeline-virtualization.mjs \ + | tee /tmp/timeline-gate-virtualized.json \ + || VIRTUALIZED_STATUS=$? + + { + echo "### Timeline viewport gate" + echo "- Default arm exit: ${DEFAULT_STATUS}" + echo "- Virtualized arm exit: ${VIRTUALIZED_STATUS}" + } >> "$GITHUB_STEP_SUMMARY" + + if (( DEFAULT_STATUS != 0 || VIRTUALIZED_STATUS != 0 )); then + echo "FAIL: default=${DEFAULT_STATUS}, virtualized=${VIRTUALIZED_STATUS}" + exit 1 + fi + - name: Upload gate evidence + # The gate's whole output is machine-readable evidence, and a red run is + # exactly when someone needs to read it. Keep it on failure too. + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: timeline-viewport-gate-evidence + path: /tmp/timeline-gate-*.json + if-no-files-found: error + smoke-global-install: name: "Smoke: global install" needs: [changes, build] diff --git a/bun.lock b/bun.lock index 3daf65a1c..85a5cbd6f 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 1f5bf769a..8873e5199 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/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index db08ce0a7..539148800 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -118,8 +118,13 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool // the selection changes, not only on the next playhead tick. const selectedElementId = usePlayerStore((s) => s.selectedElementId); const elements = usePlayerStore((s) => s.elements); + const timelineFitPps = usePlayerStore((s) => s.timelineFitPps); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); - const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); + const displayedTimelineZoomPercent = getTimelineZoomPercent( + zoomMode, + manualZoomPercent, + timelineFitPps, + ); const { state: keyframeState, isMotionPath: keyframeIsMotionPath, @@ -418,7 +423,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool onClick={() => { setZoomMode("manual"); setManualZoomPercent( - getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent), + getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps), ); }} className={flatIdle} @@ -430,12 +435,14 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool type="range" min="0" max="100" - value={timelineZoomPercentToSlider(displayedTimelineZoomPercent)} + value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)} title={`${displayedTimelineZoomPercent}%`} aria-label="Timeline zoom" onChange={(e) => { setZoomMode("manual"); - setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value))); + setManualZoomPercent( + timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps), + ); }} // h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible // track stays 2px and the thumb 10px, only the pointer box grows. @@ -447,7 +454,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool aria-label="Zoom in" onClick={() => { setZoomMode("manual"); - setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent)); + setManualZoomPercent( + getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps), + ); }} className={flatIdle} > diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5bdece645..8874acb80 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 52f10ae87..ca4080205 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}
{ }); describe("useGestureRecording", () => { + it("keeps only the latest gesture sample in each output frame", () => { + const animationFrames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + let nowMs = 0; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); + + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const element = iframe.contentDocument!.createElement("div"); + element.id = "card"; + iframe.contentDocument!.body.append(element); + + let recording: ReturnType | null = null; + function Harness() { + recording = useGestureRecording(); + return null; + } + const root = mountReactHarness(); + + act(() => recording?.startRecording(element, iframe)); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 10 })); + nowMs = 1; + act(() => animationFrames.shift()?.(1)); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 20 })); + nowMs = 10; + act(() => animationFrames.shift()?.(10)); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 40 })); + nowMs = 20; + act(() => animationFrames.shift()?.(20)); + document.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 50 })); + nowMs = 30; + act(() => animationFrames.shift()?.(30)); + + let samples: GestureSample[] = []; + act(() => { + samples = recording?.stopRecording() ?? []; + }); + + expect(samples).toEqual([ + { time: 0, properties: { x: 10, y: 0 } }, + { time: 1 / 30, properties: { x: 40, y: 0 } }, + ]); + act(() => root.unmount()); + }); + it("releases the preview runtime when unmounted during a recording", () => { const cancelAnimationFrame = vi.fn(); vi.stubGlobal( diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 63488df85..78c778b24 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore, liveTime } from "../player/store/playerStore"; +import { frameToSeconds, secondsToFrame } from "../player/lib/time"; export interface GestureSample { time: number; @@ -139,7 +140,18 @@ function recordSample(r: RecordingRefs, time: number, properties: Record { + 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 a434677c4..88d4f1ca8 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 0caa0e326..a7558d56a 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 7335a0fad..f415c9fce 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 1c3022c85..28260a275 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 000000000..8772785ac --- /dev/null +++ b/packages/studio/src/player/components/Timeline.virtualization.test.tsx @@ -0,0 +1,400 @@ +// @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 mountTimeline(element: React.ReactElement) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => root.render(element)); + await act(async () => {}); + return { host, root }; +} + +async function dispatchScroll(scroller: HTMLElement): Promise { + await act(async () => { + scroller.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); +} + +function clipsByTrack(count: number, duration = 1, includeLabels = false) { + return Array.from({ length: count }, (_, track) => ({ + id: `clip-${track}`, + ...(includeLabels ? { label: `Clip ${track}` } : {}), + tag: "div", + start: 0, + duration, + track, + })); +} + +async function scrollTimelineHorizontally( + scroller: HTMLElement, + scrollLeft: number, +): Promise { + scroller.scrollLeft = scrollLeft; + await dispatchScroll(scroller); +} + +// 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: clipsByTrack(10_000), + }); + + const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 })); + + 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, root } = await mountTimeline( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ); + try { + 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(); + + if (scroller) await dispatchScroll(scroller); + 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, + elements: clipsByTrack(1_000), + }); + + const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 3 })); + + 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: clipsByTrack(40, 10, true), + }); + + const { host, root } = await mountTimeline( + React.createElement(Timeline, { + renderClipContent: () => React.createElement("span", { "data-rich-content": true }), + }), + ); + 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); + + if (scroller) await dispatchScroll(scroller); + + 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"]'); + if (scroller) await dispatchScroll(scroller); + 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 dispatchScroll(scroller); + + 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 3cb0d52e6..edc94b171 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). */} -