Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .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
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
2 changes: 1 addition & 1 deletion packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 3 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,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", () => {
Expand Down
33 changes: 27 additions & 6 deletions packages/studio/src/player/components/timelineZoomInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -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);
Expand All @@ -240,6 +241,7 @@ describe("requestTimelineZoom", () => {
const unregisterOlder = registerTimelineZoomViewport({
scroll: document.createElement("div"),
contentOrigin: 32,
publishScroll,
});
viewport();
unregisterOlder();
Expand All @@ -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 });
Expand Down
6 changes: 5 additions & 1 deletion packages/studio/src/player/components/timelineZoomInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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();
}
Expand Down
94 changes: 90 additions & 4 deletions packages/studio/src/player/components/useTimelinePlayhead.test.tsx
Original file line number Diff line number Diff line change
@@ -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) },
});
Expand All @@ -27,16 +30,24 @@ 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);
const durationRef = useRef(60);
useTimelinePlayhead({
playheadRef: { current: document.createElement("div") },
scrollRef,
syncScrollViewport,
ppsRef: { current: pps },
durationRef,
isDragging: { current: dragging },
Expand Down Expand Up @@ -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 (
<>
<Harness scroll={scroll} syncScrollViewport={syncScrollViewport} />
{renderTimeRange.start <= 59 && renderTimeRange.end >= 59 && <span data-clip="59" />}
<output>{viewport.scrollLeft}</output>
</>
);
}
const root = createRoot(container);
roots.push(root);
act(() => root.render(<Probe />));
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({
Expand Down
Loading
Loading