From 22160180ce0156851c8d04542ac94bf78fc5bb3a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:20:43 +0200 Subject: [PATCH] perf(studio): define timeline viewport budgets and fixtures --- .../src/hooks/useStudioTestHooks.test.tsx | 125 ++++++++++++++ .../studio/src/hooks/useStudioTestHooks.ts | 36 ++++ .../timelinePerformanceDiagnostics.test.ts | 85 ++++++++++ .../lib/timelinePerformanceDiagnostics.ts | 115 +++++++++++++ .../player/lib/timelinePerformanceFixture.ts | 157 ++++++++++++++++++ .../lib/timelineViewportBudgets.test.ts | 48 ++++++ .../src/player/lib/timelineViewportBudgets.ts | 125 ++++++++++++++ 7 files changed, 691 insertions(+) create mode 100644 packages/studio/src/hooks/useStudioTestHooks.test.tsx create mode 100644 packages/studio/src/player/lib/timelinePerformanceDiagnostics.test.ts create mode 100644 packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts create mode 100644 packages/studio/src/player/lib/timelinePerformanceFixture.ts create mode 100644 packages/studio/src/player/lib/timelineViewportBudgets.test.ts create mode 100644 packages/studio/src/player/lib/timelineViewportBudgets.ts diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx new file mode 100644 index 0000000000..847ccfd53b --- /dev/null +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore } from "../player/store/playerStore"; +import { + createTimelinePerformanceFixture, + type TimelinePerformanceFixtureProfile, +} from "../player/lib/timelinePerformanceFixture"; +import { useStudioTestHooks } from "./useStudioTestHooks"; + +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [ + "dense-short", + "long-overlap", + "keyframe-heavy-expanded", + "composition-heavy", + "remote-unsupported", +]; + +function Probe(): null { + useStudioTestHooks({ + previewIframeRef: { current: null }, + buildDomSelectionFromTarget: vi.fn(), + applyDomSelection: vi.fn(), + }); + return null; +} + +describe("timeline performance fixture", () => { + afterEach(() => { + window.__studioTest = undefined; + usePlayerStore.getState().reset(); + }); + + it("generates stable 50k identities, counts, distribution, and duration", () => { + const first = createTimelinePerformanceFixture({ + elementCount: 50_000, + profile: "dense-short", + }); + const second = createTimelinePerformanceFixture({ + elementCount: 50_000, + profile: "dense-short", + }); + + expect(first.summary).toEqual(second.summary); + expect(first.summary).toEqual({ + elementCount: 50_000, + profile: "dense-short", + duration: 120, + trackCount: 1_000, + keyframedElementCount: 0, + expandedElementCount: 0, + }); + expect(new Set(first.elements.map((element) => element.track)).size).toBe(1_000); + const perTrack = new Map(); + for (const element of first.elements) { + perTrack.set(element.track, (perTrack.get(element.track) ?? 0) + 1); + } + expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128); + expect(first.elements.slice(0, 3)).toEqual(second.elements.slice(0, 3)); + expect(first.elements.at(-1)).toEqual(second.elements.at(-1)); + }); + + it.each(PROFILES)("builds the %s 1k scale profile", (profile) => { + const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile }); + expect(fixture.elements).toHaveLength(1_000); + expect(fixture.summary.elementCount).toBe(1_000); + expect(fixture.summary.duration).toBeGreaterThan(0); + expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000); + if (profile === "keyframe-heavy-expanded") { + expect(fixture.keyframeCache.size).toBe(1_000); + expect(fixture.gsapAnimations.size).toBe(1_000); + expect(fixture.expandedClipIds.size).toBe(1_000); + } + }); + + it("publishes one dev-only loader that replaces fixture state atomically", () => { + const host = document.createElement("div"); + const root = createRoot(host); + act(() => root.render()); + const api = window.__studioTest; + expect(api).toBeDefined(); + if (!api) throw new Error("Expected dev Studio test API"); + let notifications = 0; + const unsubscribe = usePlayerStore.subscribe(() => { + notifications += 1; + }); + + const summary = api.loadTimelinePerformanceFixture({ + elementCount: 1_000, + profile: "keyframe-heavy-expanded", + }); + + expect(summary.elementCount).toBe(1_000); + expect(notifications).toBe(1); + expect(usePlayerStore.getState()).toMatchObject({ + duration: 600, + timelineReady: true, + }); + expect(usePlayerStore.getState().elements).toHaveLength(1_000); + expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); + unsubscribe(); + act(() => root.unmount()); + expect(window.__studioTest).toBeUndefined(); + }); + + it("does not mutate state when the fixture request is invalid", () => { + const host = document.createElement("div"); + const root = createRoot(host); + act(() => root.render()); + const api = window.__studioTest; + if (!api) throw new Error("Expected dev Studio test API"); + const before = usePlayerStore.getState().elements; + + expect(() => + Reflect.apply(api.loadTimelinePerformanceFixture, api, [ + { elementCount: 999, profile: "dense-short" }, + ]), + ).toThrow("elementCount must be 1000 or 50000"); + expect(usePlayerStore.getState().elements).toBe(before); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 2e0023e550..3949c2d253 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -1,5 +1,16 @@ import { useEffect } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player/store/playerStore"; +import { + readTimelinePerformanceDiagnostics, + type TimelinePerformanceDiagnostics, +} from "../player/lib/timelinePerformanceDiagnostics"; +import { + createTimelinePerformanceFixture, + type TimelinePerformanceFixtureSpec, + type TimelinePerformanceFixtureSummary, +} from "../player/lib/timelinePerformanceFixture"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets"; interface StudioTestHookDeps { previewIframeRef: React.MutableRefObject; @@ -12,6 +23,11 @@ interface StudioTestHookDeps { interface StudioTestApi { selectByDomId: (id: string) => Promise; + loadTimelinePerformanceFixture: ( + spec: TimelinePerformanceFixtureSpec, + ) => TimelinePerformanceFixtureSummary; + readTimelinePerformanceDiagnostics: () => Readonly; + timelineViewportBudgets: typeof TIMELINE_VIEWPORT_BUDGETS; } declare global { @@ -52,6 +68,26 @@ export function useStudioTestHooks({ applyDomSelection(selection, { revealPanel: true }); return true; }, + loadTimelinePerformanceFixture: (spec) => { + const fixture = createTimelinePerformanceFixture(spec); + usePlayerStore.setState({ + currentTime: 0, + duration: fixture.summary.duration, + timelineReady: true, + zoomMode: "manual", + manualZoomPercent: 2_000, + elements: fixture.elements, + selectedElementId: null, + selectedElementIds: new Set(), + selectedKeyframes: new Set(), + keyframeCache: fixture.keyframeCache, + gsapAnimations: fixture.gsapAnimations, + expandedClipIds: fixture.expandedClipIds, + }); + return fixture.summary; + }, + readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(), + timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS, }; window.__studioTest = api; return () => { 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/timelinePerformanceFixture.ts b/packages/studio/src/player/lib/timelinePerformanceFixture.ts new file mode 100644 index 0000000000..12707ea33c --- /dev/null +++ b/packages/studio/src/player/lib/timelinePerformanceFixture.ts @@ -0,0 +1,157 @@ +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; + elements: TimelineElement[]; + keyframeCache: Map; + gsapAnimations: Map; + expandedClipIds: Set; +} + +const TRACK_COUNT = 1_000; +const PROFILE_GEOMETRY: Readonly< + Record +> = 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 }, +}); + +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"); + } + const geometry = PROFILE_GEOMETRY[spec.profile]; + if (!geometry) { + throw new RangeError(`Unknown timeline performance fixture profile: ${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(); + const gsapAnimations = new Map(); + const expandedClipIds = new Set(); + + 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, + }; +} 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); +}