From 042ec2ebd07f296477f80658e391365b38749c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 8 Oct 2026 08:37:55 +0000 Subject: [PATCH] fix(studio): restore overscan and publish the zoom viewport (#5151) * fix(studio): restore the timeline overscan budget * test(studio): align window witnesses with the restored budget * fix(studio): publish the viewport when a zoom anchor lands * fix(studio): publish the viewport when a zoom-out ends at its laid-out scale An eased zoom-out that ends at the scale already laid out only moves the scroll position, so the timeline drew from the old position until the next scroll event. The zoom input now publishes that scroll through the same callback the zoom anchor uses. * fix(studio): a pan at the laid-out scale publishes its scroll before paint The same-scale ending of a zoom ran outside React, so its scroll publish rendered on the next task, after the browser painted the old window. It now flushes like the scale change beside it, and its test pans at the laid-out scale instead of changing it. * test(studio): a same-scale pan must render its scroll before paint Reads the render window outside act, so unwrapping the flush fails it, and records each published scroll at call time instead of reading the live element afterwards. --- .github/workflows/ci.yml | 10 ++ .github/workflows/player-perf.yml | 10 ++ .github/workflows/preview-regression.yml | 10 ++ .github/workflows/regression.yml | 10 ++ .github/workflows/windows-render.yml | 10 ++ .../src/components/TimelineToolbar.test.tsx | 2 +- .../player/components/timelineLayout.test.ts | 6 +- .../components/timelineZoomInput.test.ts | 33 +++++-- .../player/components/timelineZoomInput.ts | 6 +- .../components/useTimelinePlayhead.test.tsx | 94 ++++++++++++++++++- .../player/components/useTimelinePlayhead.ts | 21 ++++- .../components/useTimelineProviderState.tsx | 1 + .../lib/timelineViewportBudgets.test.ts | 2 +- .../src/player/lib/timelineViewportBudgets.ts | 2 +- 14 files changed, 197 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75dc5cdbac..94bf8e5141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,10 +85,20 @@ jobs: - name: Reject accidental file deletions if: github.event_name == 'pull_request' run: node scripts/check-no-main-deletions.mjs --base origin/main + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + if: github.event_name == 'pull_request' + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: token: "" + base: ${{ steps.base.outputs.sha }} filters: | catalog_index: - "registry/**" diff --git a/.github/workflows/player-perf.yml b/.github/workflows/player-perf.yml index f8310a70f1..811f9a41ac 100644 --- a/.github/workflows/player-perf.yml +++ b/.github/workflows/player-perf.yml @@ -30,10 +30,20 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + if: github.event_name == 'pull_request' + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: token: "" + base: ${{ steps.base.outputs.sha }} filters: | perf: - "packages/player/**" diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index f7666d8361..ee66a154ea 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -29,10 +29,20 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + if: github.event_name == 'pull_request' + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: token: "" + base: ${{ steps.base.outputs.sha }} filters: | preview: - "packages/core/**" diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 5b42d5515e..9f8832bcc3 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -50,11 +50,21 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + if: github.event_name == 'pull_request' + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter if: github.event_name != 'schedule' with: token: "" + base: ${{ steps.base.outputs.sha }} filters: | code: - "packages/core/**" diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index 6b79739d68..7c717fab65 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -53,10 +53,20 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + if: github.event_name == 'pull_request' + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4 id: filter with: token: "" + base: ${{ steps.base.outputs.sha }} # A file counts only if it matches every pattern. Player and Studio `src/` run in a browser # and no code reads a package README, so a diff confined to them skips Windows. predicate-quantifier: every diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index b35d299c4b..57abdb229f 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -271,7 +271,7 @@ describe("TimelineToolbar Fit", () => { timelineFitPps: 10, timelinePps: 10, }); - return registerTimelineZoomViewport({ scroll, contentOrigin: 32 }); + return registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll: () => {} }); } it("moves the slider and its readout with a zoom while it is previewed", () => { diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index 3a5c11c041..14f4d525e2 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -27,13 +27,13 @@ import { resolveInsertRow } from "./timelineCollision"; import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; describe("horizontal timeline window", () => { - it("adds the shared half-viewport overscan on each side and clamps to duration", () => { + it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => { expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual( - { start: 0, end: 8.5 }, + { start: 0, end: 7.25 }, ); expect( getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20), - ).toEqual({ start: 14.5, end: 20 }); + ).toEqual({ start: 15.75, end: 20 }); }); it("generates globally aligned ticks directly inside the bounded window", () => { diff --git a/packages/studio/src/player/components/timelineZoomInput.test.ts b/packages/studio/src/player/components/timelineZoomInput.test.ts index f7ee6c8fa9..10ca177402 100644 --- a/packages/studio/src/player/components/timelineZoomInput.test.ts +++ b/packages/studio/src/player/components/timelineZoomInput.test.ts @@ -49,6 +49,7 @@ afterEach(() => { }); let unregisterViewport = () => {}; +const publishScroll = vi.fn(); /** A 1080px timeline viewport with 32px of track headers, holding one scaled row. */ function viewport(scrollLeft = 0, scrollWidth = 20_000) { @@ -60,7 +61,7 @@ function viewport(scrollLeft = 0, scrollWidth = 20_000) { }); const row = scroll.appendChild(document.createElement("div")); row.setAttribute("data-timeline-zoom-scale", ""); - unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 }); + unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll }); return { scroll, row }; } @@ -120,7 +121,7 @@ describe("requestTimelineZoom", () => { it("lays a zoom-out about the left edge out at once, before it shows unmounted time", () => { usePlayerStore.setState({ duration: 1000 }); viewport(); - // Mounted to (1080 - 32 + 540) / 10 = 158.8 s; at 5 px/s the view reaches 209.6 s. + // Mounted to (1080 - 32 + 270) / 10 = 131.8 s; at 5 px/s the view reaches 209.6 s. requestTimelineZoom(50, { time: 0, x: 32 }); vi.advanceTimersToNextFrame(); expect(usePlayerStore.getState().timelinePps).toBe(5); @@ -134,14 +135,14 @@ describe("requestTimelineZoom", () => { timelinePps: 100, }); viewport(5000); - // Mounted 44.28..65.88 s; at 60 px/s about 55.24 s the view shows 46.5..63.97 s. - requestTimelineZoom(600, { time: 55.24, x: 556 }); + // Mounted 46.98..63.18 s; at 70 px/s about 55.24 s the view shows 47.75..62.73 s. + requestTimelineZoom(700, { time: 55.24, x: 556 }); vi.advanceTimersToNextFrame(); expect(usePlayerStore.getState().timelinePps).toBe(100); }); it("lays out a zoom-out before it shows past the window ruler ticks are drawn in", () => { - // 50 s of clips in content 1996 s wide: ticks are drawn to 157 s, a view and a half in. + // 50 s of clips in content 1996 s wide: ticks are drawn to 131.8 s, a view and a quarter in. usePlayerStore.setState({ duration: 50 }); viewport(); requestTimelineZoom(60, { time: 0, x: 32 }); @@ -230,7 +231,7 @@ describe("requestTimelineZoom", () => { const { scroll } = viewport(); requestTimelineZoom(150); unregisterViewport(); - unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 }); + unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll }); await Promise.resolve(); vi.advanceTimersByTime(200); expect(usePlayerStore.getState().timelinePps).toBe(15); @@ -240,6 +241,7 @@ describe("requestTimelineZoom", () => { const unregisterOlder = registerTimelineZoomViewport({ scroll: document.createElement("div"), contentOrigin: 32, + publishScroll, }); viewport(); unregisterOlder(); @@ -258,6 +260,25 @@ describe("requestTimelineZoom", () => { }); describe("zoomTimelineToRange", () => { + it("tells the timeline where a pan at the laid-out scale scrolled to", () => { + usePlayerStore.setState({ + duration: 1000, + zoomMode: "manual", + manualZoomPercent: 1000, + timelineFitPps: 10, + timelinePps: 100, + }); + const { scroll } = viewport(0); + const published: number[] = []; + publishScroll.mockImplementation((el: HTMLDivElement) => published.push(el.scrollLeft)); + void zoomTimelineToRange(60, 70); + for (let i = 0; i < 40; i++) vi.advanceTimersToNextFrame(); + publishScroll.mockReset(); + expect(usePlayerStore.getState().timelinePps).toBe(100); + expect(scroll.scrollLeft).toBeGreaterThan(5000); + expect(published.at(-1)).toBe(scroll.scrollLeft); + }); + it("fills the width with the range and puts its start at the left margin", () => { viewport(); void zoomTimelineToRange(40, 90, { smooth: false }); diff --git a/packages/studio/src/player/components/timelineZoomInput.ts b/packages/studio/src/player/components/timelineZoomInput.ts index a2e82866f0..207f033f3d 100644 --- a/packages/studio/src/player/components/timelineZoomInput.ts +++ b/packages/studio/src/player/components/timelineZoomInput.ts @@ -21,6 +21,7 @@ export interface TimelineZoomAnchor { export interface TimelineZoomViewport { scroll: HTMLDivElement; contentOrigin: number; + publishScroll: (scroll: HTMLDivElement) => void; } /** How an eased zoom ended: it reached its range, or a person's zoom or the caller stopped it. */ @@ -209,7 +210,10 @@ function commitPreview() { ); } preview = null; - if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) view.scroll.scrollLeft = left; + if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) { + view.scroll.scrollLeft = left; + flushSync(() => view.publishScroll(view.scroll)); + } clearScaled(view.scroll); emitPreview(); } diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index 528f3fb43a..bda431cf1f 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -1,20 +1,23 @@ // @vitest-environment happy-dom -import { act, useRef } from "react"; +import { act, useLayoutEffect, useRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; +import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; +import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; +import { requestTimelineZoom, settleTimelineZoom } from "./timelineZoomInput"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const ORIGIN = 32; -function scrollBox(scrollLeft: number) { +function scrollBox(scrollLeft: number, clientWidth = 800) { const el = document.createElement("div"); let left = scrollLeft; Object.defineProperties(el, { - clientWidth: { value: 800 }, + clientWidth: { value: clientWidth }, scrollWidth: { value: 20_000 }, scrollLeft: { get: () => left, set: (v: number) => (left = v) }, }); @@ -27,9 +30,16 @@ interface HarnessProps { scroll: HTMLDivElement; dragging?: boolean; zoomMode?: ZoomMode; + syncScrollViewport?: (scroll: HTMLDivElement) => void; } -function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" }: HarnessProps) { +function Harness({ + pps: fixedPps, + scroll, + dragging = false, + zoomMode = "manual", + syncScrollViewport = () => {}, +}: HarnessProps) { const storePps = usePlayerStore((s) => s.timelinePps); const pps = fixedPps ?? storePps; const scrollRef = useRef(scroll); @@ -37,6 +47,7 @@ function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" useTimelinePlayhead({ playheadRef: { current: document.createElement("div") }, scrollRef, + syncScrollViewport, ppsRef: { current: pps }, durationRef, isDragging: { current: dragging }, @@ -238,6 +249,81 @@ describe("useTimelinePlayhead follow while paused", () => { }); }); +describe("useTimelinePlayhead committed viewport", () => { + function mountViewport(scrollLeft: number) { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + usePlayerStore.setState({ + zoomMode: "manual", + manualZoomPercent: 1000, + timelineFitPps: 10, + timelinePps: 100, + }); + const scroll = scrollBox(scrollLeft, 1080); + const container = document.createElement("div"); + function Probe() { + const pps = usePlayerStore((s) => s.timelinePps); + const { viewport, setScrollRef, syncScrollViewport } = useTimelineScrollViewport( + useRef(scroll), + [], + ); + useLayoutEffect(() => setScrollRef(scroll), [setScrollRef]); + const { renderTimeRange } = useTimelineClipRenderWindow({ + tracks: [], + viewport, + pixelsPerSecond: pps, + contentOrigin: ORIGIN, + duration: 100, + }); + return ( + <> + + {renderTimeRange.start <= 59 && renderTimeRange.end >= 59 && } + {viewport.scrollLeft} + + ); + } + const root = createRoot(container); + roots.push(root); + act(() => root.render()); + return { scroll, container }; + } + + afterEach(() => vi.unstubAllGlobals()); + + it("keeps the visible 59-second clip mounted when a pointer zoom commits", () => { + const { scroll, container } = mountViewport(5000); + expect(container.querySelector('[data-clip="59"]')).not.toBeNull(); + act(() => { + requestTimelineZoom(1090, { time: 55.24, x: 556 }); + settleTimelineZoom(); + }); + expect(scroll.scrollLeft).toBeCloseTo(5497.16); + expect(container.querySelector('[data-clip="59"]')).not.toBeNull(); + expect(Number(container.querySelector("output")?.textContent)).toBeCloseTo(5497.16); + }); + + it("renders a pan at the laid-out scale before the browser paints", () => { + const { scroll, container } = mountViewport(0); + const actEnvironment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const wasActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = false; + try { + requestTimelineZoom(1000, { time: 60, x: ORIGIN }); + settleTimelineZoom(); + expect(scroll.scrollLeft).toBe(6000); + expect(container.querySelector("output")?.textContent).toBe("6000"); + } finally { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = wasActEnvironment; + } + }); +}); + describe("useTimelinePlayhead wheel zoom", () => { beforeEach(() => { vi.useFakeTimers({ diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 6310fb537e..65db9cc195 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -42,6 +42,7 @@ function revealPlayheadScrollLeft( interface UseTimelinePlayheadInput { playheadRef: React.RefObject; scrollRef: React.RefObject; + syncScrollViewport: (scroll: HTMLDivElement) => void; ppsRef: React.RefObject; durationRef: React.RefObject; isDragging: React.RefObject; @@ -61,6 +62,7 @@ interface UseTimelinePlayheadInput { export function useTimelinePlayhead({ playheadRef, scrollRef, + syncScrollViewport, ppsRef, durationRef, isDragging, @@ -100,6 +102,7 @@ export function useTimelinePlayhead({ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth); const left = anchor.time * pps + contentOrigin - anchor.x; scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, left)); + syncScrollViewport(scroll); return; } const zoomed = userZoomCount !== prevZoomCount; @@ -121,7 +124,8 @@ export function useTimelinePlayhead({ scroll.scrollLeft = zoomed ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored) : anchored; - }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]); + syncScrollViewport(scroll); + }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin, syncScrollViewport]); const syncPlayheadPosition = useCallback( (time: number) => { @@ -279,14 +283,25 @@ export function useTimelinePlayhead({ // A press meets the zoom it sees, not the one still waiting to be laid out. scroll.addEventListener("pointerdown", settleTimelineZoom, { capture: true }); scroll.addEventListener("scroll", redrawTimelineZoomPreview, { passive: true }); - const unregisterZoomViewport = registerTimelineZoomViewport({ scroll, contentOrigin }); + const unregisterZoomViewport = registerTimelineZoomViewport({ + scroll, + contentOrigin, + publishScroll: syncScrollViewport, + }); return () => { scroll.removeEventListener("wheel", handlePinchWheel, { capture: true }); scroll.removeEventListener("pointerdown", settleTimelineZoom, { capture: true }); scroll.removeEventListener("scroll", redrawTimelineZoomPreview); unregisterZoomViewport(); }; - }, [handlePinchWheel, scrollRef, timelineReady, elementsLength, contentOrigin]); + }, [ + handlePinchWheel, + scrollRef, + timelineReady, + elementsLength, + contentOrigin, + syncScrollViewport, + ]); return { seekFromX, autoScrollDuringDrag, dragScrollRaf }; } diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 96c441381a..4552b52615 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -336,6 +336,7 @@ export function useTimelineProviderState({ const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({ playheadRef, scrollRef, + syncScrollViewport, ppsRef, durationRef, isDragging, diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts index 9972db441c..3c8a56496c 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts @@ -9,7 +9,7 @@ describe("timeline viewport budgets", () => { expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({ directScrollSafetyPx: 8_000_000, rowOverscanPerSide: 2, - timeOverscanViewportRatio: 0.5, + timeOverscanViewportRatio: 0.25, maxMountedRows: 64, maxMountedClipRoots: 512, maxMountedClipRootsPerRow: 128, diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.ts b/packages/studio/src/player/lib/timelineViewportBudgets.ts index d51ac9baec..4e4c2e3e1c 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts @@ -61,7 +61,7 @@ export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); / export const TIMELINE_VIEWPORT_BUDGETS: Readonly = Object.freeze({ directScrollSafetyPx: 8_000_000, rowOverscanPerSide: 2, - timeOverscanViewportRatio: 0.5, + timeOverscanViewportRatio: 0.25, maxMountedRows: 64, maxMountedClipRoots: 512, maxMountedClipRootsPerRow: 128,