From 3a8051895fd75bb890d5411d2fbc1e9dd74f893f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 19:08:05 -0700 Subject: [PATCH 1/8] fix(ci): change detection compares a pull request with current main dorny/paths-filter with token "" diffs pull_request.base.sha..merge with two dots; base.sha goes stale as main moves, so a PR behind main counted main's newer commits as its own and ran unrelated gates. Pass the merge commit's first parent as base on pull requests. --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/player-perf.yml | 7 +++++++ .github/workflows/preview-regression.yml | 7 +++++++ .github/workflows/regression.yml | 7 +++++++ .github/workflows/windows-render.yml | 7 +++++++ 5 files changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75dc5cdbac..f1803f90c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,10 +85,17 @@ 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 main'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: echo "sha=$(git rev-parse HEAD^1)" >> "$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..61f7a7b974 100644 --- a/.github/workflows/player-perf.yml +++ b/.github/workflows/player-perf.yml @@ -30,10 +30,17 @@ 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 main'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: echo "sha=$(git rev-parse HEAD^1)" >> "$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..7e7a7d92dd 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -29,10 +29,17 @@ 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 main'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: echo "sha=$(git rev-parse HEAD^1)" >> "$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..734be50bd0 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -50,11 +50,18 @@ 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 main'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: echo "sha=$(git rev-parse HEAD^1)" >> "$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..2a60f09612 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -53,10 +53,17 @@ 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 main'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: echo "sha=$(git rev-parse HEAD^1)" >> "$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 From 12a4718d4b6938327d8a56d256f6765cab7c312c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 19:19:41 -0700 Subject: [PATCH 2/8] fix(ci): the pull request base step fails unless HEAD is the merge commit echo "sha=$(git rev-parse HEAD^1)" exited 0 when rev-parse failed, and on a non-merge checkout HEAD^1 would be the PR's previous commit, silently skipping gates. Verify HEAD^2 exists and assign the SHA so a failure stops the step. --- .github/workflows/ci.yml | 7 +++++-- .github/workflows/player-perf.yml | 7 +++++-- .github/workflows/preview-regression.yml | 7 +++++-- .github/workflows/regression.yml | 7 +++++-- .github/workflows/windows-render.yml | 7 +++++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1803f90c1..94bf8e5141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,11 +86,14 @@ jobs: 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 main's newer commits as the PR's changes. + # 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: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + 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: diff --git a/.github/workflows/player-perf.yml b/.github/workflows/player-perf.yml index 61f7a7b974..811f9a41ac 100644 --- a/.github/workflows/player-perf.yml +++ b/.github/workflows/player-perf.yml @@ -31,11 +31,14 @@ jobs: 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 main's newer commits as the PR's changes. + # 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: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + 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: diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index 7e7a7d92dd..ee66a154ea 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -30,11 +30,14 @@ jobs: 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 main's newer commits as the PR's changes. + # 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: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + 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: diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index 734be50bd0..9f8832bcc3 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -51,11 +51,14 @@ jobs: 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 main's newer commits as the PR's changes. + # 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: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + 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' diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index 2a60f09612..7c717fab65 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -54,11 +54,14 @@ jobs: 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 main's newer commits as the PR's changes. + # 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: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + 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: From 5edee34a45cb202c1e468454ce5f39a6b5fcb65e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 20:11:58 -0700 Subject: [PATCH 3/8] fix(studio): timeline scrolling mounts a quarter viewport each side; zoom previews keep half #5109 widened the render window to half a viewport so zoom-out previews have clips to show; every scroll step then mounted it and the viewport gate's interaction p95 went over budget. Widen only while a zoom preview is live, telling listeners before its first frame and widening the preview's drawn range once mounted. --- .../player/components/timelineLayout.test.ts | 12 ++- .../components/timelineViewportGeometry.ts | 3 +- .../components/timelineZoomInput.test.ts | 25 ++++++ .../player/components/timelineZoomInput.ts | 19 ++++- .../useTimelineClipRenderWindow.test.tsx | 82 +++++++++++++++++++ .../components/useTimelineClipRenderWindow.ts | 20 ++++- .../lib/timelineViewportBudgets.test.ts | 3 +- .../src/player/lib/timelineViewportBudgets.ts | 5 +- 8 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index 3a5c11c041..11fce8c420 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -27,13 +27,19 @@ 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 a quarter-viewport overscan on each side at rest 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("takes the wider overscan a zoom preview asks for", () => { + expect( + getTimelineRenderTimeRange({ scrollLeft: 1_000, clientWidth: 500 }, 100, 200, 20, 0.5), + ).toEqual({ start: 5.5, end: 15.5 }); }); it("generates globally aligned ticks directly inside the bounded window", () => { diff --git a/packages/studio/src/player/components/timelineViewportGeometry.ts b/packages/studio/src/player/components/timelineViewportGeometry.ts index 6c408a0128..f00efab808 100644 --- a/packages/studio/src/player/components/timelineViewportGeometry.ts +++ b/packages/studio/src/player/components/timelineViewportGeometry.ts @@ -8,11 +8,12 @@ export function getTimelineRenderTimeRange( pixelsPerSecond: number, contentOrigin: number, duration: number, + overscanRatio = TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio, ): TimelineTimeRange { if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) { return { start: 0, end: 0 }; } - const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio; + const overscanPx = viewport.clientWidth * overscanRatio; const startPx = viewport.scrollLeft - contentOrigin - overscanPx; const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx; return { diff --git a/packages/studio/src/player/components/timelineZoomInput.test.ts b/packages/studio/src/player/components/timelineZoomInput.test.ts index f7ee6c8fa9..0677042d0e 100644 --- a/packages/studio/src/player/components/timelineZoomInput.test.ts +++ b/packages/studio/src/player/components/timelineZoomInput.test.ts @@ -6,6 +6,7 @@ import { isTimelineMoving, subscribeTimelineMotion } from "./timelineMotion"; import { cancelTimelineZoom, isTimelineZoomPreviewing, + markTimelineZoomWindowMounted, subscribeTimelineZoomPreview, redrawTimelineZoomPreview, currentTimelineRange, @@ -64,6 +65,15 @@ function viewport(scrollLeft = 0, scrollWidth = 20_000) { return { scroll, row }; } +/** What the timeline's render window does: mounts the zoom's wider window while a preview shows. */ +let unmountZoomWindow = () => {}; +beforeEach(() => { + unmountZoomWindow = subscribeTimelineZoomPreview(() => { + if (isTimelineZoomPreviewing()) markTimelineZoomWindowMounted(0.5); + }); +}); +afterEach(() => unmountZoomWindow()); + /** Where `time` lands on screen once the committed zoom is laid out. */ const laidOutX = (time: number) => { const anchor = takeTimelineZoomAnchor(); @@ -140,6 +150,21 @@ describe("requestTimelineZoom", () => { expect(usePlayerStore.getState().timelinePps).toBe(100); }); + it("lays the same zoom-out out at once while only the rest window is mounted", () => { + unmountZoomWindow(); + usePlayerStore.setState({ + duration: 100, + zoomMode: "manual", + manualZoomPercent: 1000, + timelinePps: 100, + }); + viewport(5000); + // Mounted 46.98..63.18 s at rest, short of the 46.5..63.97 s the preview would show. + requestTimelineZoom(600, { time: 55.24, x: 556 }); + vi.advanceTimersToNextFrame(); + expect(usePlayerStore.getState().timelinePps).toBe(60); + }); + 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. usePlayerStore.setState({ duration: 50 }); diff --git a/packages/studio/src/player/components/timelineZoomInput.ts b/packages/studio/src/player/components/timelineZoomInput.ts index a2e82866f0..b413fe9661 100644 --- a/packages/studio/src/player/components/timelineZoomInput.ts +++ b/packages/studio/src/player/components/timelineZoomInput.ts @@ -154,9 +154,21 @@ function previewNeedsLayout(p: ZoomPreview, scroll: HTMLDivElement, contentOrigi } /** The times laid out now: the render window clips, ruler ticks and beat lines are drawn in. */ -function drawnRange(scroll: HTMLDivElement, pps: number, contentOrigin: number): TimelineTimeRange { +function drawnRange( + scroll: HTMLDivElement, + pps: number, + contentOrigin: number, + overscanRatio?: number, +): TimelineTimeRange { const contentEnd = (scroll.scrollWidth - contentOrigin) / pps; - return getTimelineRenderTimeRange(scroll, pps, contentOrigin, contentEnd); + return getTimelineRenderTimeRange(scroll, pps, contentOrigin, contentEnd, overscanRatio); +} + +/** Called once the wider window a preview asked for is mounted, so the preview may show it. */ +export function markTimelineZoomWindowMounted(overscanRatio: number) { + const view = viewport; + if (!preview || !view) return; + preview.drawn = drawnRange(view.scroll, preview.basePps, view.contentOrigin, overscanRatio); } function scalePreview(scroll: HTMLElement) { @@ -237,6 +249,7 @@ function request(percent: number, anchor: TimelineZoomAnchor | null, byPerson: b const { scroll, contentOrigin } = view; const now = shown(scroll); const at = anchor ?? defaultAnchor(view, now.pps, now.left); + const starting = !preview; preview ??= { percent: clamped, pps, @@ -254,6 +267,8 @@ function request(percent: number, anchor: TimelineZoomAnchor | null, byPerson: b preview.pps = pps; preview.shift = scroll.scrollLeft - left; preview.byPerson ||= byPerson; + // Before the first frame, so the timeline mounts the preview's wider window in time to show it. + if (starting) emitPreview(); if (!frame) frame = requestAnimationFrame(drawPreview); if (restTimer) clearTimeout(restTimer); restTimer = setTimeout(commitPreview, TIMELINE_REST_MS); diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx new file mode 100644 index 0000000000..b53308fc4c --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { usePlayerStore } from "../store/playerStore"; +import { + cancelTimelineZoom, + registerTimelineZoomViewport, + requestTimelineZoom, +} from "./timelineZoomInput"; +import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; +import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | null = null; +let unregisterViewport = () => {}; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout"], + }); + // 1000% of a 10 px/s fit, scrolled to 50 s in a 1080px viewport with 32px of track headers. + usePlayerStore.setState({ + zoomMode: "manual", + manualZoomPercent: 1000, + timelineFitPps: 10, + timelinePps: 100, + duration: 100, + currentTime: 0, + }); + const scroll = document.createElement("div"); + Object.defineProperties(scroll, { + clientWidth: { value: 1080 }, + scrollWidth: { value: 10_032 }, + scrollLeft: { value: 5000, writable: true }, + }); + unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 }); +}); + +afterEach(() => { + act(() => cancelTimelineZoom()); + act(() => root?.unmount()); + root = null; + unregisterViewport(); + vi.useRealTimers(); +}); + +function renderWindow() { + let range = { start: 0, end: 0 }; + function Harness() { + ({ renderTimeRange: range } = useTimelineClipRenderWindow({ + tracks: [], + viewport: { scrollLeft: 5000, clientWidth: 1080 } as TimelineScrollViewportSnapshot, + pixelsPerSecond: 100, + contentOrigin: 32, + duration: 100, + })); + return null; + } + const host = document.body.appendChild(document.createElement("div")); + root = createRoot(host); + act(() => root?.render(React.createElement(Harness))); + return () => range; +} + +it("mounts a quarter viewport each side at rest and half while a zoom previews", () => { + const range = renderWindow(); + expect(range()).toEqual({ start: 46.98, end: 63.18 }); + act(() => requestTimelineZoom(600, { time: 55.24, x: 556 })); + expect(range()).toEqual({ start: 44.28, end: 65.88 }); + act(() => cancelTimelineZoom()); + expect(range()).toEqual({ start: 46.98, end: 63.18 }); +}); + +it("lets a zoom-out preview show the wider window it mounted", () => { + renderWindow(); + // At 60 px/s about 55.24 s the view shows 46.5..63.97 s: past the rest window, inside the zoom's. + act(() => requestTimelineZoom(600, { time: 55.24, x: 556 })); + act(() => vi.advanceTimersToNextFrame()); + expect(usePlayerStore.getState().timelinePps).toBe(100); +}); diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts index 13d968a095..2293cc0df4 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -1,10 +1,16 @@ -import { useMemo } from "react"; +import { useLayoutEffect, useMemo, useSyncExternalStore } from "react"; import { createTimelineClipIndex } from "../lib/timelineClipIndex"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; import { getTimelineRenderTimeRange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; +import { + isTimelineZoomPreviewing, + markTimelineZoomWindowMounted, + subscribeTimelineZoomPreview, +} from "./timelineZoomInput"; interface UseTimelineClipRenderWindowInput { tracks: Parameters[0]; @@ -36,10 +42,18 @@ export function useTimelineClipRenderWindow({ keyframeContextMenuElementId, }: UseTimelineClipRenderWindowInput) { const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]); + const zooming = useSyncExternalStore(subscribeTimelineZoomPreview, isTimelineZoomPreviewing); + const overscanRatio = zooming + ? TIMELINE_VIEWPORT_BUDGETS.zoomOverscanViewportRatio + : TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio; const renderTimeRange = useMemo( - () => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration), - [contentOrigin, duration, pixelsPerSecond, viewport], + () => + getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration, overscanRatio), + [contentOrigin, duration, overscanRatio, pixelsPerSecond, viewport], ); + useLayoutEffect(() => { + if (zooming) markTimelineZoomWindowMounted(overscanRatio); + }, [overscanRatio, zooming]); const visibleTimeRange = useMemo( () => getTimelineVisibleTimeRange(viewport, pixelsPerSecond, contentOrigin, duration), [contentOrigin, duration, pixelsPerSecond, viewport], diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts index 9972db441c..a5448b2f60 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts @@ -9,7 +9,8 @@ describe("timeline viewport budgets", () => { expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({ directScrollSafetyPx: 8_000_000, rowOverscanPerSide: 2, - timeOverscanViewportRatio: 0.5, + timeOverscanViewportRatio: 0.25, + zoomOverscanViewportRatio: 0.5, 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..7efd1c5dd6 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts @@ -2,6 +2,7 @@ export interface TimelineViewportBudgets { directScrollSafetyPx: number; rowOverscanPerSide: number; timeOverscanViewportRatio: number; + zoomOverscanViewportRatio: number; maxMountedRows: number; maxMountedClipRoots: number; maxMountedClipRootsPerRow: number; @@ -61,7 +62,9 @@ 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, + // Wider only while a zoom previews, so a zoom-out has clips to show; every scroll step pays for the window. + zoomOverscanViewportRatio: 0.5, maxMountedRows: 64, maxMountedClipRoots: 512, maxMountedClipRootsPerRow: 128, From 3e72f02659c916cf11bcad48b53cca2c797f4fa8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 20:22:47 -0700 Subject: [PATCH 4/8] refactor(studio): drop two comments the names already carry --- packages/studio/src/player/components/timelineZoomInput.ts | 1 - packages/studio/src/player/lib/timelineViewportBudgets.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/studio/src/player/components/timelineZoomInput.ts b/packages/studio/src/player/components/timelineZoomInput.ts index b413fe9661..f5d69c3385 100644 --- a/packages/studio/src/player/components/timelineZoomInput.ts +++ b/packages/studio/src/player/components/timelineZoomInput.ts @@ -164,7 +164,6 @@ function drawnRange( return getTimelineRenderTimeRange(scroll, pps, contentOrigin, contentEnd, overscanRatio); } -/** Called once the wider window a preview asked for is mounted, so the preview may show it. */ export function markTimelineZoomWindowMounted(overscanRatio: number) { const view = viewport; if (!preview || !view) return; diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.ts b/packages/studio/src/player/lib/timelineViewportBudgets.ts index 7efd1c5dd6..7f5ecec245 100644 --- a/packages/studio/src/player/lib/timelineViewportBudgets.ts +++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts @@ -63,7 +63,6 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly = Obje directScrollSafetyPx: 8_000_000, rowOverscanPerSide: 2, timeOverscanViewportRatio: 0.25, - // Wider only while a zoom previews, so a zoom-out has clips to show; every scroll step pays for the window. zoomOverscanViewportRatio: 0.5, maxMountedRows: 64, maxMountedClipRoots: 512, From f5f9e45baeb7a225a4b386a70eebbb1614db3423 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 20:45:02 -0700 Subject: [PATCH 5/8] fix(studio): a landing zoom mounts the rest window once; eased zooms widen before frame one The render window followed isTimelineZoomPreviewing, still true during the landing layout's flushSync, so each landing rendered the wide window and then a second time narrow. It now follows its own flag, cleared before that layout. An eased zoom starts its preview at the current scale before its first frame, so the wider window is mounted when that frame is judged. --- .../components/timelineZoomInput.test.ts | 7 ++++- .../player/components/timelineZoomInput.ts | 11 ++++++- .../useTimelineClipRenderWindow.test.tsx | 29 +++++++++++++++++-- .../components/useTimelineClipRenderWindow.ts | 4 +-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/player/components/timelineZoomInput.test.ts b/packages/studio/src/player/components/timelineZoomInput.test.ts index 0677042d0e..ba1d4e2af1 100644 --- a/packages/studio/src/player/components/timelineZoomInput.test.ts +++ b/packages/studio/src/player/components/timelineZoomInput.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore } from "../store/playerStore"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; import { isTimelineMoving, subscribeTimelineMotion } from "./timelineMotion"; import { cancelTimelineZoom, isTimelineZoomPreviewing, + isTimelineZoomWindowWide, markTimelineZoomWindowMounted, subscribeTimelineZoomPreview, redrawTimelineZoomPreview, @@ -68,8 +70,11 @@ function viewport(scrollLeft = 0, scrollWidth = 20_000) { /** What the timeline's render window does: mounts the zoom's wider window while a preview shows. */ let unmountZoomWindow = () => {}; beforeEach(() => { + let mounted = false; unmountZoomWindow = subscribeTimelineZoomPreview(() => { - if (isTimelineZoomPreviewing()) markTimelineZoomWindowMounted(0.5); + if (isTimelineZoomWindowWide() === mounted) return; + mounted = isTimelineZoomWindowWide(); + if (mounted) markTimelineZoomWindowMounted(TIMELINE_VIEWPORT_BUDGETS.zoomOverscanViewportRatio); }); }); afterEach(() => unmountZoomWindow()); diff --git a/packages/studio/src/player/components/timelineZoomInput.ts b/packages/studio/src/player/components/timelineZoomInput.ts index f5d69c3385..8a0a363120 100644 --- a/packages/studio/src/player/components/timelineZoomInput.ts +++ b/packages/studio/src/player/components/timelineZoomInput.ts @@ -52,6 +52,7 @@ let viewport: TimelineZoomViewport | null = null; /** While a preview shows, scales rows and strips React mounts into the timeline before they paint. */ let mounts: MutationObserver | null = null; let preview: ZoomPreview | null = null; +let wideWindow = false; let frame = 0; let restTimer: ReturnType | null = null; let anchorForCommit: TimelineZoomAnchor | null = null; @@ -88,6 +89,7 @@ export function timelineZoomMapping(pps: number, contentOrigin: number) { /** Whether a zoom is drawn scaled right now, so boxes read off the page are scaled too. */ export const isTimelineZoomPreviewing = (): boolean => preview !== null; +export const isTimelineZoomWindowWide = (): boolean => wideWindow; /** Called each frame a zoom preview moves, and once when it is laid out or dropped. */ export function subscribeTimelineZoomPreview(listener: () => void): () => void { @@ -213,6 +215,7 @@ function commitPreview() { const done = preview; if (!view || !done) return; const left = view.scroll.scrollLeft - done.shift; + wideWindow = false; // Ending at the scale already laid out (an eased zoom-out) needs a scroll, not a layout. if (done.pps !== done.basePps) { flushSync(() => @@ -267,7 +270,10 @@ function request(percent: number, anchor: TimelineZoomAnchor | null, byPerson: b preview.shift = scroll.scrollLeft - left; preview.byPerson ||= byPerson; // Before the first frame, so the timeline mounts the preview's wider window in time to show it. - if (starting) emitPreview(); + if (starting) { + wideWindow = true; + emitPreview(); + } if (!frame) frame = requestAnimationFrame(drawPreview); if (restTimer) clearTimeout(restTimer); restTimer = setTimeout(commitPreview, TIMELINE_REST_MS); @@ -302,6 +308,7 @@ function dropPreview() { if (!preview) return; if (viewport) clearScaled(viewport.scroll); preview = null; + wideWindow = false; emitPreview(); } @@ -425,6 +432,8 @@ function easeZoom( const holdsOld = old.start >= start && old.end <= start + (old.end - old.start) * (from.pps / toPps); let layoutFirst = toPps < from.pps && from.pps / toPps <= MAX_PREVIEW_SCALE && holdsOld; + // A preview at the current scale, so the timeline mounts the zoom's wider window before frame one. + if (!layoutFirst) request(fromPercent, anchorAt(0), byPerson); const began = performance.now(); easingTo = toPercent; const step = (now: number) => { diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx index b53308fc4c..74463c5b0d 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx @@ -7,7 +7,10 @@ import { cancelTimelineZoom, registerTimelineZoomViewport, requestTimelineZoom, + settleTimelineZoom, + zoomTimelineToRange, } from "./timelineZoomInput"; +import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; @@ -46,16 +49,20 @@ afterEach(() => { vi.useRealTimers(); }); -function renderWindow() { +const SNAPSHOT = { scrollLeft: 5000, clientWidth: 1080 } as TimelineScrollViewportSnapshot; + +function renderWindow(renders: { start: number; end: number }[] = []) { let range = { start: 0, end: 0 }; function Harness() { + const pixelsPerSecond = usePlayerStore((state) => state.timelinePps); ({ renderTimeRange: range } = useTimelineClipRenderWindow({ tracks: [], - viewport: { scrollLeft: 5000, clientWidth: 1080 } as TimelineScrollViewportSnapshot, - pixelsPerSecond: 100, + viewport: SNAPSHOT, + pixelsPerSecond, contentOrigin: 32, duration: 100, })); + renders.push(range); return null; } const host = document.body.appendChild(document.createElement("div")); @@ -80,3 +87,19 @@ it("lets a zoom-out preview show the wider window it mounted", () => { act(() => vi.advanceTimersToNextFrame()); expect(usePlayerStore.getState().timelinePps).toBe(100); }); + +it("lays a zoom out in one render, with the rest window", () => { + const renders: { start: number; end: number }[] = []; + renderWindow(renders); + act(() => requestTimelineZoom(600, { time: 55.24, x: 556 })); + renders.length = 0; + act(() => settleTimelineZoom()); + expect(renders).toEqual([getTimelineRenderTimeRange(SNAPSHOT, 60, 32, 100)]); +}); + +it("keeps an eased zoom-out a preview in its first frame", () => { + renderWindow(); + act(() => void zoomTimelineToRange(60, 80, { smooth: true })); + act(() => vi.advanceTimersToNextFrame()); + expect(usePlayerStore.getState().timelinePps).toBe(100); +}); diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts index 2293cc0df4..eec9bdf6d0 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -7,7 +7,7 @@ import { } from "./timelineViewportGeometry"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; import { - isTimelineZoomPreviewing, + isTimelineZoomWindowWide, markTimelineZoomWindowMounted, subscribeTimelineZoomPreview, } from "./timelineZoomInput"; @@ -42,7 +42,7 @@ export function useTimelineClipRenderWindow({ keyframeContextMenuElementId, }: UseTimelineClipRenderWindowInput) { const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]); - const zooming = useSyncExternalStore(subscribeTimelineZoomPreview, isTimelineZoomPreviewing); + const zooming = useSyncExternalStore(subscribeTimelineZoomPreview, isTimelineZoomWindowWide); const overscanRatio = zooming ? TIMELINE_VIEWPORT_BUDGETS.zoomOverscanViewportRatio : TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio; From 4aeb7f9fb1bb16bc91b25ec5b69eb40737b57c88 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 20:48:47 -0700 Subject: [PATCH 6/8] test(studio): fake performance so the eased-zoom test measures a real first frame --- .../components/useTimelineClipRenderWindow.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx index 74463c5b0d..d016fabd56 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.test.tsx @@ -21,7 +21,13 @@ let unregisterViewport = () => {}; beforeEach(() => { vi.useFakeTimers({ - toFake: ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout"], + toFake: [ + "requestAnimationFrame", + "cancelAnimationFrame", + "setTimeout", + "clearTimeout", + "performance", + ], }); // 1000% of a 10 px/s fit, scrolled to 50 s in a 1080px viewport with 32px of track headers. usePlayerStore.setState({ From 3afd2e1c83001439edf2127b15716af436d353a2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 21:51:15 -0700 Subject: [PATCH 7/8] fix(studio): a landing zoom shows its clips at once, and the viewport gate counts blank zoom-out frames When a zoom landed, the timeline laid it out against the scroll snapshot from before the zoom; the real scroll was published a frame or more later, so a zoom-out far into a project showed no clips or ruler ticks meanwhile. The zoom-anchor layout effect now publishes the scroll it sets, before paint. The viewport gate zooms out by Ctrl+wheel and by pinch from mid-project and fails on any frame with no ruler tick in view. --- .../components/useTimelinePlayhead.test.tsx | 16 +++++ .../player/components/useTimelinePlayhead.ts | 6 +- .../components/useTimelineProviderState.tsx | 1 + .../tests/e2e/timeline-viewport-verdict.mjs | 4 +- .../e2e/timeline-viewport-verdict.test.mjs | 8 ++- .../tests/e2e/timeline-virtualization.mjs | 67 +++++++++++++++++++ 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index 528f3fb43a..01e418adf9 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -29,6 +29,10 @@ interface HarnessProps { zoomMode?: ZoomMode; } +/** The scroll positions the hook published, read at the moment it published them. */ +const published: number[] = []; +const syncScrollViewport = (el: HTMLDivElement) => published.push(el.scrollLeft); + function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" }: HarnessProps) { const storePps = usePlayerStore((s) => s.timelinePps); const pps = fixedPps ?? storePps; @@ -50,6 +54,7 @@ function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" timelineReady: true, elementsLength: 1, contentOrigin: ORIGIN, + syncScrollViewport, }); return null; } @@ -67,6 +72,7 @@ function mount(props: HarnessProps) { } beforeEach(() => { + published.length = 0; usePlayerStore.setState({ currentTime: 0, isPlaying: false, beatDragging: false }); }); afterEach(() => { @@ -103,6 +109,16 @@ describe("useTimelinePlayhead zoom anchor", () => { expect(scroll.scrollLeft).toBe(0); }); + it("publishes the scroll a zoom lands on, so the clips mounted for it are the ones shown", () => { + usePlayerStore.setState({ currentTime: 6 }); + const scroll = scrollBox(400); + const rezoom = mount({ pps: 100, scroll }); + published.length = 0; + rezoom({ pps: 200 }, true); + expect(published).toEqual([scroll.scrollLeft]); + expect(scroll.scrollLeft).not.toBe(400); + }); + it("keeps the playhead where it is on screen when the toolbar zooms", () => { usePlayerStore.setState({ currentTime: 6 }); const scroll = scrollBox(400); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 6310fb537e..e86713d19e 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -56,6 +56,7 @@ interface UseTimelinePlayheadInput { elementsLength: number; onSeek?: (time: number) => void; contentOrigin: number; + syncScrollViewport: (el: HTMLDivElement) => void; } export function useTimelinePlayhead({ @@ -75,6 +76,7 @@ export function useTimelinePlayhead({ elementsLength, onSeek, contentOrigin, + syncScrollViewport, }: UseTimelinePlayheadInput) { const dragScrollRaf = useRef(0); const previousZoomModeRef = useRef(zoomMode); @@ -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) => { diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 96c441381a..387051b338 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -350,6 +350,7 @@ export function useTimelineProviderState({ elementsLength: timelineElements.length, onSeek, contentOrigin, + syncScrollViewport, }); const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = useTimelineRazorInteraction({ diff --git a/packages/studio/tests/e2e/timeline-viewport-verdict.mjs b/packages/studio/tests/e2e/timeline-viewport-verdict.mjs index 1e7bcfdf78..c71aa22a26 100644 --- a/packages/studio/tests/e2e/timeline-viewport-verdict.mjs +++ b/packages/studio/tests/e2e/timeline-viewport-verdict.mjs @@ -63,7 +63,7 @@ export function attemptPassed({ responsivenessPassed, passingRuns, requiredPassi return responsivenessPassed && passingRuns >= requiredPassingRuns; } -export function gatePassed({ directScrollApproved, attempts, memoryReturned }) { +export function gatePassed({ directScrollApproved, attempts, memoryReturned, zoomOutBlankFrames }) { const timingPassed = attempts.slice(0, TIMING_ATTEMPTS).some((attempt) => attempt.passed); - return directScrollApproved && timingPassed && memoryReturned; + return directScrollApproved && timingPassed && memoryReturned && zoomOutBlankFrames === 0; } diff --git a/packages/studio/tests/e2e/timeline-viewport-verdict.test.mjs b/packages/studio/tests/e2e/timeline-viewport-verdict.test.mjs index 91c52153ef..6506de7ae8 100644 --- a/packages/studio/tests/e2e/timeline-viewport-verdict.test.mjs +++ b/packages/studio/tests/e2e/timeline-viewport-verdict.test.mjs @@ -131,12 +131,18 @@ describe("attemptPassed", () => { describe("gatePassed", () => { const pass = { passed: true }; const fail = { passed: false }; - const passing = { directScrollApproved: true, attempts: [pass], memoryReturned: true }; + const passing = { + directScrollApproved: true, + attempts: [pass], + memoryReturned: true, + zoomOutBlankFrames: 0, + }; it("passes only when every check holds", () => { expect(gatePassed(passing)).toBe(true); expect(gatePassed({ ...passing, directScrollApproved: false })).toBe(false); expect(gatePassed({ ...passing, memoryReturned: false })).toBe(false); + expect(gatePassed({ ...passing, zoomOutBlankFrames: 1 })).toBe(false); }); it("fails timing only when the attempt and its one rerun both fail", () => { diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs index b53c07065a..eec1dba7cb 100644 --- a/packages/studio/tests/e2e/timeline-virtualization.mjs +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -352,6 +352,14 @@ try { ); } + const zoomOut = { + ctrlWheel: await countZoomOutBlankFrames(page, { steps: 16, deltaY: 100 }), + pinch: await countZoomOutBlankFrames(page, { steps: 60, deltaY: 8 }), + }; + console.error( + `timeline zoom-out blank frames: Ctrl+wheel ${zoomOut.ctrlWheel}, pinch ${zoomOut.pinch}`, + ); + await page.evaluate(() => window.__studioTest.resetTimelinePerformanceFixture()); await page.waitForFunction( () => document.querySelector('[aria-label="Timeline track view"]') === null, @@ -406,6 +414,7 @@ try { }, }, directScrollGate, + zoomOut, attempts, aggregate: { timingPassed: attempts.some((attempt) => attempt.passed), @@ -419,6 +428,7 @@ try { directScrollApproved: directScrollGate.decision === "approved", attempts, memoryReturned, + zoomOutBlankFrames: zoomOut.ctrlWheel + zoomOut.pinch, }) ? 0 : 1; @@ -427,6 +437,63 @@ try { } process.exit(exitCode); +/** + * Zooms in with the toolbar, then out with a Ctrl+wheel gesture (`deltaY` 8 is a trackpad pinch), and + * counts frames until the zoom rests with no ruler tick in view: the ticks are drawn from the render window. + */ +async function countZoomOutBlankFrames(page, { steps, deltaY }) { + for (let i = 0; i < 7; i += 1) await page.click('button[aria-label="Zoom in"]'); + await waitForZoomRest(page); + // Far from the start, where a zoom laid out against the old scroll would show nothing. + await page.evaluate(() => { + const view = document.querySelector("[data-timeline-scroll-viewport]"); + view.scrollLeft = (view.scrollWidth - view.clientWidth) / 2; + }); + await waitForZoomRest(page); + const box = await (await page.$("[data-timeline-scroll-viewport]")).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 3); + await page.evaluate(() => { + const view = document.querySelector("[data-timeline-scroll-viewport]"); + const blank = { frames: 0, running: true }; + const tick = () => { + const r = view.getBoundingClientRect(); + const shown = [...view.querySelectorAll("[data-timeline-grid-cell]")].some((tick) => { + const t = tick.getBoundingClientRect(); + return t.right > r.left && t.left < r.right; + }); + if (!shown) blank.frames += 1; + if (blank.running) requestAnimationFrame(tick); + }; + window.__zoomOutBlank = blank; + requestAnimationFrame(tick); + }); + await page.keyboard.down("Control"); + for (let i = 0; i < steps; i += 1) { + await page.mouse.wheel({ deltaY }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + } + await page.keyboard.up("Control"); + await waitForZoomRest(page); + return page.evaluate(() => { + window.__zoomOutBlank.running = false; + return window.__zoomOutBlank.frames; + }); +} + +/** Until the zoom label holds for 30 frames: a zoom lays out about 150 ms after its last input. */ +async function waitForZoomRest(page) { + await page.evaluate(async () => { + const label = () => document.querySelector('[aria-label="Timeline zoom level"]')?.textContent; + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + for (let held = 0, last = label(); held < 30; ) { + await nextFrame(); + const now = label(); + held = now === last ? held + 1 : 0; + last = now; + } + }); +} + async function waitForFixtureRender(page, elementCount) { const deadline = Date.now() + 60_000; let observed = null; From 2c66bae8907c59413f8ffdbac4bc9f1f9b5a4764 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 21:54:34 -0700 Subject: [PATCH 8/8] test(ci): measure the studio viewport gate at #5151 three times --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94bf8e5141..dd2732a6df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -833,6 +833,10 @@ jobs: needs: [changes] if: needs.changes.outputs.studio == 'true' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sample: [1, 2, 3] timeout-minutes: 18 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -1049,7 +1053,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: timeline-viewport-gate-evidence + name: timeline-viewport-gate-evidence-${{ matrix.sample }} path: | /tmp/timeline-gate-*.json /tmp/studio-open-counts.json