Files
hyperframes/packages/studio/src/player/components/timelineLayout.test.ts
T
Miguel Ángel 723d3381c4 fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures

* test(studio): gate timeline viewport performance in Chromium

* refactor(studio): isolate clip drag lifecycle

* refactor(studio): extract timeline render contracts

* perf(studio): centralize timeline viewport geometry

* perf(studio): follow playhead across virtualized rows

* perf(studio): add timeline clip-window index primitive

* perf(studio): virtualize timeline clip windows

* perf(studio): stop timeline scroll work when row virtualization is off

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.

* ci(studio): run the timeline viewport gate on studio changes

The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.

* fix(ci): preserve both timeline gate evidence arms

* ci(studio): report timeline gate arm statuses

* ci(studio): require timeline gate evidence artifacts

* fix(studio): keep dense keyframes readable

* fix(ci): resolve timeline stack audit findings
2026-07-31 18:05:22 +02:00

320 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
CLIP_Y,
INSERT_BOUNDARY_BAND,
getTimelineInsertBoundaryBand,
RULER_H,
TRACK_H,
LANE_H,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
GUTTER,
TRACKS_LEFT_PAD,
getTimelineRowTop,
getTimelineScrubTime,
getTimelineRowFromY,
getTimelineRowOffsets,
getTimelineCanvasHeight,
createTimelineRowGeometry,
getTimelineRowGeometry,
trackHeights,
resolveTimelineAssetDrop,
getTimelineBeatEntries,
} from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
describe("horizontal timeline window", () => {
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: 7.25 },
);
expect(
getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
).toEqual({ start: 15.75, end: 20 });
});
it("generates globally aligned ticks directly inside the bounded window", () => {
const ticks = generateTicks(10_000, 100, undefined, { start: 500.2, end: 501.8 });
const interval = getTimelineMajorTickInterval(10_000, 100);
expect(ticks.major.every((time) => time >= 500.2 && time <= 501.8)).toBe(true);
expect(
ticks.major.every((time) => Math.abs(time / interval - Math.round(time / interval)) < 1e-6),
).toBe(true);
expect(ticks.major.length + ticks.minor.length).toBeLessThan(100);
});
it("slices beat records with original strength indexes and unions a pinned beat", () => {
expect(
getTimelineBeatEntries(
[0, 1, 2, 3],
[0.1, 0.2, 0.3, 0.4],
{ start: 1, end: 3 },
new Set([3]),
),
).toEqual([
{ index: 1, time: 1, strength: 0.2 },
{ index: 2, time: 2, strength: 0.3 },
{ index: 3, time: 3, strength: 0.4 },
]);
});
});
/** N collapsed rows, the shape every caller passes when nothing is expanded. */
const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H);
describe("variable timeline row geometry", () => {
const tracks = [
[{ clipId: "a", laneCount: 0 }],
[{ clipId: "b", laneCount: 2 }],
[{ clipId: "c", laneCount: 1 }],
];
it("resolves every row to the base height when no clip is expanded", () => {
expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]);
expect(trackHeights([[], [], []])).toEqual([TRACK_H, TRACK_H, TRACK_H]);
});
it("adds one lane height per lane on an expanded clip", () => {
expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]);
});
it("derives row tops from cumulative offsets", () => {
const heights = trackHeights(tracks, new Set(["b"]));
expect(getTimelineRowOffsets(heights)).toEqual([
0,
TRACK_H,
2 * TRACK_H + 2 * LANE_H,
3 * TRACK_H + 2 * LANE_H,
]);
expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H);
});
it("maps y inside an expanded lane region back to the expanded track", () => {
const heights = trackHeights(tracks, new Set(["b"]));
const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5;
const row = getTimelineRowFromY(yInSecondExpandedLane, heights);
expect(Math.floor(row)).toBe(1);
expect(row).toBeGreaterThan(1.5);
expect(row).toBeLessThan(2);
});
it("sums resolved row heights into the canvas height", () => {
const heights = trackHeights(tracks, new Set(["b"]));
expect(getTimelineCanvasHeight(heights)).toBe(
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD,
);
});
it("reuses one immutable geometry snapshot for one height array", () => {
const heights = trackHeights(tracks, new Set(["b"]));
const first = getTimelineRowGeometry(heights);
expect(getTimelineRowGeometry(heights)).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(Object.isFrozen(first.rowOffsets)).toBe(true);
});
it("looks up row boundaries through the precomputed geometry", () => {
const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]);
expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry);
expect(geometry.getRowIndex(8)).toBe(1);
expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1);
expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2);
expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2);
expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD);
});
});
describe("collapsed timeline row geometry characterization", () => {
it.each([
[0, 74],
[1, 122],
[4, 266],
])("keeps row %i at content y=%i", (row, expectedTop) => {
expect(getTimelineRowTop(row)).toBe(expectedTop);
});
it.each([
[74, 0],
[86, 0.25],
[146, 1.5],
[290, 4.5],
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
});
it.each([
[0, 146],
[1, 194],
[3, 290],
[5, 386],
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
});
});
describe("track-area breathing pad y-math", () => {
describe("getTimelineRowTop", () => {
it("offsets the first lane below the ruler by the top pad", () => {
expect(getTimelineRowTop(0)).toBe(RULER_H + TRACKS_TOP_PAD);
});
it("advances by one track height per row, keeping the pad", () => {
expect(getTimelineRowTop(1)).toBe(RULER_H + TRACKS_TOP_PAD + TRACK_H);
expect(getTimelineRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H);
});
it("is a strict positive shift from the pre-pad formula (pad is non-zero)", () => {
expect(TRACKS_TOP_PAD).toBeGreaterThan(0);
expect(getTimelineRowTop(2)).toBe(RULER_H + 2 * TRACK_H + TRACKS_TOP_PAD);
});
});
describe("getTimelineRowFromY", () => {
it("is the exact inverse of getTimelineRowTop at lane boundaries", () => {
for (const row of [0, 1, 2, 7]) {
expect(getTimelineRowFromY(getTimelineRowTop(row))).toBeCloseTo(row, 10);
}
});
it("floors a y inside the top pad (above lane 0) to a negative fraction", () => {
// A drop in the pad between the ruler and lane 0 sits at row < 0, so a
// floor lands it on row -1 → getDefaultDroppedTrack floors to the top lane.
const yInPad = RULER_H + TRACKS_TOP_PAD / 2;
expect(getTimelineRowFromY(yInPad)).toBeLessThan(0);
});
it("maps a y in the middle of lane 1 into [1,2)", () => {
const yMidLane1 = getTimelineRowTop(1) + TRACK_H / 2;
const row = getTimelineRowFromY(yMidLane1);
expect(row).toBeGreaterThanOrEqual(1);
expect(row).toBeLessThan(2);
});
});
describe("getTimelineCanvasHeight", () => {
it("reserves ruler + top pad + lanes + bottom pad", () => {
expect(getTimelineCanvasHeight([])).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
expect(getTimelineCanvasHeight(baseRows(3))).toBe(
RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
);
});
it("leaves room below the last lane for a drag-into-void new track", () => {
// The gap below the final lane must be at least a full track height so a
// clip can be dropped there to create a new bottom track.
const oneLane = getTimelineCanvasHeight(baseRows(1));
const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
});
});
describe("resolveTimelineAssetDrop honours the top pad", () => {
const base = {
rectLeft: 0,
rectTop: 0,
scrollLeft: 0,
scrollTop: 0,
contentOrigin: GUTTER,
pixelsPerSecond: 100,
duration: 60,
rowHeights: baseRows(3),
trackOrder: [0, 1, 2],
};
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
const clientX = GUTTER + 100; // t = 1s (contentOrigin = GUTTER)
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
expect(track).toBe(0);
expect(start).toBe(1);
});
it("drops into the top pad → floors to the first lane (row < 0)", () => {
const clientY = RULER_H + TRACKS_TOP_PAD / 2; // inside the pad, above lane 0
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
expect(track).toBe(0);
});
it("drops below the last lane → appends a new track", () => {
const clientY = getTimelineRowTop(2) + TRACK_H + 4; // in the bottom pad
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
expect(track).toBe(3); // max(trackOrder)+1
});
it("keeps a drop in an expanded lane region on that track", () => {
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
const clientY = getTimelineRowTop(0, rowHeights) + TRACK_H + LANE_H;
const { track } = resolveTimelineAssetDrop({ ...base, rowHeights }, GUTTER, clientY);
expect(track).toBe(0);
});
});
});
describe("getTimelineScrubTime", () => {
const at = (clientX: number, duration = 10) =>
getTimelineScrubTime({
clientX,
viewportLeft: 0,
scrollLeft: 0,
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
pixelsPerSecond: 100,
duration,
});
const origin = GUTTER + TRACKS_LEFT_PAD;
it("maps the content origin to t=0", () => {
expect(at(origin)).toBe(0);
expect(at(origin + 250)).toBe(2.5);
});
// The bug: a pointer left of the origin used to abort the scrub instead of
// clamping, so dragging the playhead to the start only worked if a sample
// happened to land in the few px before t=0.
it("clamps a pointer left of the origin to 0 instead of dropping the scrub", () => {
expect(at(origin - 1)).toBe(0);
expect(at(origin - 500)).toBe(0);
expect(at(0)).toBe(0);
});
it("clamps past the end to the duration", () => {
expect(at(origin + 5000)).toBe(10);
});
it("returns 0 for a degenerate zoom or duration", () => {
expect(
getTimelineScrubTime({
clientX: 500,
viewportLeft: 0,
scrollLeft: 0,
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
pixelsPerSecond: 0,
duration: 10,
}),
).toBe(0);
expect(at(origin + 250, Number.NaN)).toBe(0);
});
});
// The only production hook keeping resolveInsertRow's band aligned with the
// rendered clip inset once rows can be taller than TRACK_H. Pinned directly so a
// change to CLIP_Y or the invalid-height fallback can't silently drift it.
describe("getTimelineInsertBoundaryBand", () => {
it("matches the fixed band for a plain track row", () => {
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(INSERT_BOUNDARY_BAND);
expect(getTimelineInsertBoundaryBand(TRACK_H)).toBe(CLIP_Y / TRACK_H);
});
it("shrinks as the row grows, so the band stays CLIP_Y pixels tall", () => {
const expanded = TRACK_H + 2 * LANE_H;
expect(getTimelineInsertBoundaryBand(expanded)).toBeCloseTo(CLIP_Y / expanded, 10);
expect(getTimelineInsertBoundaryBand(expanded)).toBeLessThan(INSERT_BOUNDARY_BAND);
});
it("falls back to the plain-track band for a height that is not usable", () => {
for (const height of [0, -10, Number.NaN]) {
expect(getTimelineInsertBoundaryBand(height)).toBe(INSERT_BOUNDARY_BAND);
}
});
});