diff --git a/packages/studio/src/player/lib/timelinePerformanceDiagnostics.test.ts b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.test.ts
new file mode 100644
index 0000000000..8e2f44741b
--- /dev/null
+++ b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.test.ts
@@ -0,0 +1,85 @@
+// @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 = `
+
`;
+ const before = document.body.innerHTML;
+
+ expect(readTimelinePerformanceDiagnostics()).toMatchObject({
+ timelineRoots: 1,
+ mountedRows: 2,
+ mountedClipRoots: 3,
+ 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 = '';
+ 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("checks the DOM ceilings including the strict descendant boundary", () => {
+ const budgets = resolveTimelineViewportBudgets({
+ maxMountedClipRoots: 2,
+ maxMountedClipRootsPerRow: 1,
+ maxMountedTimelineDescendants: 4,
+ });
+ expect(
+ getTimelineResourceBudgetStatus(
+ {
+ ...readTimelinePerformanceDiagnostics(),
+ mountedClipRoots: 2,
+ maxMountedClipRootsInOneRow: 2,
+ mountedTimelineDescendants: 4,
+ },
+ budgets,
+ ),
+ ).toEqual({ clipRoots: true, clipRootsPerRow: false, descendants: 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");
+ });
+});
diff --git a/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts
new file mode 100644
index 0000000000..e1f2f5964b
--- /dev/null
+++ b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts
@@ -0,0 +1,115 @@
+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>;
+}
+
+export interface TimelineResourceBudgetStatus {
+ 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> {
+ const counts: Record = {
+ idle: 0,
+ loading: 0,
+ ready: 0,
+ fallback: 0,
+ error: 0,
+ };
+ for (const node of root.querySelectorAll("[data-timeline-poster-state]")) {
+ const state = node.dataset.timelinePosterState;
+ if (state && state in counts) counts[state as TimelinePosterState] += 1;
+ }
+ return Object.freeze(counts);
+}
+
+function maxClipsInOneRow(root: ParentNode): number {
+ const byRow = new Map();
+ for (const clip of root.querySelectorAll('[data-clip="true"]')) {
+ const row = clip.closest("[data-timeline-row]");
+ 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(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 {
+ const timelineRoots = root.querySelectorAll('[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 = TIMELINE_VIEWPORT_BUDGETS,
+): Readonly {
+ return Object.freeze({
+ clipRoots: diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots,
+ clipRootsPerRow: diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow,
+ descendants: diagnostics.mountedTimelineDescendants < budgets.maxMountedTimelineDescendants,
+ });
+}
+
+export function resolveTimelineScrollStrategy(
+ contentWidthPx: number,
+ budgets: Readonly = 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";
+}
diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.test.ts b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts
new file mode 100644
index 0000000000..a2f1bcf018
--- /dev/null
+++ b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts
@@ -0,0 +1,48 @@
+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,
+ maxMountedClipRoots: 512,
+ maxMountedClipRootsPerRow: 128,
+ maxMountedTimelineDescendants: 5_000,
+ thumbnailCacheBytes: 64 * 1024 * 1024,
+ waveformCacheBytes: 16 * 1024 * 1024,
+ interactionP95Ms: 50,
+ constrainedInteractionP95Ms: 75,
+ 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"],
+ [{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"],
+ [{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"],
+ ] as const)("rejects an invalid override %#", (overrides, message) => {
+ expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message);
+ });
+});
diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.ts b/packages/studio/src/player/lib/timelineViewportBudgets.ts
new file mode 100644
index 0000000000..178bb3f4b7
--- /dev/null
+++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts
@@ -0,0 +1,125 @@
+export interface TimelineViewportBudgets {
+ directScrollSafetyPx: number;
+ rowOverscanPerSide: number;
+ timeOverscanViewportRatio: 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;
+ 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 = Object.freeze({
+ directScrollSafetyPx: 8_000_000,
+ rowOverscanPerSide: 4,
+ timeOverscanViewportRatio: 0.5,
+ 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: 50,
+ longTaskLimitMs: 50,
+ 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 = {},
+): Readonly {
+ for (const [name, value] of Object.entries(overrides)) {
+ assertValidBudget(name as keyof TimelineViewportBudgets, value);
+ }
+ const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides };
+ 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);
+}