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); +}