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/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, + }; +}