Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down Expand Up @@ -823,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
Expand Down Expand Up @@ -1039,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
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/player-perf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/preview-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/windows-render.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions packages/studio/src/player/components/timelineLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/studio/src/player/components/timelineZoomInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

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,
currentTimelineRange,
Expand Down Expand Up @@ -64,6 +67,18 @@ 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(() => {
let mounted = false;
unmountZoomWindow = subscribeTimelineZoomPreview(() => {
if (isTimelineZoomWindowWide() === mounted) return;
mounted = isTimelineZoomWindowWide();
if (mounted) markTimelineZoomWindowMounted(TIMELINE_VIEWPORT_BUDGETS.zoomOverscanViewportRatio);
});
});
afterEach(() => unmountZoomWindow());

/** Where `time` lands on screen once the committed zoom is laid out. */
const laidOutX = (time: number) => {
const anchor = takeTimelineZoomAnchor();
Expand Down Expand Up @@ -140,6 +155,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 });
Expand Down
27 changes: 25 additions & 2 deletions packages/studio/src/player/components/timelineZoomInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;
let anchorForCommit: TimelineZoomAnchor | null = null;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -154,9 +156,20 @@ 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);
}

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) {
Expand Down Expand Up @@ -202,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(() =>
Expand Down Expand Up @@ -237,6 +251,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,
Expand All @@ -254,6 +269,11 @@ 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) {
wideWindow = true;
emitPreview();
}
if (!frame) frame = requestAnimationFrame(drawPreview);
if (restTimer) clearTimeout(restTimer);
restTimer = setTimeout(commitPreview, TIMELINE_REST_MS);
Expand Down Expand Up @@ -288,6 +308,7 @@ function dropPreview() {
if (!preview) return;
if (viewport) clearScaled(viewport.scroll);
preview = null;
wideWindow = false;
emitPreview();
}

Expand Down Expand Up @@ -411,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) => {
Expand Down
Loading
Loading