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
This commit is contained in:
Miguel Ángel
2026-07-31 18:05:22 +02:00
committed by GitHub
parent fbfffb1aa7
commit 723d3381c4
82 changed files with 5757 additions and 1087 deletions
@@ -1,6 +1,6 @@
import { usePlayerStore } from "../store/playerStore";
import { isMusicTrack } from "../../utils/timelineInspector";
import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers";
import { getTimelineElementIndexes } from "./timelineElementIndexes";
export { stopScrubPreviewAudio };
@@ -8,7 +8,7 @@ export { stopScrubPreviewAudio };
// Skipped when audio is muted or the time falls outside the music clip.
export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void {
const s = usePlayerStore.getState();
const music = s.elements.find(isMusicTrack);
const music = getTimelineElementIndexes(s.elements).musicElement;
if (!music || s.audioMuted) return;
const rel = nextTime - music.start;
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIdentity } from "./timelineElementHelpers";
import { createTimelineClipIndex, queryTimelineClipIndex } from "./timelineClipIndex";
function clip(
id: string,
start: number,
duration: number,
track = 1,
hidden = false,
): TimelineElement {
return { id, tag: "div", start, duration, track, hidden };
}
function ids(elements: readonly TimelineElement[]): string[] {
return elements.map(getTimelineElementIdentity);
}
function createDeterministicRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
return state / 0x1_0000_0000;
};
}
function linearOracle(
elements: readonly TimelineElement[],
range: { start: number; end: number },
pinned: ReadonlySet<string>,
): readonly TimelineElement[] {
return elements.filter((element) => {
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return false;
if (pinned.has(getTimelineElementIdentity(element))) return true;
if (range.end <= range.start) return false;
const end = element.start + Math.max(0, element.duration);
if (end <= element.start) return element.start >= range.start && element.start < range.end;
return element.start < range.end && end > range.start;
});
}
describe("timelineClipIndex", () => {
it("finds a long predecessor spanning the window", () => {
const index = createTimelineClipIndex([[1, [clip("long", 0, 100), clip("late", 80, 1)]]]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 50, end: 51 }))).toEqual(["long"]);
});
it("prunes 50k ended clips around one long interval in a late window", () => {
const elements = [
clip("long", 0, 50_000),
...Array.from({ length: 49_999 }, (_, index) => clip(`short-${index}`, index, 0.25)),
];
const index = createTimelineClipIndex([[1, elements]]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 49_999, end: 49_999.5 }))).toEqual([
"long",
]);
});
it("queries a shifted multi-drag window on a 50k-clip row without a row scan", () => {
const elements = Array.from({ length: 50_000 }, (_, index) =>
clip(`clip-${index}`, index, 0.25),
);
const index = createTimelineClipIndex([[1, elements]]);
const selected = new Set(["clip-10", "clip-49"]);
expect(
ids(
queryTimelineClipIndex(index, 1, { start: 60_000, end: 60_001 }, new Set(), [
{ range: { start: 10, end: 11 }, identities: selected },
]),
),
).toEqual(["clip-10"]);
});
it("uses half-open render boundaries and retains zero-duration points", () => {
const index = createTimelineClipIndex([
[
1,
[
clip("left", 0, 2),
clip("right", 2, 2),
clip("point", 3, 0),
clip("negative", 3.5, -2),
clip("micro", 4, 1e-9),
],
],
]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 2, end: 4 }))).toEqual([
"right",
"point",
"negative",
]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 4, end: 5 }))).toEqual(["micro"]);
});
it("preserves projection order after overlap and pin union", () => {
const index = createTimelineClipIndex([
[1, [clip("pinned", 10, 1), clip("visible-b", 2, 1), clip("visible-a", 1, 3)]],
]);
expect(
ids(queryTimelineClipIndex(index, 1, { start: 1.5, end: 2.5 }, new Set(["pinned"]))),
).toEqual(["pinned", "visible-b", "visible-a"]);
});
it("keeps duplicate identities deterministic and ignores stale pins", () => {
const index = createTimelineClipIndex([
[1, [clip("duplicate", 10, 1), clip("duplicate", 20, 1), clip("hidden", 30, 1, 1, true)]],
[2, [clip("duplicate", 40, 1, 2)]],
]);
expect(
ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }, new Set(["duplicate", "stale"]))),
).toEqual(["duplicate", "duplicate"]);
expect(
ids(queryTimelineClipIndex(index, 2, { start: 0, end: 1 }, new Set(["duplicate"]))),
).toEqual(["duplicate"]);
});
it("keeps an immutable interval snapshot when the source projection array changes", () => {
const source = [clip("first", 0, 2), clip("second", 5, 2)];
const index = createTimelineClipIndex([[1, source]]);
source.splice(0, source.length, clip("replacement", 0, 20));
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual(["first"]);
});
it("excludes clips with non-finite timing", () => {
const index = createTimelineClipIndex([
[1, [clip("bad-start", Number.NaN, 1), clip("bad-duration", 0, Number.POSITIVE_INFINITY)]],
]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual([]);
});
it("indexes synthetic fractional display rows independently", () => {
const index = createTimelineClipIndex([
[1, [clip("host", 0, 1)]],
[1.5, [clip("child", 10, 1, 1.5)]],
]);
expect(ids(queryTimelineClipIndex(index, 1.5, { start: 10, end: 11 }))).toEqual(["child"]);
expect(ids(queryTimelineClipIndex(index, 1, { start: 10, end: 11 }))).toEqual([]);
});
it("matches a linear oracle across deterministic randomized windows and pins", () => {
const random = createDeterministicRandom(0x2698);
for (let scenario = 0; scenario < 50; scenario += 1) {
const elements = Array.from({ length: 60 }, (_, index) => {
const element = clip(
`clip-${scenario}-${index}`,
Math.floor(random() * 120) - 10,
random() < 0.15 ? -Math.floor(random() * 5) : Math.floor(random() * 30),
);
if (index % 4 === 0) element.key = `index.html#${element.id}`;
return element;
});
const index = createTimelineClipIndex([[1, elements]]);
for (let query = 0; query < 12; query += 1) {
const start = Math.floor(random() * 120) - 10;
const range = { start, end: start + Math.floor(random() * 35) };
const pinned = new Set(
elements
.filter(() => random() < 0.05)
.map((element) => getTimelineElementIdentity(element)),
);
expect(queryTimelineClipIndex(index, 1, range, pinned)).toEqual(
linearOracle(elements, range, pinned),
);
}
}
});
});
@@ -0,0 +1,208 @@
import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIdentity } from "./timelineElementHelpers";
export interface TimelineTimeRange {
readonly start: number;
readonly end: number;
}
export interface TimelineClipQueryWindow {
readonly range: TimelineTimeRange;
readonly identities: ReadonlySet<string>;
}
interface TimelineClipInterval {
readonly element: TimelineElement;
readonly identity: string;
readonly ordinal: number;
readonly start: number;
readonly end: number;
}
interface TimelineClipRowIndex {
readonly byStart: readonly TimelineClipInterval[];
readonly treeLeafCount: number;
readonly maxPositiveEndTree: readonly number[];
readonly maxPointStartTree: readonly number[];
readonly byIdentity: ReadonlyMap<string, readonly TimelineClipInterval[]>;
}
export interface TimelineClipIndex {
readonly rows: ReadonlyMap<number, TimelineClipRowIndex>;
}
function clipInterval(element: TimelineElement, ordinal: number): TimelineClipInterval | null {
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
const duration = Math.max(0, element.duration);
return Object.freeze({
element,
identity: getTimelineElementIdentity(element),
ordinal,
start: element.start,
end: element.start + duration,
});
}
function timelineClipTreeLeafCount(intervalCount: number): number {
let leafCount = 1;
while (leafCount < intervalCount) leafCount *= 2;
return leafCount;
}
function createTimelineClipMaxTrees(intervals: readonly TimelineClipInterval[]) {
const treeLeafCount = timelineClipTreeLeafCount(intervals.length);
const maxPositiveEndTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
const maxPointStartTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
for (let index = 0; index < intervals.length; index += 1) {
const interval = intervals[index];
if (!interval) continue;
const leaf = treeLeafCount + index;
if (interval.end > interval.start) maxPositiveEndTree[leaf] = interval.end;
else maxPointStartTree[leaf] = interval.start;
}
for (let node = treeLeafCount - 1; node > 0; node -= 1) {
maxPositiveEndTree[node] = Math.max(
maxPositiveEndTree[node * 2] ?? Number.NEGATIVE_INFINITY,
maxPositiveEndTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
);
maxPointStartTree[node] = Math.max(
maxPointStartTree[node * 2] ?? Number.NEGATIVE_INFINITY,
maxPointStartTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
);
}
return {
treeLeafCount,
maxPositiveEndTree: Object.freeze(maxPositiveEndTree),
maxPointStartTree: Object.freeze(maxPointStartTree),
};
}
/**
* Build an immutable snapshot for one exact display-track projection.
* Rebuild only when that projection's array identity changes. Construction is
* O(n log n) overall because each row is sorted once.
*/
export function createTimelineClipIndex(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
): TimelineClipIndex {
const rows = new Map<number, TimelineClipRowIndex>();
for (const [rowKey, elements] of tracks) {
const intervals = elements
.map(clipInterval)
.filter((interval): interval is TimelineClipInterval => interval !== null);
const byStart = Object.freeze(
[...intervals].sort(
(left, right) => left.start - right.start || left.ordinal - right.ordinal,
),
);
const maxTrees = createTimelineClipMaxTrees(byStart);
const mutableByIdentity = new Map<string, TimelineClipInterval[]>();
for (const interval of intervals) {
const matches = mutableByIdentity.get(interval.identity) ?? [];
matches.push(interval);
mutableByIdentity.set(interval.identity, matches);
}
const byIdentity = new Map(
[...mutableByIdentity].map(([identity, matches]) => [identity, Object.freeze(matches)]),
);
rows.set(rowKey, Object.freeze({ byStart, ...maxTrees, byIdentity }));
}
return Object.freeze({ rows });
}
function upperBoundStart(intervals: readonly TimelineClipInterval[], end: number): number {
let low = 0;
let high = intervals.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
if ((intervals[mid]?.start ?? Number.POSITIVE_INFINITY) < end) low = mid + 1;
else high = mid;
}
return low;
}
function overlaps(interval: TimelineClipInterval, range: TimelineTimeRange): boolean {
if (interval.end <= interval.start)
return interval.start >= range.start && interval.start < range.end;
return interval.start < range.end && interval.end > range.start;
}
function collectTimelineClipOverlaps(
row: TimelineClipRowIndex,
candidateCount: number,
range: TimelineTimeRange,
selected: Set<TimelineClipInterval>,
identities?: ReadonlySet<string>,
): void {
const visit = (node: number, left: number, right: number) => {
if (
left >= candidateCount ||
((row.maxPositiveEndTree[node] ?? Number.NEGATIVE_INFINITY) <= range.start &&
(row.maxPointStartTree[node] ?? Number.NEGATIVE_INFINITY) < range.start)
) {
return;
}
if (right - left === 1) {
const interval = row.byStart[left];
if (interval) selectTimelineClipOverlap(interval, range, selected, identities);
return;
}
const middle = Math.floor((left + right) / 2);
visit(node * 2, left, middle);
visit(node * 2 + 1, middle, right);
};
visit(1, 0, row.treeLeafCount);
}
function selectTimelineClipOverlap(
interval: TimelineClipInterval,
range: TimelineTimeRange,
selected: Set<TimelineClipInterval>,
identities: ReadonlySet<string> | undefined,
): void {
if (!overlaps(interval, range)) return;
if (identities !== undefined && !identities.has(interval.identity)) return;
selected.add(interval);
}
/**
* Query one display row. The overlap set and explicit actor pins are returned
* in the row's original projection order, so windowing never changes z/DOM order.
* The start lookup is O(log n). Balanced max trees prune both children whose
* positive intervals and point clips cannot reach the window. The overlap walk
* is O((k + 1) log n) for k candidates across the primary and actor windows;
* pin lookup is O(p), followed by the projection-order sort of the unique
* result set.
*/
export function queryTimelineClipIndex(
index: TimelineClipIndex,
rowKey: number,
range: TimelineTimeRange,
pinnedIdentities: ReadonlySet<string> = new Set(),
actorWindows: readonly TimelineClipQueryWindow[] = [],
): readonly TimelineElement[] {
const row = index.rows.get(rowKey);
if (!row) return Object.freeze([]);
const selected = new Set<TimelineClipInterval>();
if (range.end > range.start) {
collectTimelineClipOverlaps(row, upperBoundStart(row.byStart, range.end), range, selected);
}
for (const actorWindow of actorWindows) {
if (actorWindow.range.end <= actorWindow.range.start) continue;
collectTimelineClipOverlaps(
row,
upperBoundStart(row.byStart, actorWindow.range.end),
actorWindow.range,
selected,
actorWindow.identities,
);
}
for (const identity of pinnedIdentities) {
for (const interval of row.byIdentity.get(identity) ?? []) selected.add(interval);
}
return Object.freeze(
[...selected]
.sort((left, right) => left.ordinal - right.ordinal)
.map((interval) => interval.element),
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIndexes } from "./timelineElementIndexes";
describe("getTimelineElementIndexes", () => {
const elements: TimelineElement[] = [
{ id: "hero", tag: "img", src: "hero.png", start: 0, duration: 2, track: 0 },
{
id: "bgm",
tag: "audio",
src: "music.wav",
start: 0,
duration: 10,
track: 3,
timelineRole: "music",
},
];
it("indexes media and music identities", () => {
const indexes = getTimelineElementIndexes(elements);
expect(indexes.byKey.get("hero")).toBe(elements[0]);
expect(indexes.musicElement).toBe(elements[1]);
expect(indexes.mediaElements).toEqual(elements);
expect(indexes.audioTracks).toEqual(new Set([3]));
});
it("reuses one index for the same immutable array snapshot", () => {
expect(getTimelineElementIndexes(elements)).toBe(getTimelineElementIndexes(elements));
expect(getTimelineElementIndexes([...elements])).not.toBe(getTimelineElementIndexes(elements));
});
});
@@ -0,0 +1,44 @@
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIdentity } from "./timelineElementHelpers";
export interface TimelineElementIndexes {
readonly byKey: ReadonlyMap<string, TimelineElement>;
readonly musicElement: TimelineElement | null;
readonly mediaElements: readonly TimelineElement[];
readonly audioTracks: ReadonlySet<number>;
}
const indexCache = new WeakMap<readonly TimelineElement[], TimelineElementIndexes>();
/**
* Index a store element snapshot once. Playback-only Zustand updates keep the
* same array identity, so selectors can reuse this object without rescanning a
* large timeline or triggering a component render.
*/
export function getTimelineElementIndexes(
elements: readonly TimelineElement[],
): TimelineElementIndexes {
const cached = indexCache.get(elements);
if (cached) return cached;
const byKey = new Map<string, TimelineElement>();
const mediaElements: TimelineElement[] = [];
const audioTracks = new Set<number>();
let musicElement: TimelineElement | null = null;
for (const element of elements) {
byKey.set(getTimelineElementIdentity(element), element);
if (element.src) mediaElements.push(element);
if (isAudioTimelineElement(element)) audioTracks.add(element.track);
if (!musicElement && isMusicTrack(element)) musicElement = element;
}
const indexes = Object.freeze({
byKey,
musicElement,
mediaElements: Object.freeze(mediaElements),
audioTracks,
});
indexCache.set(elements, indexes);
return indexes;
}
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import {
getTimelineResourceBudgetStatus,
readTimelinePerformanceDiagnostics,
resolveTimelineScrollStrategy,
} from "./timelinePerformanceDiagnostics";
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
describe("timeline performance diagnostics", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("reads mounted resources without mutating the timeline", () => {
document.body.innerHTML = `
<div aria-label="Timeline" data-timeline-scheduler-queued="3"
data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
<div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
<div data-timeline-row><div data-clip="true"></div></div>
<div data-clip="true"></div>
<div data-timeline-grid-cell></div><div data-timeline-grid-cell></div>
<div data-timeline-poster-state="ready"></div>
<div data-timeline-poster-state="error"></div>
<div data-timeline-poster-state="constructor"></div>
</div>`;
const before = document.body.innerHTML;
expect(readTimelinePerformanceDiagnostics()).toMatchObject({
timelineRoots: 1,
mountedRows: 2,
mountedClipRoots: 4,
maxMountedClipRootsInOneRow: 2,
mountedTimeGridCells: 2,
schedulerQueued: 3,
schedulerActive: 2,
cacheBytes: 4096,
posterStates: { idle: 0, loading: 0, ready: 1, fallback: 0, error: 1 },
});
expect(document.body.innerHTML).toBe(before);
});
it("returns the zero baseline after unmount or reset removes the DOM", () => {
document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
document.body.replaceChildren();
expect(readTimelinePerformanceDiagnostics()).toEqual({
timelineRoots: 0,
mountedRows: 0,
mountedClipRoots: 0,
maxMountedClipRootsInOneRow: 0,
mountedTimeGridCells: 0,
mountedTimelineDescendants: 0,
schedulerQueued: 0,
schedulerActive: 0,
cacheBytes: 0,
posterStates: { idle: 0, loading: 0, ready: 0, fallback: 0, error: 0 },
});
});
it("treats every DOM ceiling as inclusive", () => {
const budgets = resolveTimelineViewportBudgets({
maxMountedClipRoots: 2,
maxMountedClipRootsPerRow: 1,
maxMountedRows: 2,
maxMountedTimelineDescendants: 4,
});
expect(
getTimelineResourceBudgetStatus(
{
...readTimelinePerformanceDiagnostics(),
mountedClipRoots: 2,
maxMountedClipRootsInOneRow: 2,
mountedTimelineDescendants: 4,
},
budgets,
),
).toEqual({
timelineRoot: false,
rows: true,
clipRoots: true,
clipRootsPerRow: false,
descendants: true,
});
});
it("fails the resource status when the timeline is absent", () => {
expect(getTimelineResourceBudgetStatus(readTimelinePerformanceDiagnostics())).toMatchObject({
timelineRoot: false,
});
});
it("selects direct scrolling only through the configured safety envelope", () => {
expect(resolveTimelineScrollStrategy(8_000_000)).toBe("direct");
expect(resolveTimelineScrollStrategy(8_000_001)).toBe("segmented");
expect(() => resolveTimelineScrollStrategy(Number.NaN)).toThrow("content width");
});
});
@@ -0,0 +1,120 @@
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
export type TimelinePosterState = "idle" | "loading" | "ready" | "fallback" | "error";
export interface TimelinePerformanceDiagnostics {
timelineRoots: number;
mountedRows: number;
mountedClipRoots: number;
maxMountedClipRootsInOneRow: number;
mountedTimeGridCells: number;
mountedTimelineDescendants: number;
schedulerQueued: number;
schedulerActive: number;
cacheBytes: number;
posterStates: Readonly<Record<TimelinePosterState, number>>;
}
export interface TimelineResourceBudgetStatus {
timelineRoot: boolean;
rows: boolean;
clipRoots: boolean;
clipRootsPerRow: boolean;
descendants: boolean;
}
function readNonNegativeNumber(value: string | undefined): number {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : 0;
}
function countPosters(root: ParentNode): Readonly<Record<TimelinePosterState, number>> {
const counts: Record<TimelinePosterState, number> = {
idle: 0,
loading: 0,
ready: 0,
fallback: 0,
error: 0,
};
for (const node of root.querySelectorAll<HTMLElement>("[data-timeline-poster-state]")) {
const state = node.dataset.timelinePosterState;
if (state && Object.hasOwn(counts, state)) counts[state as TimelinePosterState] += 1;
}
return Object.freeze(counts);
}
function maxClipsInOneRow(root: ParentNode): number {
const byRow = new Map<Element, number>();
for (const clip of root.querySelectorAll<HTMLElement>('[data-clip="true"]')) {
const row = clip.closest("[data-timeline-row]");
if (!row) continue;
byRow.set(row, (byRow.get(row) ?? 0) + 1);
}
return Math.max(0, ...byRow.values());
}
function sumDataAttribute(root: ParentNode, selector: string, dataKey: string): number {
let total = 0;
for (const node of root.querySelectorAll<HTMLElement>(selector)) {
total += readNonNegativeNumber(node.dataset[dataKey]);
}
return total;
}
/**
* Read current timeline costs directly from the mounted DOM. No counters are
* retained, so an unmount or project reset is reflected as a zero baseline on
* the next read rather than depending on cleanup ordering.
*/
export function readTimelinePerformanceDiagnostics(
root: ParentNode = document,
): Readonly<TimelinePerformanceDiagnostics> {
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
let mountedTimelineDescendants = 0;
for (const timelineRoot of timelineRoots) {
mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
}
return Object.freeze({
timelineRoots: timelineRoots.length,
mountedRows: root.querySelectorAll("[data-timeline-row]").length,
mountedClipRoots: root.querySelectorAll('[data-clip="true"]').length,
maxMountedClipRootsInOneRow: maxClipsInOneRow(root),
mountedTimeGridCells: root.querySelectorAll("[data-timeline-grid-cell]").length,
mountedTimelineDescendants,
schedulerQueued: sumDataAttribute(
root,
"[data-timeline-scheduler-queued]",
"timelineSchedulerQueued",
),
schedulerActive: sumDataAttribute(
root,
"[data-timeline-scheduler-active]",
"timelineSchedulerActive",
),
cacheBytes: sumDataAttribute(root, "[data-timeline-cache-bytes]", "timelineCacheBytes"),
posterStates: countPosters(root),
});
}
export function getTimelineResourceBudgetStatus(
diagnostics: TimelinePerformanceDiagnostics,
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
): Readonly<TimelineResourceBudgetStatus> {
return Object.freeze({
timelineRoot: diagnostics.timelineRoots === 1,
rows: diagnostics.mountedRows <= budgets.maxMountedRows,
clipRoots: diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots,
clipRootsPerRow: diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow,
descendants: diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants,
});
}
export function resolveTimelineScrollStrategy(
contentWidthPx: number,
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
): "direct" | "segmented" {
if (!Number.isFinite(contentWidthPx) || contentWidthPx < 0) {
throw new RangeError("Timeline content width must be a finite non-negative number");
}
return contentWidthPx <= budgets.directScrollSafetyPx ? "direct" : "segmented";
}
@@ -0,0 +1,167 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
export type TimelinePerformanceFixtureProfile =
| "dense-short"
| "long-overlap"
| "keyframe-heavy-expanded"
| "composition-heavy"
| "remote-unsupported";
export interface TimelinePerformanceFixtureSpec {
elementCount: 1_000 | 50_000;
profile: TimelinePerformanceFixtureProfile;
}
export interface TimelinePerformanceFixtureSummary extends TimelinePerformanceFixtureSpec {
duration: number;
trackCount: number;
keyframedElementCount: number;
expandedElementCount: number;
}
export interface TimelinePerformanceFixture {
summary: Readonly<TimelinePerformanceFixtureSummary>;
elements: TimelineElement[];
keyframeCache: Map<string, KeyframeCacheEntry>;
gsapAnimations: Map<string, GsapAnimation[]>;
expandedClipIds: Set<string>;
}
const TRACK_COUNT = 1_000;
let fixtureLeaseActive = false;
const PROFILE_GEOMETRY: Readonly<
Record<TimelinePerformanceFixtureProfile, { duration: number; clipDuration: number }>
> = Object.freeze({
"dense-short": { duration: 120, clipDuration: 1.5 },
"long-overlap": { duration: 7_200, clipDuration: 120 },
"keyframe-heavy-expanded": { duration: 600, clipDuration: 8 },
"composition-heavy": { duration: 900, clipDuration: 12 },
"remote-unsupported": { duration: 900, clipDuration: 12 },
});
/** Prevent live iframe discovery from replacing an explicitly loaded dev fixture. */
export function setTimelinePerformanceFixtureLease(active: boolean): void {
fixtureLeaseActive = active;
}
export function hasTimelinePerformanceFixtureLease(): boolean {
return fixtureLeaseActive;
}
function validateFixtureSpec(spec: TimelinePerformanceFixtureSpec) {
if (spec.elementCount !== 1_000 && spec.elementCount !== 50_000) {
throw new RangeError("Timeline performance fixture elementCount must be 1000 or 50000");
}
if (!Object.hasOwn(PROFILE_GEOMETRY, spec.profile)) {
throw new RangeError(`Unknown timeline performance fixture profile: ${spec.profile}`);
}
const geometry = PROFILE_GEOMETRY[spec.profile];
return geometry;
}
function fixtureTrack(index: number, spec: TimelinePerformanceFixtureSpec): number {
if (index < TRACK_COUNT) return index;
if (spec.profile !== "dense-short") return index % TRACK_COUNT;
// Keep the dense profile inside the declared 128-roots-per-row envelope while
// still representing every one of the 1,000 logical tracks.
const denseTrackCount = Math.ceil((spec.elementCount - TRACK_COUNT) / 127);
return (index - TRACK_COUNT) % Math.max(1, denseTrackCount);
}
function fixtureStart(
index: number,
profile: TimelinePerformanceFixtureProfile,
duration: number,
clipDuration: number,
): number {
const available = Math.max(0, duration - clipDuration);
if (profile === "dense-short") return (index % 128) * 0.5;
if (profile === "long-overlap") return (index * 37) % Math.max(1, available);
return (index * 17) % Math.max(1, available);
}
function keyframeData(): KeyframeCacheEntry {
return {
format: "percentage",
keyframes: [0, 33, 66, 100].map((percentage) => ({
percentage,
propertyGroup: "position",
properties: { x: percentage },
ease: "power2.inOut",
})),
};
}
function fixtureAnimation(id: string, start: number, duration: number): GsapAnimation {
return {
id: `animation-${id}`,
targetSelector: `#${id}`,
method: "to",
position: start,
resolvedStart: start,
duration,
propertyGroup: "position",
fromProperties: { x: 0 },
properties: { x: 100 },
ease: "power2.inOut",
};
}
/** Pure deterministic generator; the dev test hook performs the one store mutation. */
export function createTimelinePerformanceFixture(
spec: TimelinePerformanceFixtureSpec,
): TimelinePerformanceFixture {
const geometry = validateFixtureSpec(spec);
const elements: TimelineElement[] = [];
const keyframeCache = new Map<string, KeyframeCacheEntry>();
const gsapAnimations = new Map<string, GsapAnimation[]>();
const expandedClipIds = new Set<string>();
for (let index = 0; index < spec.elementCount; index += 1) {
const id = `perf-${spec.profile}-${spec.elementCount}-${index}`;
const start = fixtureStart(index, spec.profile, geometry.duration, geometry.clipDuration);
const track = fixtureTrack(index, spec);
const element: TimelineElement = {
id,
key: id,
domId: id,
selector: `#${id}`,
label: `Fixture ${index + 1}`,
tag: spec.profile === "remote-unsupported" && index % 2 === 0 ? "video" : "div",
start,
duration: geometry.clipDuration,
track,
authoredTrack: track,
};
if (spec.profile === "composition-heavy") {
element.compositionSrc = `compositions/perf-${index % 32}.html`;
} else if (spec.profile === "remote-unsupported") {
element.src =
index % 2 === 0
? `https://media.invalid/perf-${index % 32}.mp4`
: `assets/perf-${index % 32}.unsupported`;
}
if (spec.profile === "keyframe-heavy-expanded") {
keyframeCache.set(id, keyframeData());
gsapAnimations.set(id, [fixtureAnimation(id, start, geometry.clipDuration)]);
expandedClipIds.add(id);
}
elements.push(element);
}
return {
summary: Object.freeze({
...spec,
duration: geometry.duration,
trackCount: TRACK_COUNT,
keyframedElementCount: keyframeCache.size,
expandedElementCount: expandedClipIds.size,
}),
elements,
keyframeCache,
gsapAnimations,
expandedClipIds,
};
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
TIMELINE_VIEWPORT_BUDGETS,
resolveTimelineViewportBudgets,
} from "./timelineViewportBudgets";
describe("timeline viewport budgets", () => {
it("owns the agreed direct-scroll, DOM, media, and measurement ceilings", () => {
expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({
directScrollSafetyPx: 8_000_000,
rowOverscanPerSide: 2,
timeOverscanViewportRatio: 0.25,
maxMountedRows: 64,
maxMountedClipRoots: 512,
maxMountedClipRootsPerRow: 128,
maxMountedTimelineDescendants: 5_000,
thumbnailCacheBytes: 64 * 1024 * 1024,
waveformCacheBytes: 16 * 1024 * 1024,
interactionP95Ms: 50,
constrainedInteractionP95Ms: 75,
constrainedFrameIntervalP95Ms: 75,
longTaskLimitMs: 50,
constrainedLongTaskLimitMs: 300,
posterCoverageRatio: 0.9,
supportedFixtureFallbackRatio: 0.02,
warmupRuns: 3,
measuredRuns: 5,
requiredPassingRuns: 4,
});
expect(Object.isFrozen(TIMELINE_VIEWPORT_BUDGETS)).toBe(true);
});
it("creates an immutable test override without changing production defaults", () => {
const resolved = resolveTimelineViewportBudgets({
directScrollSafetyPx: 256,
measuredRuns: 1,
requiredPassingRuns: 1,
});
expect(resolved.directScrollSafetyPx).toBe(256);
expect(resolved.maxMountedClipRoots).toBe(512);
expect(TIMELINE_VIEWPORT_BUDGETS.directScrollSafetyPx).toBe(8_000_000);
expect(Object.isFrozen(resolved)).toBe(true);
});
it.each([
[{ maxMountedClipRoots: -1 }, "maxMountedClipRoots"],
[{ frameIntervalP95Ms: Number.NaN }, "frameIntervalP95Ms"],
[{ warmupRuns: 0.5 }, "warmupRuns"],
[{ measuredRuns: 0 }, "measuredRuns"],
[{ requiredPassingRuns: 0 }, "requiredPassingRuns"],
[{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"],
[{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"],
[{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"],
] as const)("rejects an invalid override %#", (overrides, message) => {
expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message);
});
});
@@ -0,0 +1,139 @@
export interface TimelineViewportBudgets {
directScrollSafetyPx: number;
rowOverscanPerSide: number;
timeOverscanViewportRatio: number;
maxMountedRows: number;
maxMountedClipRoots: number;
maxMountedClipRootsPerRow: number;
maxMountedTimelineDescendants: number;
posterMaxPhysicalWidth: number;
posterMaxPhysicalHeight: number;
posterDprCap: number;
richPreviewFrameCount: number;
concurrentVideoDecodes: number;
concurrentMetadataJobs: number;
concurrentCompositionFetches: number;
concurrentServerPages: number;
thumbnailCacheBytes: number;
thumbnailCacheEntries: number;
thumbnailCacheEntriesPerProject: number;
metadataRegistryEntries: number;
metadataFailureTtlMs: number;
waveformCacheBytes: number;
waveformCacheEntries: number;
compositionDiskCacheBytes: number;
compositionDiskCacheMaxAgeMs: number;
interactionP95Ms: number;
frameIntervalP95Ms: number;
constrainedInteractionP95Ms: number;
constrainedFrameIntervalP95Ms: number;
longTaskLimitMs: number;
constrainedLongTaskLimitMs: number;
memoryReturnToleranceRatio: number;
posterColdP95Ms: number;
posterCachedP95Ms: number;
constrainedPosterColdP95Ms: number;
constrainedPosterCachedP95Ms: number;
posterCoverageRatio: number;
posterCoverageSettleMs: number;
constrainedPosterCoverageSettleMs: number;
richPreviewP95Ms: number;
constrainedRichPreviewP95Ms: number;
supportedFixtureFallbackRatio: number;
warmupRuns: number;
measuredRuns: number;
requiredPassingRuns: number;
}
const MEBIBYTE = 1024 * 1024;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* The sole default budget owner for timeline viewport and media virtualization.
* Consumers may resolve an immutable per-test override; production defaults are
* never mutated globally.
*/
export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Object.freeze({
directScrollSafetyPx: 8_000_000,
rowOverscanPerSide: 2,
timeOverscanViewportRatio: 0.25,
maxMountedRows: 64,
maxMountedClipRoots: 512,
maxMountedClipRootsPerRow: 128,
maxMountedTimelineDescendants: 5_000,
posterMaxPhysicalWidth: 240,
posterMaxPhysicalHeight: 135,
posterDprCap: 1.5,
richPreviewFrameCount: 6,
concurrentVideoDecodes: 2,
concurrentMetadataJobs: 4,
concurrentCompositionFetches: 2,
concurrentServerPages: 1,
thumbnailCacheBytes: 64 * MEBIBYTE,
thumbnailCacheEntries: 256,
thumbnailCacheEntriesPerProject: 96,
metadataRegistryEntries: 512,
metadataFailureTtlMs: 30_000,
waveformCacheBytes: 16 * MEBIBYTE,
waveformCacheEntries: 256,
compositionDiskCacheBytes: 512 * MEBIBYTE,
compositionDiskCacheMaxAgeMs: 14 * DAY_MS,
interactionP95Ms: 50,
frameIntervalP95Ms: 33.3,
constrainedInteractionP95Ms: 75,
constrainedFrameIntervalP95Ms: 75,
longTaskLimitMs: 50,
constrainedLongTaskLimitMs: 300,
memoryReturnToleranceRatio: 0.15,
posterColdP95Ms: 750,
posterCachedP95Ms: 250,
constrainedPosterColdP95Ms: 1_200,
constrainedPosterCachedP95Ms: 400,
posterCoverageRatio: 0.9,
posterCoverageSettleMs: 1_500,
constrainedPosterCoverageSettleMs: 2_500,
richPreviewP95Ms: 750,
constrainedRichPreviewP95Ms: 1_200,
supportedFixtureFallbackRatio: 0.02,
warmupRuns: 3,
measuredRuns: 5,
requiredPassingRuns: 4,
});
function assertValidBudget(name: keyof TimelineViewportBudgets, value: number): void {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`Timeline viewport budget ${name} must be a finite non-negative number`);
}
}
export function resolveTimelineViewportBudgets(
overrides: Partial<TimelineViewportBudgets> = {},
): Readonly<TimelineViewportBudgets> {
for (const [name, value] of Object.entries(overrides)) {
assertValidBudget(name as keyof TimelineViewportBudgets, value);
}
const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides };
for (const name of ["warmupRuns", "measuredRuns", "requiredPassingRuns"] as const) {
if (!Number.isInteger(resolved[name])) {
throw new RangeError(`Timeline viewport budget ${name} must be an integer`);
}
}
if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) {
throw new RangeError(
"Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero",
);
}
if (resolved.requiredPassingRuns > resolved.measuredRuns) {
throw new RangeError("Timeline viewport budget requiredPassingRuns cannot exceed measuredRuns");
}
for (const name of [
"memoryReturnToleranceRatio",
"posterCoverageRatio",
"supportedFixtureFallbackRatio",
] as const) {
if (resolved[name] > 1) {
throw new RangeError(`Timeline viewport budget ${name} cannot exceed 1`);
}
}
return Object.freeze(resolved);
}