From 0b521c76a5888a36c9404d282775460f6612b393 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:20:43 +0200 Subject: [PATCH 1/3] perf(studio): define timeline viewport budgets and fixtures --- .../src/hooks/useStudioTestHooks.test.tsx | 141 ++++++++++++++++ .../studio/src/hooks/useStudioTestHooks.ts | 38 +++++ .../player/components/TimelineLanes.test.tsx | 1 + .../src/player/components/TimelineLanes.tsx | 1 + .../timelinePerformanceDiagnostics.test.ts | 100 +++++++++++ .../lib/timelinePerformanceDiagnostics.ts | 120 +++++++++++++ .../player/lib/timelinePerformanceFixture.ts | 157 ++++++++++++++++++ .../lib/timelineViewportBudgets.test.ts | 58 +++++++ .../src/player/lib/timelineViewportBudgets.ts | 139 ++++++++++++++++ .../studio/src/player/store/playerStore.ts | 68 ++++---- 10 files changed, 794 insertions(+), 29 deletions(-) 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..6279eb7770 --- /dev/null +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -0,0 +1,141 @@ +// @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 the expected dense-short 50k distribution", () => { + const first = createTimelinePerformanceFixture({ + elementCount: 50_000, + profile: "dense-short", + }); + + 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); + }); + + it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => { + const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile }); + const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile }); + expect(second).toEqual(first); + }); + + 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; + usePlayerStore.setState({ + isPlaying: true, + requestedSeekTime: 42, + clipRevealRequest: { elementId: "stale", nonce: 7 }, + clipManifest: [], + lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]), + }); + 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({ + isPlaying: false, + requestedSeekTime: null, + clipRevealRequest: null, + clipManifest: null, + duration: 600, + timelineReady: true, + }); + expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0); + 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); + expect(() => + Reflect.apply(api.loadTimelinePerformanceFixture, api, [ + { elementCount: 1_000, profile: "constructor" }, + ]), + ).toThrow("Unknown timeline performance fixture profile"); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index a434677c4a..8b1ea7158c 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 { createTimelineResetState, 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,28 @@ export function useStudioTestHooks({ applyDomSelection(selection, { revealPanel: true }); return true; }, + loadTimelinePerformanceFixture: (spec) => { + const fixture = createTimelinePerformanceFixture(spec); + usePlayerStore.setState({ + ...createTimelineResetState(), + currentTime: 0, + duration: fixture.summary.duration, + timelineReady: true, + loopEnabled: false, + 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/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 248b1675a2..d7ac20bc59 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -144,6 +144,7 @@ describe("TimelineLanes track numbering", () => { }); expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]); + expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2); expect(view.host.innerHTML).not.toContain("0.16666666666666666"); act(() => view.root.unmount()); }); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 268220ac73..2ff7a20faa 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -150,6 +150,7 @@ export function TimelineLanes({ return (
{ + 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: 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 = '
'; + 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"); + }); +}); diff --git a/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts new file mode 100644 index 0000000000..2b30e615d4 --- /dev/null +++ b/packages/studio/src/player/lib/timelinePerformanceDiagnostics.ts @@ -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>; +} + +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> { + 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 && Object.hasOwn(counts, state)) 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]"); + 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(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({ + 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 = 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..d49c7b1b98 --- /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"); + } + 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(); + 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..7acc87c93c --- /dev/null +++ b/packages/studio/src/player/lib/timelineViewportBudgets.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/player/lib/timelineViewportBudgets.ts b/packages/studio/src/player/lib/timelineViewportBudgets.ts new file mode 100644 index 0000000000..6d88a20c2a --- /dev/null +++ b/packages/studio/src/player/lib/timelineViewportBudgets.ts @@ -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 = 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 = {}, +): Readonly { + 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); +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index e8c101c8fd..688f2a22f3 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,10 +1,11 @@ import { create } from "zustand"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { BeatEditState } from "../../utils/beatEditing"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { computePinnedZoomPercent } from "../components/timelineZoom"; -import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice"; +import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; @@ -283,6 +284,42 @@ export const liveTime = { }, }; +export function createTimelineResetState() { + return { + isPlaying: false, + currentTime: 0, + duration: 0, + timelineReady: false, + beatDragging: false, + elements: [], + selectedElementId: null, + zEditVersion: 0, + inPoint: null, + outPoint: null, + activeTool: "select" as const, + activeKeyframePct: null, + motionPathArmed: false, + motionPathCreateAvailable: false, + selectedKeyframes: new Set(), + expandedClipIds: new Set(), + focusedEaseSegment: null, + selectedElementIds: new Set(), + requestedSeekTime: null, + clipRevealRequest: null, + lintFindingsByElement: new Map(), + keyframeCache: new Map(), + gsapAnimations: new Map(), + beatAnalysis: null, + beatEdits: null, + beatUndo: [], + beatRedo: [], + beatPersist: null, + clipManifest: null, + clipParentMap: new Map(), + domClipChildren: [], + }; +} + export const usePlayerStore = create((set, get) => ({ isPlaying: false, currentTime: 0, @@ -517,34 +554,7 @@ export const usePlayerStore = create((set, get) => ({ // Resets project-specific state when switching compositions. // playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved // because they are user preferences that should survive project switches. - reset: () => - set({ - isPlaying: false, - currentTime: 0, - duration: 0, - timelineReady: false, - beatDragging: false, - elements: [], - selectedElementId: null, - inPoint: null, - outPoint: null, - activeTool: "select", - selectedKeyframes: new Set(), - expandedClipIds: new Set(), - focusedEaseSegment: null, - selectedElementIds: new Set(), - clipRevealRequest: null, - keyframeCache: new Map(), - gsapAnimations: new Map(), - beatAnalysis: null, - beatEdits: null, - beatUndo: [], - beatRedo: [], - beatPersist: null, - clipManifest: null, - clipParentMap: new Map(), - domClipChildren: [], - }), + reset: () => set(createTimelineResetState()), })); // Bug-bash aid: expose the store so a reproduction can dump live state from the From 80d51e7a1cb0756a77b34af1a8be2ccd299f1510 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:23:44 +0200 Subject: [PATCH 2/3] test(studio): gate timeline viewport performance in Chromium --- packages/studio/package.json | 1 + .../src/hooks/useStudioTestHooks.test.tsx | 9 + .../studio/src/hooks/useStudioTestHooks.ts | 4 + .../timeline-virtualization/hyperframes.json | 8 + .../timeline-virtualization/index.html | 39 ++ .../tests/e2e/timeline-virtualization.mjs | 361 ++++++++++++++++++ 6 files changed, 422 insertions(+) create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html create mode 100644 packages/studio/tests/e2e/timeline-virtualization.mjs diff --git a/packages/studio/package.json b/packages/studio/package.json index 7d7a7e9992..060f5cf032 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,6 +49,7 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts" }, diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index 6279eb7770..9fbb51e215 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -112,6 +112,15 @@ describe("timeline performance fixture", () => { expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0); expect(usePlayerStore.getState().elements).toHaveLength(1_000); expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); + + api.resetTimelinePerformanceFixture(); + expect(notifications).toBe(2); + expect(usePlayerStore.getState()).toMatchObject({ + isPlaying: false, + duration: 0, + timelineReady: false, + elements: [], + }); unsubscribe(); act(() => root.unmount()); expect(window.__studioTest).toBeUndefined(); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 8b1ea7158c..d3920a846f 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -26,6 +26,7 @@ interface StudioTestApi { loadTimelinePerformanceFixture: ( spec: TimelinePerformanceFixtureSpec, ) => TimelinePerformanceFixtureSummary; + resetTimelinePerformanceFixture: () => void; readTimelinePerformanceDiagnostics: () => Readonly; timelineViewportBudgets: typeof TIMELINE_VIEWPORT_BUDGETS; } @@ -88,6 +89,9 @@ export function useStudioTestHooks({ }); return fixture.summary; }, + resetTimelinePerformanceFixture: () => { + usePlayerStore.getState().reset(); + }, readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(), timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS, }; diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json b/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json new file mode 100644 index 0000000000..c4816b5eaa --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html new file mode 100644 index 0000000000..2846170bf5 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html @@ -0,0 +1,39 @@ + + + + + + Timeline virtualization QA fixture + + + +
+ + + diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs new file mode 100644 index 0000000000..bf662c4656 --- /dev/null +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node +/** + * Reproducible timeline viewport gate against a running Studio preview of the + * adjacent fixture. The script prints machine-readable evidence; it never + * substitutes synthetic timings for a browser run. + * + * STUDIO_URL=http://127.0.0.1:5190/#project/timeline-virtualization \ + * node packages/studio/tests/e2e/timeline-virtualization.mjs + */ +import { existsSync, readdirSync } from "node:fs"; +import { homedir, platform, arch } from "node:os"; +import { join } from "node:path"; +import puppeteer from "puppeteer-core"; + +const STUDIO_URL = process.env.STUDIO_URL; +const PROFILE = process.env.TIMELINE_PROFILE || "dense-short"; +const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000); +const TIER = process.env.TIMELINE_TIER || "primary"; +const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR + ? Number(process.env.TIMELINE_CHROME_MAJOR) + : null; + +if (!STUDIO_URL) { + console.error("STUDIO_URL is required and must point at the timeline-virtualization fixture"); + process.exit(2); +} +if ( + ![1_000, 50_000].includes(ELEMENT_COUNT) || + !["primary", "low-resource", "high-dpr"].includes(TIER) +) { + console.error( + "TIMELINE_ELEMENT_COUNT must be 1000 or 50000; " + + "TIMELINE_TIER must be primary, low-resource, or high-dpr", + ); + process.exit(2); +} + +function resolveChromeExecutable() { + const chromeRoot = join(homedir(), ".cache", "puppeteer", "chrome"); + const builds = existsSync(chromeRoot) ? readdirSync(chromeRoot).sort().reverse() : []; + const installedCandidates = builds.flatMap((build) => + [ + "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-linux64/chrome", + ].map((relative) => join(chromeRoot, build, relative)), + ); + return [ + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + ...installedCandidates, + ].find((candidate) => candidate && existsSync(candidate)); +} + +function percentile(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +async function collectHeapBytes(client) { + const usage = await client.send("Runtime.getHeapUsage"); + return usage.usedSize; +} + +async function collectRun(page, injectedLongTaskMs = 0) { + return page.evaluate(async (longTaskProbeMs) => { + const longTasks = []; + const scroller = findTimelineScroller(); + const observer = observeLongTasks(); + let interactions; + let frameIntervals; + try { + ({ interactions, frameIntervals } = await measureScrollInteractions(scroller)); + if (longTaskProbeMs > 0) await runLongTaskProbe(longTaskProbeMs); + } finally { + recordLongTasks(observer.takeRecords()); + observer.disconnect(); + } + return { + interactionP95Ms: percentileInPage(interactions, 0.95), + frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95), + longestTaskMs: Math.max(0, ...longTasks), + scrollWidth: scroller.scrollWidth, + scrollHeight: scroller.scrollHeight, + diagnostics: window.__studioTest.readTimelinePerformanceDiagnostics(), + }; + + function percentileInPage(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; + } + + function findTimelineScroller() { + const root = document.querySelector('[aria-label="Timeline"]'); + if (!(root instanceof HTMLElement)) throw new Error("Timeline root not mounted"); + const scroller = Array.from(root.querySelectorAll("div")).find( + (node) => node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight, + ); + if (!(scroller instanceof HTMLElement)) throw new Error("Timeline scroller not mounted"); + return scroller; + } + + function recordLongTasks(entries) { + for (const entry of entries) longTasks.push(entry.duration); + } + + function observeLongTasks() { + if ( + typeof PerformanceObserver !== "function" || + !PerformanceObserver.supportedEntryTypes.includes("longtask") + ) { + throw new Error( + "The Long Tasks API is required to enforce the timeline responsiveness gate", + ); + } + const performanceObserver = new PerformanceObserver((list) => { + recordLongTasks(list.getEntries()); + }); + performanceObserver.observe({ entryTypes: ["longtask"] }); + return performanceObserver; + } + + function runLongTaskProbe(durationMs) { + return new Promise((resolve) => { + setTimeout(() => { + const started = performance.now(); + while (performance.now() - started < durationMs) { + // Deliberately block the browser task to prove the gate observes it. + } + setTimeout(resolve, 0); + }, 0); + }); + } + + async function measureScrollInteractions(timelineScroller) { + const interactions = []; + const frameIntervals = []; + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) { + const started = performance.now(); + timelineScroller.scrollLeft = Math.round( + (timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio, + ); + timelineScroller.scrollTop = Math.round( + (timelineScroller.scrollHeight - timelineScroller.clientHeight) * ratio, + ); + const firstFrame = await nextFrame(); + const secondFrame = await nextFrame(); + interactions.push(secondFrame - started); + frameIntervals.push(secondFrame - firstFrame); + } + return { interactions, frameIntervals }; + } + }, injectedLongTaskMs); +} + +async function assertLongTaskCapture(browser, longTaskLimitMs) { + const page = await browser.newPage(); + const injectedDurationMs = longTaskLimitMs + 25; + try { + await page.setContent(` +
+
+
+
+
+ `); + await page.evaluate(() => { + window.__studioTest = { readTimelinePerformanceDiagnostics: () => ({}) }; + }); + const probe = await collectRun(page, injectedDurationMs); + if (probe.longestTaskMs <= longTaskLimitMs) { + throw new Error( + `Long Tasks observer missed an injected ${injectedDurationMs}ms browser task: ` + + `observed ${probe.longestTaskMs}ms against a ${longTaskLimitMs}ms limit`, + ); + } + return { + injectedDurationMs, + observedLongestTaskMs: probe.longestTaskMs, + }; + } finally { + await page.close(); + } +} + +async function measureMaximumReliableScrollWidth(page) { + return page.evaluate(() => { + const viewportWidth = 320; + const container = document.createElement("div"); + const content = document.createElement("div"); + container.style.cssText = `position:fixed;left:-10000px;top:0;width:${viewportWidth}px;height:1px;overflow:auto`; + content.style.height = "1px"; + container.append(content); + document.body.append(container); + const reliable = (width) => { + content.style.width = `${width}px`; + container.scrollLeft = width; + const expected = width - viewportWidth; + return container.scrollWidth >= width - 1 && container.scrollLeft >= expected - 1; + }; + let low = 0; + let high = 64_000_000; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (reliable(middle)) low = middle; + else high = middle; + } + container.remove(); + return low; + }); +} + +const executablePath = resolveChromeExecutable(); +if (!executablePath) { + console.error("No Chrome executable found; set PUPPETEER_EXECUTABLE_PATH"); + process.exit(2); +} + +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"], +}); +let exitCode = 1; +try { + const version = await browser.version(); + const chromeMajor = Number(/(?:Chrome|Chromium)\/(\d+)/.exec(version)?.[1]); + if (EXPECTED_CHROME_MAJOR !== null && chromeMajor !== EXPECTED_CHROME_MAJOR) { + throw new Error( + `Pinned Chrome ${EXPECTED_CHROME_MAJOR} required, received ${version}. ` + + "Override TIMELINE_CHROME_MAJOR only when intentionally recording a new baseline.", + ); + } + const page = await browser.newPage(); + await page.setViewport({ + width: 1440, + height: 900, + deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, + }); + const client = await page.createCDPSession(); + if (TIER === "low-resource") { + await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + } + await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForFunction( + () => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function", + { timeout: 30_000 }, + ); + + await page.evaluate((profile) => { + window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); + }, PROFILE); + await client.send("HeapProfiler.collectGarbage"); + const baselineHeapBytes = await collectHeapBytes(client); + + const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets); + const longTaskLimitMs = + TIER === "primary" ? budgets.longTaskLimitMs : budgets.constrainedLongTaskLimitMs; + const longTaskObserverProbe = await assertLongTaskCapture(browser, longTaskLimitMs); + const summary = await page.evaluate( + ({ elementCount, profile }) => + window.__studioTest.loadTimelinePerformanceFixture({ elementCount, profile }), + { elementCount: ELEMENT_COUNT, profile: PROFILE }, + ); + await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]')); + const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); + + const runs = []; + const interactionLimitMs = + TIER === "primary" ? budgets.interactionP95Ms : budgets.constrainedInteractionP95Ms; + const frameIntervalLimitMs = + TIER === "primary" ? budgets.frameIntervalP95Ms : budgets.constrainedFrameIntervalP95Ms; + for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) { + const run = await collectRun(page); + if (index >= budgets.warmupRuns) runs.push(run); + } + for (const run of runs) { + run.passed = + run.interactionP95Ms <= interactionLimitMs && + run.frameIntervalP95Ms <= frameIntervalLimitMs && + run.longestTaskMs <= longTaskLimitMs && + run.diagnostics.timelineRoots === 1 && + run.diagnostics.mountedRows <= budgets.maxMountedRows && + run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots && + run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow && + run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants; + } + + await page.evaluate(() => window.__studioTest.resetTimelinePerformanceFixture()); + await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]') === null); + await page.evaluate((profile) => { + window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); + }, PROFILE); + await client.send("HeapProfiler.collectGarbage"); + const returnedHeapBytes = await collectHeapBytes(client); + const memoryReturned = + returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio); + const passingRuns = runs.filter((run) => run.passed).length; + const maxTimelineContentWidthPx = Math.max(0, ...runs.map((run) => run.scrollWidth)); + const directScrollGate = { + safetyEnvelopePx: budgets.directScrollSafetyPx, + maxTimelineContentWidthPx, + measuredMaxReliableScrollWidth, + decision: + maxTimelineContentWidthPx <= budgets.directScrollSafetyPx && + measuredMaxReliableScrollWidth >= maxTimelineContentWidthPx + ? "approved" + : "rejected", + }; + const evidence = { + environment: { + browser: version, + executablePath, + os: platform(), + architecture: arch(), + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, + cpuThrottleRate: TIER === "low-resource" ? 4 : 1, + tier: TIER, + longTaskObserverProbe, + fixture: summary, + runProtocol: { + warmups: budgets.warmupRuns, + measured: budgets.measuredRuns, + requiredPassing: budgets.requiredPassingRuns, + }, + }, + directScrollGate, + runs, + aggregate: { + interactionP95Ms: percentile( + runs.map((run) => run.interactionP95Ms), + 0.95, + ), + frameIntervalP95Ms: percentile( + runs.map((run) => run.frameIntervalP95Ms), + 0.95, + ), + passingRuns, + baselineHeapBytes, + returnedHeapBytes, + memoryReturned, + }, + }; + console.log(JSON.stringify(evidence, null, 2)); + exitCode = + directScrollGate.decision === "approved" && + passingRuns >= budgets.requiredPassingRuns && + memoryReturned + ? 0 + : 1; +} finally { + await browser.close(); +} +process.exit(exitCode); From 1b39e7942af9c6811a1a21827220beb2f9bf3915 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:24:46 +0200 Subject: [PATCH 3/3] refactor(studio): isolate clip drag lifecycle --- .../timelineClipDragGestureLifecycle.test.ts | 97 +++++++ .../timelineClipDragGestureLifecycle.ts | 259 ++++++++++++++++++ .../player/components/useTimelineClipDrag.ts | 244 ++--------------- 3 files changed, 384 insertions(+), 216 deletions(-) create mode 100644 packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts create mode 100644 packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts new file mode 100644 index 0000000000..b52b984e39 --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +import type { SetStateAction } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; +import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes"; + +afterEach(() => { + document.body.replaceChildren(); + usePlayerStore.getState().reset(); +}); + +describe("timeline clip drag gesture lifecycle", () => { + it("finishes a drag after virtualization unmounts its source row", () => { + const element: TimelineElement = { + id: "clip-1", + tag: "div", + start: 0, + duration: 2, + track: 0, + }; + const drag: DraggedClipState = { + element, + originClientX: 0, + originClientY: 0, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: 0, + pointerClientY: 0, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: 1, + previewTrack: 0, + desiredTrack: 0, + insertRow: null, + snapTime: null, + snapType: null, + started: true, + }; + const draggedClipRef = { current: drag as DraggedClipState | null }; + const resizingClipRef = { current: null as ResizingClipState | null }; + const setDraggedClip = (next: SetStateAction) => { + draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next; + }; + const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({ + ...previous, + previewStart: 3, + })); + const onMoveElement = vi.fn(); + const stopAutoScroll = vi.fn(); + const dispose = mountTimelineClipDragGestureLifecycle({ + draggedClipRef, + resizingClipRef, + blockedClipRef: { current: null }, + groupResizeRef: { current: null }, + suppressClickRef: { current: false }, + elementsRef: { current: [element] }, + trackOrderRef: { current: [0] }, + setDraggedClip, + setResizingClip: () => {}, + setShowPopover: () => {}, + setRangeSelectionRef: { current: null }, + applyResizePointerRef: { current: () => {} }, + syncClipDragAutoScrollRef: { current: () => {} }, + stopClipDragAutoScrollRef: { current: stopAutoScroll }, + updateDraggedClipPreviewRef: { current: updateDraggedClipPreview }, + restoreGroupResizeMembers: () => {}, + updateElement: vi.fn(), + onMoveElementRef: { current: onMoveElement }, + onMoveElementsRef: { current: undefined }, + onResizeElementRef: { current: undefined }, + onResizeElementsRef: { current: undefined }, + onBlockedEditAttemptRef: { current: undefined }, + readZIndexRef: { current: undefined }, + onStackingPatchesRef: { current: undefined }, + refreshAfterLaneMoveRef: { current: undefined }, + }); + + const sourceRow = document.createElement("div"); + sourceRow.dataset.timelineRow = ""; + sourceRow.append(document.createElement("div")); + document.body.append(sourceRow); + sourceRow.remove(); + + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 })); + expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1); + expect(draggedClipRef.current?.previewStart).toBe(3); + + window.dispatchEvent(new MouseEvent("pointerup")); + expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 }); + expect(draggedClipRef.current).toBeNull(); + expect(stopAutoScroll).toHaveBeenCalledTimes(1); + + dispose(); + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts new file mode 100644 index 0000000000..f9432dd1d2 --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -0,0 +1,259 @@ +import type { Dispatch, RefObject, SetStateAction } from "react"; +import { resolveTimelineDragEscape } from "./timelineEditing"; +import { commitDraggedClipMove } from "./timelineClipDragCommit"; +import type { + BlockedClipState, + DraggedClipState, + ResizingClipState, +} from "./timelineClipDragTypes"; +import type { TimelineGroupResizeSession } from "./timelineGroupEditing"; +import { commitTimelineGroupResize } from "./timelineGroupResizeCommit"; +import { + beginTimelineOptimisticGesture, + rollbackLatestTimelineOptimisticGesture, +} from "./timelineOptimisticRevision"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import type { StackingPatch } from "./timelineStackingSync"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; + +type UpdateElement = ReturnType["updateElement"]; + +interface TimelineClipDragGestureLifecycleInput { + draggedClipRef: RefObject; + resizingClipRef: RefObject; + blockedClipRef: RefObject; + groupResizeRef: RefObject; + suppressClickRef: RefObject; + elementsRef: RefObject; + trackOrderRef: RefObject; + setDraggedClip: Dispatch>; + setResizingClip: Dispatch>; + setShowPopover: (show: boolean) => void; + setRangeSelectionRef: RefObject<((selection: null) => void) | null>; + applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>; + syncClipDragAutoScrollRef: RefObject<(clientX: number, clientY: number) => void>; + stopClipDragAutoScrollRef: RefObject<() => void>; + updateDraggedClipPreviewRef: RefObject< + (drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState + >; + restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void; + updateElement: UpdateElement; + onMoveElementRef: RefObject; + onMoveElementsRef: RefObject; + onResizeElementRef: RefObject; + onResizeElementsRef: RefObject; + onBlockedEditAttemptRef: RefObject< + ((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined + >; + readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>; + onStackingPatchesRef: RefObject< + ((patches: StackingPatch[]) => Promise | void) | undefined + >; + refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>; +} + +export function mountTimelineClipDragGestureLifecycle({ + draggedClipRef, + resizingClipRef, + blockedClipRef, + groupResizeRef, + suppressClickRef, + elementsRef, + trackOrderRef, + setDraggedClip, + setResizingClip, + setShowPopover, + setRangeSelectionRef, + applyResizePointerRef, + syncClipDragAutoScrollRef, + stopClipDragAutoScrollRef, + updateDraggedClipPreviewRef, + restoreGroupResizeMembers, + updateElement, + onMoveElementRef, + onMoveElementsRef, + onResizeElementRef, + onResizeElementsRef, + onBlockedEditAttemptRef, + readZIndexRef, + onStackingPatchesRef, + refreshAfterLaneMoveRef, +}: TimelineClipDragGestureLifecycleInput): () => void { + const clearSuppressedClick = () => { + requestAnimationFrame(() => { + suppressClickRef.current = false; + }); + }; + + const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => { + const distance = Math.abs(event.clientX - resize.originClientX); + if (!resize.started && distance < 2) return; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + applyResizePointerRef.current(resize, event.clientX); + syncClipDragAutoScrollRef.current(event.clientX, event.clientY); + }; + + const handleBlockedPointerMove = (event: PointerEvent, blocked: BlockedClipState) => { + const distance = Math.hypot( + event.clientX - blocked.originClientX, + event.clientY - blocked.originClientY, + ); + const threshold = blocked.intent === "move" ? 4 : 2; + if (!blocked.started && distance < threshold) return; + if (!blocked.started) { + blocked.started = true; + blockedClipRef.current = blocked; + suppressClickRef.current = true; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent); + } + }; + + const handleDragPointerMove = (event: PointerEvent, drag: DraggedClipState) => { + const distance = Math.hypot( + event.clientX - drag.originClientX, + event.clientY - drag.originClientY, + ); + if (!drag.started && distance < 4) return; + setShowPopover(false); + setRangeSelectionRef.current?.(null); + setDraggedClip((previous) => + previous + ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY) + : previous, + ); + syncClipDragAutoScrollRef.current(event.clientX, event.clientY); + }; + + const handleWindowPointerMove = (event: PointerEvent) => { + const resize = resizingClipRef.current; + if (resize) return handleResizePointerMove(event, resize); + const blocked = blockedClipRef.current; + if (blocked) return handleBlockedPointerMove(event, blocked); + const drag = draggedClipRef.current; + if (drag) handleDragPointerMove(event, drag); + }; + + const commitResizePointerUp = (resize: ResizingClipState) => { + resizingClipRef.current = null; + setResizingClip(null); + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (!resize.started) { + if (groupSession) restoreGroupResizeMembers(groupSession); + return; + } + suppressClickRef.current = true; + clearSuppressedClick(); + if (groupSession) { + commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current); + return; + } + const hasChanged = + resize.previewStart !== resize.element.start || + resize.previewDuration !== resize.element.duration || + resize.previewPlaybackStart !== resize.element.playbackStart; + if (!hasChanged) return; + + const resizeKey = resize.element.key ?? resize.element.id; + const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]); + updateElement(resizeKey, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }); + Promise.resolve( + onResizeElementRef.current?.(resize.element, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }), + ).catch((error) => { + rollbackLatestTimelineOptimisticGesture(updateElement, revision, [ + { + key: resizeKey, + updates: { + start: resize.element.start, + duration: resize.element.duration, + playbackStart: resize.element.playbackStart, + }, + }, + ]); + console.error("[Timeline] Failed to persist clip resize", error); + }); + }; + + const finishBlockedPointerUp = (blocked: BlockedClipState) => { + blockedClipRef.current = null; + if (blocked.started) clearSuppressedClick(); + }; + + const commitDragPointerUp = (drag: DraggedClipState) => { + draggedClipRef.current = null; + setDraggedClip(null); + if (!drag.started) return; + suppressClickRef.current = true; + clearSuppressedClick(); + commitDraggedClipMove(drag, { + elements: elementsRef.current, + trackOrder: trackOrderRef.current, + updateElement, + onMoveElement: onMoveElementRef.current, + onMoveElements: onMoveElementsRef.current, + selectedKeys: usePlayerStore.getState().selectedElementIds, + readZIndex: readZIndexRef.current, + onStackingPatches: onStackingPatchesRef.current, + refreshAfterLaneMove: refreshAfterLaneMoveRef.current, + }); + }; + + const handleWindowPointerUp = () => { + stopClipDragAutoScrollRef.current(); + const resize = resizingClipRef.current; + if (resize) return commitResizePointerUp(resize); + const blocked = blockedClipRef.current; + if (blocked) return finishBlockedPointerUp(blocked); + const drag = draggedClipRef.current; + if (!drag) { + if (suppressClickRef.current) clearSuppressedClick(); + return; + } + commitDragPointerUp(drag); + }; + + const handleWindowKeyDown = (event: KeyboardEvent) => { + const decision = resolveTimelineDragEscape({ + key: event.key, + drag: draggedClipRef.current, + resize: resizingClipRef.current, + blocked: blockedClipRef.current, + }); + if (!decision.cancel) return; + event.preventDefault(); + event.stopPropagation(); + stopClipDragAutoScrollRef.current(); + draggedClipRef.current = null; + setDraggedClip(null); + resizingClipRef.current = null; + setResizingClip(null); + const groupSession = groupResizeRef.current; + groupResizeRef.current = null; + if (groupSession) restoreGroupResizeMembers(groupSession); + blockedClipRef.current = null; + if (decision.suppressClick) suppressClickRef.current = true; + }; + + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + window.addEventListener("keydown", handleWindowKeyDown, true); + return () => { + stopClipDragAutoScrollRef.current(); + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + window.removeEventListener("keydown", handleWindowKeyDown, true); + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 90d4100813..da366e6bdb 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -3,7 +3,6 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { applyTimelineAutoScrollStep, resolveTimelineAutoScrollLoopAction, - resolveTimelineDragEscape, } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; @@ -14,7 +13,6 @@ import { type TimelineGroupResizeSession, } from "./timelineGroupEditing"; import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineSnapping"; -import { commitDraggedClipMove } from "./timelineClipDragCommit"; import type { StackingPatch } from "./timelineStackingSync"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { @@ -28,11 +26,7 @@ import type { ResizingClipState, BlockedClipState, } from "./timelineClipDragTypes"; -import { - beginTimelineOptimisticGesture, - rollbackLatestTimelineOptimisticGesture, -} from "./timelineOptimisticRevision"; -import { commitTimelineGroupResize } from "./timelineGroupResizeCommit"; +import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; export type { DraggedClipState, @@ -363,215 +357,33 @@ export function useTimelineClipDrag({ stopClipDragAutoScrollRef.current = stopClipDragAutoScroll; useMountEffect(() => { - const clearSuppressedClick = () => { - requestAnimationFrame(() => { - suppressClickRef.current = false; - }); - }; - - /* ── pointermove branch handlers (dispatched by drag/resize/blocked) ── */ - const handleResizePointerMove = (e: PointerEvent, resize: ResizingClipState) => { - const distance = Math.abs(e.clientX - resize.originClientX); - if (!resize.started && distance < 2) return; - - setShowPopover(false); - setRangeSelectionRef.current?.(null); - - applyResizePointerRef.current(resize, e.clientX); - // Edge auto-scroll during a trim, exactly like the move branch — lets a - // right-edge trim keep extending past the current viewport (the stepper - // re-runs the scroll-compensated preview each frame). - syncClipDragAutoScrollRef.current(e.clientX, e.clientY); - }; - - const handleBlockedPointerMove = (e: PointerEvent, blocked: BlockedClipState) => { - const distance = Math.hypot( - e.clientX - blocked.originClientX, - e.clientY - blocked.originClientY, - ); - const threshold = blocked.intent === "move" ? 4 : 2; - if (!blocked.started && distance < threshold) return; - if (!blocked.started) { - blocked.started = true; - blockedClipRef.current = blocked; - suppressClickRef.current = true; - setShowPopover(false); - setRangeSelectionRef.current?.(null); - onBlockedEditAttemptRef.current?.(blocked.element, blocked.intent); - } - }; - - const handleDragPointerMove = (e: PointerEvent, drag: DraggedClipState) => { - const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY); - if (!drag.started && distance < 4) return; - - setShowPopover(false); - setRangeSelectionRef.current?.(null); - - setDraggedClip((prev) => - prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev, - ); - syncClipDragAutoScrollRef.current(e.clientX, e.clientY); - }; - - const handleWindowPointerMove = (e: PointerEvent) => { - const resize = resizingClipRef.current; - if (resize) return handleResizePointerMove(e, resize); - const blocked = blockedClipRef.current; - if (blocked) return handleBlockedPointerMove(e, blocked); - const drag = draggedClipRef.current; - if (drag) handleDragPointerMove(e, drag); - }; - - /* ── pointerup commit handlers (dispatched by drag/resize/blocked) ──── */ - const commitResizePointerUp = (resize: ResizingClipState) => { - resizingClipRef.current = null; - setResizingClip(null); - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (!resize.started) { - // No preview ran, so no group store-mutation to undo; guard is defensive. - if (groupSession) restoreGroupResizeMembers(groupSession); - return; - } - - suppressClickRef.current = true; - clearSuppressedClick(); - - if (groupSession) { - commitTimelineGroupResize(groupSession, updateElement, onResizeElementsRef.current); - return; - } - - const hasChanged = - resize.previewStart !== resize.element.start || - resize.previewDuration !== resize.element.duration || - resize.previewPlaybackStart !== resize.element.playbackStart; - if (!hasChanged) return; - - const resizeKey = resize.element.key ?? resize.element.id; - const revision = beginTimelineOptimisticGesture(updateElement, [resizeKey]); - updateElement(resizeKey, { - start: resize.previewStart, - duration: resize.previewDuration, - playbackStart: resize.previewPlaybackStart, - }); - - Promise.resolve( - onResizeElementRef.current?.(resize.element, { - start: resize.previewStart, - duration: resize.previewDuration, - playbackStart: resize.previewPlaybackStart, - }), - ).catch((error) => { - rollbackLatestTimelineOptimisticGesture(updateElement, revision, [ - { - key: resizeKey, - updates: { - start: resize.element.start, - duration: resize.element.duration, - playbackStart: resize.element.playbackStart, - }, - }, - ]); - console.error("[Timeline] Failed to persist clip resize", error); - }); - }; - - const finishBlockedPointerUp = (blocked: BlockedClipState) => { - blockedClipRef.current = null; - if (!blocked.started) return; - clearSuppressedClick(); - }; - - const commitDragPointerUp = (drag: DraggedClipState) => { - draggedClipRef.current = null; - setDraggedClip(null); - if (!drag.started) return; - - suppressClickRef.current = true; - clearSuppressedClick(); - - // Commit the drag — insert (new track), main-track ripple (reflow contiguous), - // a plain single-clip move, or a multi-selection move (every selected clip - // shifts by the dragged clip's time delta). See timelineClipDragCommit. - commitDraggedClipMove(drag, { - elements: elementsRef.current, - trackOrder: trackOrderRef.current, - updateElement, - onMoveElement: onMoveElementRef.current, - onMoveElements: onMoveElementsRef.current, - selectedKeys: usePlayerStore.getState().selectedElementIds, - // Lane ↔ stacking: engages only when the timeline layer provisions both - // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync. - readZIndex: readZIndexRef.current, - onStackingPatches: onStackingPatchesRef.current, - refreshAfterLaneMove: refreshAfterLaneMoveRef.current, - }); - }; - - const handleWindowPointerUp = () => { - stopClipDragAutoScrollRef.current(); - - const resize = resizingClipRef.current; - if (resize) return commitResizePointerUp(resize); - - const blocked = blockedClipRef.current; - if (blocked) return finishBlockedPointerUp(blocked); - - const drag = draggedClipRef.current; - if (!drag) { - // Escape-cancel leaves the click suppressor armed so the click this - // pointerup generates can't act on the clip; disarm it right after. - if (suppressClickRef.current) clearSuppressedClick(); - return; - } - commitDragPointerUp(drag); - }; - - // Escape cancels the in-progress gesture: no commit, no undo entry. The - // previews live only in the drag/resize state (the store is untouched - // until the pointerup commit), so clearing them restores the pre-drag - // timeline. Clip drags never take pointer capture (all tracking runs on - // these window listeners), so there is no capture to release; the null - // refs make the remaining pointermove/pointerup a no-op. - const handleWindowKeyDown = (e: KeyboardEvent) => { - const decision = resolveTimelineDragEscape({ - key: e.key, - drag: draggedClipRef.current, - resize: resizingClipRef.current, - blocked: blockedClipRef.current, - }); - if (!decision.cancel) return; - e.preventDefault(); - e.stopPropagation(); - stopClipDragAutoScrollRef.current(); - draggedClipRef.current = null; - setDraggedClip(null); - resizingClipRef.current = null; - setResizingClip(null); - // Undo any group-resize preview store-mutation (non-grabbed members) so the - // cancelled gesture restores the pre-drag timeline, like the single-clip path. - const groupSession = groupResizeRef.current; - groupResizeRef.current = null; - if (groupSession) restoreGroupResizeMembers(groupSession); - blockedClipRef.current = null; - // The pointer is usually still down; keep the suppressor armed until the - // eventual pointerup (which disarms it) so its click can't reselect. - if (decision.suppressClick) suppressClickRef.current = true; - }; - - window.addEventListener("pointermove", handleWindowPointerMove); - window.addEventListener("pointerup", handleWindowPointerUp); - window.addEventListener("pointercancel", handleWindowPointerUp); - window.addEventListener("keydown", handleWindowKeyDown, true); - return () => { - stopClipDragAutoScrollRef.current(); - window.removeEventListener("pointermove", handleWindowPointerMove); - window.removeEventListener("pointerup", handleWindowPointerUp); - window.removeEventListener("pointercancel", handleWindowPointerUp); - window.removeEventListener("keydown", handleWindowKeyDown, true); - }; + return mountTimelineClipDragGestureLifecycle({ + draggedClipRef, + resizingClipRef, + blockedClipRef, + groupResizeRef, + suppressClickRef, + elementsRef, + trackOrderRef, + setDraggedClip, + setResizingClip, + setShowPopover, + setRangeSelectionRef, + applyResizePointerRef, + syncClipDragAutoScrollRef, + stopClipDragAutoScrollRef, + updateDraggedClipPreviewRef, + restoreGroupResizeMembers, + updateElement, + onMoveElementRef, + onMoveElementsRef, + onResizeElementRef, + onResizeElementsRef, + onBlockedEditAttemptRef, + readZIndexRef, + onStackingPatchesRef, + refreshAfterLaneMoveRef, + }); }); return {