From 2f240637758450d41ab64d498400bbac0bae626e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:20:43 +0200 Subject: [PATCH 1/5] 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 5befbb594ab3826ab7fbdd1d04129f01c07cb82a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:23:44 +0200 Subject: [PATCH 2/5] 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 1f5bf769a6..2708d814be 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 2ec5b7ea3c6ca2b730feaf49714d202ac95d1fa7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:24:46 +0200 Subject: [PATCH 3/5] 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 { From 89f3608fd7578b5b85b4a53abb3b99650af31193 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:27:41 +0200 Subject: [PATCH 4/5] refactor(studio): extract timeline render contracts --- .../studio/src/player/components/Timeline.tsx | 95 ++++---------- .../src/player/components/TimelineRuler.tsx | 3 +- .../src/player/components/timelineLayout.ts | 118 ------------------ .../components/timelineRulerGeometry.ts | 115 +++++++++++++++++ .../player/components/timelineViewModel.ts | 44 +++++++ .../useTimelineSelectionLifecycle.ts | 29 +++++ .../components/useTimelineShiftModifier.ts | 20 +++ .../src/player/components/useTimelineTicks.ts | 17 +++ .../src/player/hooks/timelinePlayerSync.ts | 20 +++ .../src/player/hooks/useTimelinePlayer.ts | 21 +--- .../studio/src/player/store/playerStore.ts | 10 +- .../studio/src/utils/studioUiPreferences.ts | 4 +- 12 files changed, 280 insertions(+), 216 deletions(-) create mode 100644 packages/studio/src/player/components/timelineRulerGeometry.ts create mode 100644 packages/studio/src/player/components/timelineViewModel.ts create mode 100644 packages/studio/src/player/components/useTimelineSelectionLifecycle.ts create mode 100644 packages/studio/src/player/components/useTimelineShiftModifier.ts create mode 100644 packages/studio/src/player/components/useTimelineTicks.ts create mode 100644 packages/studio/src/player/hooks/timelinePlayerSync.ts diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 1c3022c858..9f2e3f603f 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,10 +1,9 @@ -import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react"; +import { useRef, useMemo, useCallback, useState, memo } from "react"; import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; import { isMusicTrack } from "../../utils/timelineInspector"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; -import { useMountEffect } from "../../hooks/useMountEffect"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; @@ -16,14 +15,12 @@ import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays } from "./TimelineOverlays"; -import { animationContributesLane } from "./TimelinePropertyLanes"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; -import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout"; +import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; -import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; import { @@ -37,11 +34,17 @@ import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; +import { + getEffectiveTimelineDuration, + getTimelinePreviewElement, + hasKeyframedTimelineClips, +} from "./timelineViewModel"; +import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; +import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; +import { useTimelineTicks } from "./useTimelineTicks"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { - generateTicks, - formatTimelineTickLabel, shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, getTimelineScrollLeftForZoomAnchor, @@ -52,6 +55,7 @@ export { shouldHandleTimelineDeleteKey, getDefaultDroppedTrack, } from "./timelineLayout"; +export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; export const Timeline = memo(function Timeline({ onSeek, @@ -117,13 +121,7 @@ export const Timeline = memo(function Timeline({ // Label mode = comp has keyframed clips (not just when expanded): keeps the layer // disclosure + property column visible and reserves a GUTTER before 0s (Figma). const hasKeyframedClips = useMemo( - () => - Array.from(gsapAnimations.values()).some((list) => - // Same lane-contribution predicate the layout uses: real keyframes OR a - // synthesizable flat tween. Checking animation.keyframes alone left a - // flat-tween-only comp without its reserved label column. - list.some((animation) => animationContributesLane(animation)), - ), + () => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations], ); const labelMode = hasKeyframedClips; @@ -142,20 +140,7 @@ export const Timeline = memo(function Timeline({ const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); - const [shiftHeld, setShiftHeld] = useState(false); - - useMountEffect(() => { - const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); - const blur = () => setShiftHeld(false); - window.addEventListener("keydown", key); - window.addEventListener("keyup", key); - window.addEventListener("blur", blur); - return () => { - window.removeEventListener("keydown", key); - window.removeEventListener("keyup", key); - window.removeEventListener("blur", blur); - }; - }); + const shiftHeld = useTimelineShiftModifier(); const [showPopover, setShowPopover] = useState(false); const [kfContextMenu, setKfContextMenu] = useState(null); @@ -172,12 +157,10 @@ export const Timeline = memo(function Timeline({ // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom). const lastScrollLeftRef = useRef(0); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - if (rawElements.length === 0) return safeDur; - const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration)); - return Number.isFinite(result) ? result : safeDur; - }, [rawElements, duration]); + const effectiveDuration = useMemo( + () => getEffectiveTimelineDuration(duration, rawElements), + [duration, rawElements], + ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); @@ -300,14 +283,6 @@ export const Timeline = memo(function Timeline({ toggleSelectedKeyframe, }); - const selectedElement = useMemo( - () => - expandedElements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null, - [expandedElements, selectedElementId], - ); - const selectedElementRef = useRef(selectedElement); - selectedElementRef.current = selectedElement; - const { pps, fitPps, @@ -406,41 +381,15 @@ export const Timeline = memo(function Timeline({ }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag - const prevSelectedRef = useRef(selectedElementRef.current); - // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps - useEffect(() => { - const prev = prevSelectedRef.current; - const curr = selectedElementRef.current; - prevSelectedRef.current = curr; - if (prev && !curr) { - setShowPopover(false); - setRangeSelection(null); - } - }); - - // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames. - const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; - const { major, minor } = useMemo( - () => generateTicks(displayDuration, pps, tickFps), - [displayDuration, pps, tickFps], + useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => + setRangeSelection(null), ); + + const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode); const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration; const getPreviewElement = useCallback( - (element: TimelineElement): TimelineElement => { - if ( - resizingClip && - (resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id) - ) { - return { - ...element, - start: resizingClip.previewStart, - duration: resizingClip.previewDuration, - playbackStart: resizingClip.previewPlaybackStart, - }; - } - return element; - }, + (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), [resizingClip], ); diff --git a/packages/studio/src/player/components/TimelineRuler.tsx b/packages/studio/src/player/components/TimelineRuler.tsx index 5356341bbe..bcb95f9acc 100644 --- a/packages/studio/src/player/components/TimelineRuler.tsx +++ b/packages/studio/src/player/components/TimelineRuler.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import type { TimelineTheme } from "./timelineTheme"; -import { RULER_H, formatTimelineTickLabel } from "./timelineLayout"; +import { RULER_H } from "./timelineLayout"; +import { formatTimelineTickLabel } from "./timelineRulerGeometry"; import { usePlayerStore } from "../store/playerStore"; import { secondsToFrame } from "../lib/time"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 0ac3cb856a..9e660f2bc6 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,4 +1,3 @@ -import { formatTime } from "../lib/time"; import type { ZoomMode } from "../store/playerStore"; /* ── Layout constants ──────────────────────────────────────────────── */ @@ -193,123 +192,6 @@ export const MIN_TIMELINE_EXTENT_S = 60; export const FIT_ZOOM_HEADROOM = 1.2; /* ── Tick generation ──────────────────────────────────────────────── */ -// fallow-ignore-next-line complexity -function getMajorTickInterval( - duration: number, - pixelsPerSecond?: number, - frameRate?: number, -): number { - // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s, - // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get - // readable (non-colliding) labels instead of the old 10m fallback everywhere. - const zoomIntervals = [ - 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, - ]; - let interval: number; - if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { - const targetMajorPx = 88; - interval = - zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? - 3600; - } else { - const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; - const target = duration / 6; - interval = durationIntervals.find((candidate) => candidate >= target) ?? 60; - } - // Frame display mode: labels are frame numbers, so a major step must be a - // WHOLE number of frames — sub-frame steps produce duplicate/uneven labels - // (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so - // the label spacing never drops below the readability target. - if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) { - const fps = frameRate ?? 0; - return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps; - } - return interval; -} - -// How many equal parts to split each major interval into for minor ticks. Prefer -// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then -// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode -// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a -// sub-frame time is not a seekable position), so only divisors of the major -// step's frame count qualify — quarters, then fifths (15/30-frame majors), -// thirds, halves. -// fallow-ignore-next-line complexity -function getMinorSubdivisions( - majorInterval: number, - pixelsPerSecond?: number, - frameRate?: number, -): number { - const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0; - if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks - const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0; - const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0; - const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2]; - for (const parts of candidates) { - if (fps > 0 && majorFrames % parts !== 0) continue; - if ((majorInterval / parts) * pps >= 8) return parts; - } - return 0; -} - -// Ticks are exact multiples of the interval (multiplied per index, never -// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to -// keep values/keys clean without disturbing frame-exact positions like 2/30s. -function roundTickValue(t: number): number { - return Math.round(t * 1e6) / 1e6; -} - -export function generateTicks( - duration: number, - pixelsPerSecond?: number, - frameRate?: number, -): { major: number[]; minor: number[] } { - if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) - return { major: [], minor: [] }; - const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate); - const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate); - const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0; - const major: number[] = []; - const minor: number[] = []; - const maxTicks = 2000; // Safety cap to prevent runaway tick generation - for (let i = 0; major.length < maxTicks; i++) { - const t = i * majorInterval; - if (t > duration + 0.001) break; - major.push(roundTickValue(t)); - // Emit the (subdivisions - 1) minor ticks between this major and the next. - for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) { - const m = t + k * minorInterval; - if (m <= duration + 0.001) minor.push(roundTickValue(m)); - } - } - return { major, minor }; -} - -export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) { - if (!Number.isFinite(time)) return "00:00"; - const safeTime = Math.max(0, time); - if (majorInterval < 0.1) { - const totalHundredths = Math.round(safeTime * 100); - const wholeSeconds = Math.floor(totalHundredths / 100); - const hundredth = totalHundredths % 100; - return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`; - } - if (majorInterval < 1) { - const totalTenths = Math.round(safeTime * 10); - const wholeSeconds = Math.floor(totalTenths / 10); - const tenth = totalTenths % 10; - return `${formatTime(wholeSeconds)}.${tenth}`; - } - if (duration >= 3600 || safeTime >= 3600) { - const totalSeconds = Math.floor(safeTime); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - return formatTime(safeTime); -} - /* ── Width / duration derivation ──────────────────────────────────── */ /** * Fit-mode pixels-per-second: fill the viewport with the composition plus diff --git a/packages/studio/src/player/components/timelineRulerGeometry.ts b/packages/studio/src/player/components/timelineRulerGeometry.ts new file mode 100644 index 0000000000..eeec57ecd0 --- /dev/null +++ b/packages/studio/src/player/components/timelineRulerGeometry.ts @@ -0,0 +1,115 @@ +import { formatTime } from "../lib/time"; + +// fallow-ignore-next-line complexity +function getTimelineMajorTickInterval( + duration: number, + pixelsPerSecond?: number, + frameRate?: number, +): number { + // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s, + // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get + // readable (non-colliding) labels instead of the old 10m fallback everywhere. + const zoomIntervals = [ + 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, + ]; + let interval: number; + if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { + const targetMajorPx = 88; + interval = + zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? + 3600; + } else { + const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; + const target = duration / 6; + interval = durationIntervals.find((candidate) => candidate >= target) ?? 60; + } + // Frame display mode: labels are frame numbers, so a major step must be a + // WHOLE number of frames. Snap UP so label spacing stays readable. + if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) { + const fps = frameRate ?? 0; + return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps; + } + return interval; +} + +// Prefer quarter subdivisions so the midpoint remains visible; fall back to +// smaller whole-frame-compatible sets as ticks become too dense to read. +// fallow-ignore-next-line complexity +function getMinorSubdivisions( + majorInterval: number, + pixelsPerSecond?: number, + frameRate?: number, +): number { + const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0; + if (pps <= 0) return 4; + const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0; + const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0; + const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2]; + for (const parts of candidates) { + if (fps > 0 && majorFrames % parts !== 0) continue; + if ((majorInterval / parts) * pps >= 8) return parts; + } + return 0; +} + +// Multiply from the index rather than accumulating, then round to 1µs so long +// rulers do not drift and frame-exact positions keep clean values and keys. +function roundTickValue(time: number): number { + return Math.round(time * 1e6) / 1e6; +} + +export function generateTicks( + duration: number, + pixelsPerSecond?: number, + frameRate?: number, +): { major: number[]; minor: number[] } { + if (duration <= 0 || !Number.isFinite(duration) || duration > 14400) { + return { major: [], minor: [] }; + } + const majorInterval = getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate); + const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate); + const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0; + const major: number[] = []; + const minor: number[] = []; + // Safety cap prevents malformed inputs from creating an unbounded ruler. + const maxTicks = 2000; + for (let index = 0; major.length < maxTicks; index++) { + const time = index * majorInterval; + if (time > duration + 0.001) break; + major.push(roundTickValue(time)); + for (let part = 1; part < subdivisions && major.length + minor.length < maxTicks; part++) { + const minorTime = time + part * minorInterval; + if (minorTime <= duration + 0.001) minor.push(roundTickValue(minorTime)); + } + } + return { major, minor }; +} + +export function formatTimelineTickLabel( + time: number, + duration: number, + majorInterval: number, +): string { + if (!Number.isFinite(time)) return "00:00"; + const safeTime = Math.max(0, time); + if (majorInterval < 0.1) { + const totalHundredths = Math.round(safeTime * 100); + const wholeSeconds = Math.floor(totalHundredths / 100); + const hundredth = totalHundredths % 100; + return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`; + } + if (majorInterval < 1) { + const totalTenths = Math.round(safeTime * 10); + const wholeSeconds = Math.floor(totalTenths / 10); + const tenth = totalTenths % 10; + return `${formatTime(wholeSeconds)}.${tenth}`; + } + if (duration >= 3600 || safeTime >= 3600) { + const totalSeconds = Math.floor(safeTime); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return formatTime(safeTime); +} diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts new file mode 100644 index 0000000000..ace13a4d1c --- /dev/null +++ b/packages/studio/src/player/components/timelineViewModel.ts @@ -0,0 +1,44 @@ +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import type { ResizingClipState } from "./timelineClipDragTypes"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { animationContributesLane } from "./TimelinePropertyLanes"; + +export function hasKeyframedTimelineClips( + animationsByElement: ReadonlyMap, +): boolean { + return Array.from(animationsByElement.values()).some((animations) => + animations.some(animationContributesLane), + ); +} + +export function getEffectiveTimelineDuration( + duration: number, + elements: readonly TimelineElement[], +): number { + const safeDuration = Number.isFinite(duration) ? duration : 0; + if (elements.length === 0) return safeDuration; + const result = Math.max( + safeDuration, + ...elements.map((element) => element.start + element.duration), + ); + return Number.isFinite(result) ? result : safeDuration; +} + +export function getTimelinePreviewElement( + element: TimelineElement, + resizingClip: ResizingClipState | null, +): TimelineElement { + if ( + resizingClip && + getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element) + ) { + return { + ...element, + start: resizingClip.previewStart, + duration: resizingClip.previewDuration, + playbackStart: resizingClip.previewPlaybackStart, + }; + } + return element; +} diff --git a/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts new file mode 100644 index 0000000000..1738ba230d --- /dev/null +++ b/packages/studio/src/player/components/useTimelineSelectionLifecycle.ts @@ -0,0 +1,29 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; + +export function useTimelineSelectionLifecycle( + elements: TimelineElement[], + selectedElementId: string | null, + setShowPopover: (show: boolean) => void, + clearRangeSelection: () => void, +): void { + const selectedElement = useMemo( + () => + elements.find((element) => getTimelineElementIdentity(element) === selectedElementId) ?? null, + [elements, selectedElementId], + ); + const selectedElementRef = useRef(selectedElement); + selectedElementRef.current = selectedElement; + const previousSelectedRef = useRef(selectedElementRef.current); + // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps + useEffect(() => { + const previous = previousSelectedRef.current; + const current = selectedElementRef.current; + previousSelectedRef.current = current; + if (previous && !current) { + setShowPopover(false); + clearRangeSelection(); + } + }); +} diff --git a/packages/studio/src/player/components/useTimelineShiftModifier.ts b/packages/studio/src/player/components/useTimelineShiftModifier.ts new file mode 100644 index 0000000000..7c334e4989 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineShiftModifier.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; + +export function useTimelineShiftModifier(): boolean { + const [shiftHeld, setShiftHeld] = useState(false); + useMountEffect(() => { + const handleKey = (event: KeyboardEvent) => + event.key === "Shift" && setShiftHeld(event.type === "keydown"); + const handleBlur = () => setShiftHeld(false); + window.addEventListener("keydown", handleKey); + window.addEventListener("keyup", handleKey); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKey); + window.removeEventListener("keyup", handleKey); + window.removeEventListener("blur", handleBlur); + }; + }); + return shiftHeld; +} diff --git a/packages/studio/src/player/components/useTimelineTicks.ts b/packages/studio/src/player/components/useTimelineTicks.ts new file mode 100644 index 0000000000..cd0852af37 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineTicks.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { STUDIO_PREVIEW_FPS } from "../lib/time"; +import type { TimelineTimeDisplayMode } from "../../utils/studioUiPreferences"; +import { generateTicks } from "./timelineRulerGeometry"; + +export function useTimelineTicks( + duration: number, + pixelsPerSecond: number, + timeDisplayMode: TimelineTimeDisplayMode, +): { major: number[]; minor: number[] } { + const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined; + const { major, minor } = useMemo( + () => generateTicks(duration, pixelsPerSecond, frameRate), + [duration, frameRate, pixelsPerSecond], + ); + return { major, minor }; +} diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts new file mode 100644 index 0000000000..cf937f8882 --- /dev/null +++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts @@ -0,0 +1,20 @@ +import type { TimelineElement } from "../store/playerStore"; + +/** Whether a derived timeline changes any field that affects rendering. */ +export function timelineElementsChanged( + previous: TimelineElement[], + next: TimelineElement[], +): boolean { + if (next.length !== previous.length) return true; + return next.some((element, index) => { + const prior = previous[index]; + return ( + !prior || + element.id !== prior.id || + element.start !== prior.start || + element.duration !== prior.duration || + element.track !== prior.track || + element.sourceDuration !== prior.sourceDuration + ); + }); +} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 39be707e68..726b64b3d6 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -43,26 +43,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol"; - -/** - * Whether the derived elements differ from the current ones in any field that - * affects rendering (identity, timing, track, or source length) — used to skip - * redundant store writes. - */ -function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean { - if (next.length !== prev.length) return true; - return next.some((el, i) => { - const p = prev[i]; - return ( - !p || - el.id !== p.id || - el.start !== p.start || - el.duration !== p.duration || - el.track !== p.track || - el.sourceDuration !== p.sourceDuration - ); - }); -} +import { timelineElementsChanged } from "./timelinePlayerSync"; export function useTimelinePlayer() { const iframeRef = useRef(null); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 688f2a22f3..18c085d044 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -3,7 +3,11 @@ 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 { + readStudioUiPreferences, + writeStudioUiPreferences, + type TimelineTimeDisplayMode, +} from "../../utils/studioUiPreferences"; import { computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; @@ -156,8 +160,8 @@ interface PlayerState extends KeyframeSlice { timelineSnapEnabled: boolean; setTimelineSnapEnabled: (enabled: boolean) => void; /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */ - timeDisplayMode: "time" | "frame"; - setTimeDisplayMode: (mode: "time" | "frame") => void; + timeDisplayMode: TimelineTimeDisplayMode; + setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void; /** * Pin the timeline zoom to its current visual scale before a duration-changing * edit, so a subsequent duration change (which recomputes fit-pps) stops diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index c2bfe7a537..02677bc42c 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -4,6 +4,8 @@ export interface StoredPreviewZoomState { panY: number; } +export type TimelineTimeDisplayMode = "time" | "frame"; + export interface StudioUiPreferences { leftCollapsed?: boolean; leftWidth?: number; @@ -21,7 +23,7 @@ export interface StudioUiPreferences { /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */ timelineSnapEnabled?: boolean; /** Transport + ruler readout mode: timecode or frame number. */ - timeDisplayMode?: "time" | "frame"; + timeDisplayMode?: TimelineTimeDisplayMode; /** * Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the * post-edit iframe reload — otherwise the store reset to "fit" and the duration From df6b9c017c8c99325647dccf3cef4ffe7b94c4fe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 19:28:52 +0200 Subject: [PATCH 5/5] perf(studio): centralize timeline viewport geometry --- .../studio/src/components/nle/NLEContext.tsx | 5 +- .../src/components/nle/TimelinePane.tsx | 2 + .../src/hooks/useStudioTestHooks.test.tsx | 6 + .../studio/src/hooks/useStudioTestHooks.ts | 3 + .../src/player/components/Timeline.test.ts | 22 +++ .../studio/src/player/components/Timeline.tsx | 79 ++++++-- .../src/player/components/TimelineTypes.ts | 2 + .../src/player/components/timelineDragDrop.ts | 10 +- .../player/components/timelineLayout.test.ts | 20 ++ .../src/player/components/timelineLayout.ts | 178 ++++++++++++------ .../components/timelineViewportGeometry.ts | 31 +++ .../components/useTimelineActiveClips.ts | 2 +- .../player/components/useTimelineClipDrag.ts | 26 ++- .../player/components/useTimelineGeometry.ts | 15 +- .../components/useTimelineRangeSelection.ts | 9 +- .../useTimelineRangeSelectionScrub.test.tsx | 5 +- .../useTimelineScrollViewport.test.tsx | 78 ++++++++ .../components/useTimelineScrollViewport.ts | 89 ++++++++- .../components/useTimelineTrackLayout.test.ts | 2 + .../components/useTimelineTrackLayout.ts | 53 ++++-- .../src/player/hooks/useTimelinePlayer.ts | 4 + .../studio/src/player/lib/playbackScrub.ts | 4 +- .../player/lib/timelineElementIndexes.test.ts | 31 +++ .../src/player/lib/timelineElementIndexes.ts | 44 +++++ .../player/lib/timelinePerformanceFixture.ts | 10 + .../src/player/store/playerStore.test.ts | 15 ++ .../studio/src/player/store/playerStore.ts | 24 ++- .../timeline-virtualization/index.html | 1 + .../tests/e2e/timeline-virtualization.mjs | 73 +++++-- 29 files changed, 684 insertions(+), 159 deletions(-) create mode 100644 packages/studio/src/player/components/timelineViewportGeometry.ts create mode 100644 packages/studio/src/player/components/useTimelineScrollViewport.test.tsx create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.test.ts create mode 100644 packages/studio/src/player/lib/timelineElementIndexes.ts diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5bdece6459..8874acb80e 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -48,6 +48,7 @@ export interface NLEContextValue { compositionLoading: boolean; setCompositionLoading: (loading: boolean) => void; timelineDisabled: boolean; + timelineSessionEpoch: number; hasLoadedOnceRef: React.MutableRefObject; // preview composition size (for preview block drop) previewCompositionSize: { width: number; height: number } | null; @@ -103,7 +104,7 @@ export function NLEProvider({ // project would otherwise keep rendering (and re-fetching from) the old project // after switching. useEffect(() => { - usePlayerStore.getState().reset(); + usePlayerStore.getState().beginTimelineSession(projectId); useAssetPreviewStore.getState().clearPreviewAsset(); }, [projectId]); @@ -289,6 +290,7 @@ export function NLEProvider({ setCompositionLoadingRaw(loading); }, []); const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading); + const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch); useEffect(() => { onCompositionLoadingChange?.(compositionLoading); @@ -319,6 +321,7 @@ export function NLEProvider({ compositionLoading, setCompositionLoading, timelineDisabled, + timelineSessionEpoch, hasLoadedOnceRef, previewCompositionSize, setPreviewCompositionSize, diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 52f10ae87d..ca40802056 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -127,6 +127,7 @@ export function TimelinePane({ persistTimelineH, containerRef, timelineDisabled, + timelineSessionEpoch, } = useNLEContext(); // Move/resize/split come from the timeline edit context, not props — the @@ -271,6 +272,7 @@ export function TimelinePane({ >
{timelineToolbar}
{ afterEach(() => { + setTimelinePerformanceFixtureLease(false); window.__studioTest = undefined; usePlayerStore.getState().reset(); }); @@ -112,6 +115,7 @@ 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); + expect(hasTimelinePerformanceFixtureLease()).toBe(true); api.resetTimelinePerformanceFixture(); expect(notifications).toBe(2); @@ -121,9 +125,11 @@ describe("timeline performance fixture", () => { timelineReady: false, elements: [], }); + expect(hasTimelinePerformanceFixtureLease()).toBe(true); unsubscribe(); act(() => root.unmount()); expect(window.__studioTest).toBeUndefined(); + expect(hasTimelinePerformanceFixtureLease()).toBe(false); }); it("does not mutate state when the fixture request is invalid", () => { diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index d3920a846f..1124b22904 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -7,6 +7,7 @@ import { } from "../player/lib/timelinePerformanceDiagnostics"; import { createTimelinePerformanceFixture, + setTimelinePerformanceFixtureLease, type TimelinePerformanceFixtureSpec, type TimelinePerformanceFixtureSummary, } from "../player/lib/timelinePerformanceFixture"; @@ -71,6 +72,7 @@ export function useStudioTestHooks({ }, loadTimelinePerformanceFixture: (spec) => { const fixture = createTimelinePerformanceFixture(spec); + setTimelinePerformanceFixtureLease(true); usePlayerStore.setState({ ...createTimelineResetState(), currentTime: 0, @@ -97,6 +99,7 @@ export function useStudioTestHooks({ }; window.__studioTest = api; return () => { + setTimelinePerformanceFixtureLease(false); // delete, not `= undefined`: an own key holding undefined keeps // `"__studioTest" in window` true, which defeats feature detection. delete window.__studioTest; diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 7335a0fadf..8d8dec2a37 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -17,6 +17,8 @@ import { shouldShowTimelineShortcutHint, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, + getTimelineVisibleTimeRange, + getTimelineScrollTopForGeometryChange, } from "./Timeline"; import { CLIP_Y, @@ -32,6 +34,7 @@ import { getTimelineDisplayContentWidth, getTimelineFitPps, getTimelineLaneTop, + createTimelineRowGeometry, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -44,6 +47,25 @@ afterEach(() => { usePlayerStore.getState().reset(); }); +describe("timeline viewport geometry", () => { + it("derives a clamped visible time range from the raw viewport", () => { + expect( + getTimelineVisibleTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20), + ).toEqual({ start: 1, end: 6 }); + expect(getTimelineVisibleTimeRange({ scrollLeft: 0, clientWidth: 100 }, 100, 200, 20)).toEqual({ + start: 0, + end: 0, + }); + }); + + it("keeps the same row anchored when a row above it expands", () => { + const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]); + const next = createTimelineRowGeometry([1, 2, 3], [104, 48, 48]); + const scrollTop = previous.getRowTop(2) - RULER_H + 6; + expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56); + }); +}); + function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { const clip = host.querySelector(`[data-el-id="${clipId}"]`); if (!clip) throw new Error(`Missing timeline clip ${clipId}`); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 9f2e3f603f..e3fe4b9c55 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,6 +1,5 @@ -import { useRef, useMemo, useCallback, useState, memo } from "react"; +import { useRef, useMemo, useCallback, useState, useLayoutEffect, memo } from "react"; import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; -import { isMusicTrack } from "../../utils/timelineInspector"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; @@ -42,6 +41,8 @@ import { import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; import { useTimelineTicks } from "./useTimelineTicks"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -57,6 +58,11 @@ export { } from "./timelineLayout"; export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; +export { + getTimelineScrollTopForGeometryChange, + getTimelineVisibleTimeRange, +} from "./timelineViewportGeometry"; + export const Timeline = memo(function Timeline({ onSeek, onDrillDown, @@ -75,6 +81,7 @@ export const Timeline = memo(function Timeline({ onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, + sessionEpoch = 0, }: TimelineProps = {}) { const { onMoveElement, @@ -106,7 +113,7 @@ export const Timeline = memo(function Timeline({ const rawElements = usePlayerStore((s) => s.elements); const expandedElements = useExpandedTimelineElements(); const beatAnalysis = usePlayerStore((s) => s.beatAnalysis); - const musicElement = usePlayerStore((s) => s.elements.find(isMusicTrack) ?? null); + const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); const beatEdits = usePlayerStore((s) => s.beatEdits); const adjustedBeatAnalysis = useMemo( () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits), @@ -164,8 +171,20 @@ export const Timeline = memo(function Timeline({ const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); - const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } = - useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds); + const { + tracks, + trackStyles, + trackOrder, + trackOrderRef, + laneCounts, + rowGeometry, + rowGeometryRef, + } = useTimelineTrackLayout( + expandedElements, + gsapAnimations, + selectedElementId, + selectedElementIds, + ); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -232,7 +251,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -251,7 +270,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, @@ -259,17 +278,44 @@ export const Timeline = memo(function Timeline({ onCompositionDrop: pinnedOnCompositionDrop, }); - const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ totalClipCount: expandedElements.length, totalRowCount: displayLayout.displayTrackOrder.length, zoomMode, }); - const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ - timelineReady, - expandedElements.length, - displayLayout.totalH, - ]); + const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = + useTimelineScrollViewport(scrollRef, [ + timelineReady, + expandedElements.length, + displayLayout.totalH, + ]); + const previousLayoutRef = useRef(displayLayout.rowGeometry); + const previousSessionEpochRef = useRef(sessionEpoch); + useLayoutEffect(() => { + const scroll = scrollRef.current; + const previousGeometry = previousLayoutRef.current; + if (previousSessionEpochRef.current !== sessionEpoch) { + previousSessionEpochRef.current = sessionEpoch; + lastScrollLeftRef.current = 0; + if (scroll) { + scroll.scrollLeft = 0; + scroll.scrollTop = 0; + syncScrollViewport(scroll); + } + } else if (scroll && previousGeometry !== displayLayout.rowGeometry) { + const nextScrollTop = getTimelineScrollTopForGeometryChange( + previousGeometry, + displayLayout.rowGeometry, + scroll.scrollTop, + ); + if (nextScrollTop !== scroll.scrollTop) { + scroll.scrollTop = nextScrollTop; + syncScrollViewport(scroll); + } + } + previousLayoutRef.current = displayLayout.rowGeometry; + }, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = @@ -292,7 +338,7 @@ export const Timeline = memo(function Timeline({ zoomModeRef, manualZoomPercentRef, } = useTimelineGeometry({ - viewportWidth, + viewportWidth: viewport.clientWidth, effectiveDuration, zoomMode, manualZoomPercent, @@ -375,7 +421,7 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onSelectElement, contentOrigin, }); @@ -409,6 +455,7 @@ export const Timeline = memo(function Timeline({
{ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload recordTimelineScroll(e.currentTarget); + syncScrollViewport(e.currentTarget, true); }} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index 95313d617f..0838e8dccb 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -5,6 +5,8 @@ import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks"; export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides { + /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */ + sessionEpoch?: number; onSeek?: (time: number) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 0d3985b7a7..8cd2bfbe78 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -5,7 +5,7 @@ import { TIMELINE_COMPOSITION_MIME, } from "../../utils/timelineCompositionDrop"; import { usePlayerStore } from "../store/playerStore"; -import { resolveTimelineAssetDrop } from "./timelineLayout"; +import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout"; import type { TimelineDropCallbacks } from "./timelineCallbacks"; interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { @@ -13,7 +13,7 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { ppsRef: RefObject; durationRef: RefObject; trackOrderRef: RefObject; - rowHeightsRef: RefObject; + rowGeometryRef: RefObject; contentOrigin: number; } @@ -56,7 +56,7 @@ export function useTimelineAssetDrop({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, contentOrigin, onFileDrop, onAssetDrop, @@ -93,7 +93,7 @@ export function useTimelineAssetDrop({ pixelsPerSecond: ppsRef.current, duration: durationRef.current, clampStartToDuration: !usePointerStart, - rowHeights: rowHeightsRef.current, + rowHeights: rowGeometryRef.current.rowHeights, trackOrder: trackOrderRef.current, }, clientX, @@ -104,7 +104,7 @@ export function useTimelineAssetDrop({ track: pointer.track, }; }, - [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin], + [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin], ); const handleAssetDrop = useCallback( diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index 4cd2fa14d4..488d0662b4 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -15,6 +15,8 @@ import { getTimelineRowFromY, getTimelineRowOffsets, getTimelineCanvasHeight, + createTimelineRowGeometry, + getTimelineRowGeometry, trackHeights, resolveTimelineAssetDrop, } from "./timelineLayout"; @@ -64,6 +66,24 @@ describe("variable timeline row geometry", () => { RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD, ); }); + + it("reuses one immutable geometry snapshot for one height array", () => { + const heights = trackHeights(tracks, new Set(["b"])); + const first = getTimelineRowGeometry(heights); + expect(getTimelineRowGeometry(heights)).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.rowOffsets)).toBe(true); + }); + + it("looks up row boundaries through the precomputed geometry", () => { + const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]); + expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry); + expect(geometry.getRowIndex(8)).toBe(1); + expect(geometry.getRowFromY(geometry.getRowTop(1))).toBe(1); + expect(geometry.getRowFromY(geometry.getRowTop(2) - 0.001)).toBeLessThan(2); + expect(geometry.getRowFromY(geometry.getRowTop(2))).toBe(2); + expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 228 + TRACKS_BOTTOM_PAD); + }); }); describe("collapsed timeline row geometry characterization", () => { diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 9e660f2bc6..36ca26d68c 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -73,55 +73,134 @@ function validRowHeight(height: number | undefined): number { return height; } -/** - * Memoized by the rowHeights array identity: a marquee/drag pointer tick calls - * getTimelineRowTop once per clip, and each call would otherwise rebuild the - * whole cumulative array (O(clips x rows) allocations per tick). rowHeights is - * itself memoized upstream (useTimelineTrackLayout), so the identity is stable - * for the life of a gesture. Callers must treat the result as read-only. - */ -const rowOffsetsCache = new WeakMap(); +export interface TimelineRowGeometry { + readonly rowKeys: readonly number[]; + readonly rowHeights: readonly number[]; + /** Cumulative row boundaries, including the final bottom boundary. */ + readonly rowOffsets: readonly number[]; + readonly rowsHeight: number; + readonly canvasHeight: number; + getRowIndex(rowKey: number): number; + getRowHeight(row: number): number; + getRowTop(row: number): number; + getRowFromY(contentY: number): number; + getRowPositionFromY(contentY: number): { + rowFloat: number; + row: number; + fraction: number; + rowHeight: number; + }; +} + +const rowGeometryCache = new WeakMap(); +const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]); + +/** Build the immutable row snapshot shared by rendering and hit testing. */ +export function createTimelineRowGeometry( + rowKeys: readonly number[], + rowHeights: readonly number[], +): TimelineRowGeometry { + const heights = Object.freeze(rowHeights.map(validRowHeight)); + const keys = Object.freeze( + heights.map((_, row) => { + const key = rowKeys[row]; + return key !== undefined && Number.isFinite(key) ? key : row; + }), + ); + const offsets = [0]; + for (const height of heights) offsets.push((offsets.at(-1) ?? 0) + height); + Object.freeze(offsets); + const rowIndexByKey = new Map(keys.map((key, row) => [key, row])); + + const getRowHeight = (row: number) => validRowHeight(heights[row]); + const getRowOffset = (row: number) => { + if (heights.length === 0) return row * TRACK_H; + if (row <= 0) return row * getRowHeight(0); + if (row >= heights.length) { + return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H; + } + const wholeRow = Math.floor(row); + return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow); + }; + const getRowFromY = (contentY: number) => { + const y = contentY - RULER_H - TRACKS_TOP_PAD; + if (heights.length === 0) return y / TRACK_H; + if (y < 0) return y / getRowHeight(0); + const rowsHeight = offsets[heights.length] ?? 0; + if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H; + + // First boundary strictly greater than y. Unlike the old linear scan this + // stays logarithmic for large timelines and uses the precomputed offsets. + let low = 1; + let high = heights.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((offsets[mid] ?? 0) > y) high = mid; + else low = mid + 1; + } + const row = low - 1; + return row + (y - (offsets[row] ?? 0)) / getRowHeight(row); + }; + const geometry: TimelineRowGeometry = { + rowKeys: keys, + rowHeights: heights, + rowOffsets: offsets, + rowsHeight: offsets.at(-1) ?? 0, + canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD, + getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1, + getRowHeight, + getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row), + getRowFromY, + getRowPositionFromY: (contentY) => { + const rowFloat = getRowFromY(contentY); + const row = Math.floor(rowFloat); + return { rowFloat, row, fraction: rowFloat - row, rowHeight: getRowHeight(row) }; + }, + }; + const frozenGeometry = Object.freeze(geometry); + rowGeometryCache.set(heights, frozenGeometry); + return frozenGeometry; +} + +/** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */ +export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry { + const cached = rowGeometryCache.get(rowHeights); + if (cached) return cached; + const geometry = createTimelineRowGeometry( + rowHeights.map((_, row) => row), + rowHeights, + ); + rowGeometryCache.set(rowHeights, geometry); + return geometry; +} /** Cumulative top offsets, including the final bottom boundary. */ export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] { - const cached = rowOffsetsCache.get(rowHeights); - if (cached) return cached; - const offsets = [0]; - for (const height of rowHeights) { - offsets.push((offsets[offsets.length - 1] ?? 0) + validRowHeight(height)); - } - rowOffsetsCache.set(rowHeights, offsets); - return offsets; + return [...getTimelineRowGeometry(rowHeights).rowOffsets]; } -export function getTimelineRowHeight(row: number, rowHeights: readonly number[] = []): number { +export function getTimelineRowHeight( + row: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { return validRowHeight(rowHeights[row]); } function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number { - if (rowHeights.length === 0) return row * TRACK_H; - const offsets = getTimelineRowOffsets(rowHeights); - if (row <= 0) return row * getTimelineRowHeight(0, rowHeights); - if (row >= rowHeights.length) { - // Deliberately TRACK_H, not the last row's height: rows past the end do not - // exist yet, and a row created by dropping there starts unexpanded. The - // pre-first-row branch above uses row 0's concrete height instead because - // that row DOES exist — the pointer is in the top pad above a real lane. - return (offsets[rowHeights.length] ?? 0) + (row - rowHeights.length) * TRACK_H; - } - const wholeRow = Math.floor(row); - const fraction = row - wholeRow; - return (offsets[wholeRow] ?? 0) + fraction * getTimelineRowHeight(wholeRow, rowHeights); + return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD; } /** * The y (content-space) of the top edge of track ROW index `row` (0 = first - * displayed lane). The single source of truth for row→y — the ruler height plus + * displayed lane). The single source of truth for row->y: the ruler height plus * the top breathing pad plus whole track lanes above it. Every clip/ghost/ - * placeholder/insertion top and every pointer-y→row inversion goes through this + * placeholder/insertion top and every pointer-y->row inversion goes through this * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift. */ -export function getTimelineRowTop(row: number, rowHeights: readonly number[] = []): number { +export function getTimelineRowTop( + row: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights); } @@ -130,34 +209,18 @@ export function getTimelineRowTop(row: number, rowHeights: readonly number[] = [ * space y (used for insert-row / drop-lane decisions). Locates the concrete row * from cumulative offsets, then returns its local fractional position. */ -export function getTimelineRowFromY(contentY: number, rowHeights: readonly number[] = []): number { - const y = contentY - RULER_H - TRACKS_TOP_PAD; - if (rowHeights.length === 0) return y / TRACK_H; - if (y < 0) return y / getTimelineRowHeight(0, rowHeights); - - const offsets = getTimelineRowOffsets(rowHeights); - for (let row = 0; row < rowHeights.length; row += 1) { - const bottom = offsets[row + 1] ?? 0; - if (y < bottom) { - const top = offsets[row] ?? 0; - return row + (y - top) / getTimelineRowHeight(row, rowHeights); - } - } - return rowHeights.length + (y - (offsets[rowHeights.length] ?? 0)) / TRACK_H; +export function getTimelineRowFromY( + contentY: number, + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, +): number { + return getTimelineRowGeometry(rowHeights).getRowFromY(contentY); } export function getTimelineRowPositionFromY( contentY: number, - rowHeights: readonly number[] = [], + rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, ): { rowFloat: number; row: number; fraction: number; rowHeight: number } { - const rowFloat = getTimelineRowFromY(contentY, rowHeights); - const row = Math.floor(rowFloat); - return { - rowFloat, - row, - fraction: rowFloat - row, - rowHeight: getTimelineRowHeight(row, rowHeights), - }; + return getTimelineRowGeometry(rowHeights).getRowPositionFromY(contentY); } /** Fractional insert band for the concrete row under a pointer. */ @@ -360,8 +423,7 @@ export function getTimelineCanvasHeight(rowHeights: readonly number[]): number { // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space // below the last lane is real scrollable surface, not a hidden buffer. - const rowsHeight = getTimelineRowOffsets(rowHeights).at(-1) ?? 0; - return RULER_H + TRACKS_TOP_PAD + rowsHeight + TRACKS_BOTTOM_PAD; + return getTimelineRowGeometry(rowHeights).canvasHeight; } /* ── UI helpers ───────────────────────────────────────────────────── */ diff --git a/packages/studio/src/player/components/timelineViewportGeometry.ts b/packages/studio/src/player/components/timelineViewportGeometry.ts new file mode 100644 index 0000000000..ee86f834fa --- /dev/null +++ b/packages/studio/src/player/components/timelineViewportGeometry.ts @@ -0,0 +1,31 @@ +import { RULER_H, type TimelineRowGeometry } from "./timelineLayout"; +import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; + +export function getTimelineVisibleTimeRange( + viewport: Pick, + pixelsPerSecond: number, + contentOrigin: number, + duration: number, +): { start: number; end: number } { + if (!(pixelsPerSecond > 0) || !(duration > 0)) return { start: 0, end: 0 }; + const start = Math.max(0, (viewport.scrollLeft - contentOrigin) / pixelsPerSecond); + const end = Math.min( + duration, + Math.max(start, (viewport.scrollLeft + viewport.clientWidth - contentOrigin) / pixelsPerSecond), + ); + return { start: Math.min(start, duration), end }; +} + +export function getTimelineScrollTopForGeometryChange( + previous: TimelineRowGeometry, + next: TimelineRowGeometry, + scrollTop: number, +): number { + const anchor = previous.getRowPositionFromY(scrollTop + RULER_H); + if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop; + const anchorKey = previous.rowKeys[anchor.row]; + if (anchorKey === undefined) return scrollTop; + const nextRow = next.getRowIndex(anchorKey); + if (nextRow < 0) return scrollTop; + return Math.max(0, scrollTop + next.getRowTop(nextRow) - previous.getRowTop(anchor.row)); +} diff --git a/packages/studio/src/player/components/useTimelineActiveClips.ts b/packages/studio/src/player/components/useTimelineActiveClips.ts index 4971b88402..071ed267e4 100644 --- a/packages/studio/src/player/components/useTimelineActiveClips.ts +++ b/packages/studio/src/player/components/useTimelineActiveClips.ts @@ -13,7 +13,7 @@ interface ActiveClipRecord { interface UseTimelineActiveClipsInput { scrollRef: React.RefObject; currentTime: number; - clipStateVersion: string; + clipStateVersion: unknown; } function readFiniteNumber(value: string | undefined): number | null { diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index da366e6bdb..86ce7173e2 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -6,7 +6,6 @@ import { } from "./timelineEditing"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore"; -import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector"; import { mergeUserBeats } from "../../utils/beatEditing"; import { buildTimelineGroupResizeMembers, @@ -27,6 +26,8 @@ import type { BlockedClipState, } from "./timelineClipDragTypes"; import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle"; +import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; +import type { TimelineRowGeometry } from "./timelineLayout"; export type { DraggedClipState, @@ -42,7 +43,7 @@ interface UseTimelineClipDragInput { ppsRef: React.RefObject; durationRef: React.RefObject; trackOrderRef: React.RefObject; - rowHeightsRef?: React.RefObject; + rowGeometryRef?: React.RefObject; onMoveElement?: ( element: TimelineElement, updates: Pick, @@ -80,7 +81,7 @@ export function useTimelineClipDrag({ ppsRef, durationRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onMoveElement, onMoveElements, onResizeElement, @@ -96,12 +97,11 @@ export function useTimelineClipDrag({ const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES); const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES); const beatEdits = usePlayerStore((s) => s.beatEdits); - const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0); - const musicPlaybackStart = usePlayerStore( - (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0, - ); - const musicDuration = usePlayerStore((s) => s.elements.find(isMusicTrack)?.duration ?? 0); - const musicSrc = usePlayerStore((s) => s.elements.find(isMusicTrack)?.src ?? null); + const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); + const musicStart = musicElement?.start ?? 0; + const musicPlaybackStart = musicElement?.playbackStart ?? 0; + const musicDuration = musicElement?.duration ?? 0; + const musicSrc = musicElement?.src ?? null; const adjustedBeatTimes = useMemo(() => { if (rawBeatTimes === EMPTY_BEAT_TIMES || musicDuration === 0) return EMPTY_BEAT_TIMES; @@ -228,23 +228,21 @@ export function useTimelineClipDrag({ // Build the audio-track set once per gesture (see snapTargetsCacheRef): it // only feeds zone-aware drop placement and is frozen while dragging. if (!dragAudioTracksRef.current) { - dragAudioTracksRef.current = new Set( - elementsRef.current.filter(isAudioTimelineElement).map((e) => e.track), - ); + dragAudioTracksRef.current = getTimelineElementIndexes(elementsRef.current).audioTracks; } return computeDragPreview(drag, clientX, clientY, { scroll: scrollRef.current, pps: ppsRef.current, duration: durationRef.current, trackOrder: trackOrderRef.current, - rowHeights: rowHeightsRef?.current, + rowHeights: rowGeometryRef?.current.rowHeights, elements: elementsRef.current, selectedKeys: usePlayerStore.getState().selectedElementIds, buildSnapTargets, audioTracks: dragAudioTracksRef.current, }); }, - [scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, buildSnapTargets], + [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets], ); // Recompute the trim preview for a pointer x. Shared by the pointermove resize diff --git a/packages/studio/src/player/components/useTimelineGeometry.ts b/packages/studio/src/player/components/useTimelineGeometry.ts index 6241cda973..3c147718c3 100644 --- a/packages/studio/src/player/components/useTimelineGeometry.ts +++ b/packages/studio/src/player/components/useTimelineGeometry.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, type RefObject } from "react"; +import { useEffect, useRef, type RefObject } from "react"; import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore"; import { getTimelinePixelsPerSecond } from "./timelineZoom"; import { @@ -78,13 +78,6 @@ export function useTimelineGeometry({ resizeGhostEndPx, }); const displayDuration = pps > 0 ? displayContentWidth / pps : effectiveDuration; - const clipStateVersion = useMemo( - () => - expandedElements - .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`) - .join("|"), - [expandedElements], - ); const zoomModeRef = useRef(zoomMode); zoomModeRef.current = zoomMode; const manualZoomPercentRef = useRef(manualZoomPercent); @@ -92,7 +85,7 @@ export function useTimelineGeometry({ fitPpsRef.current = fitPps; // Restore the horizontal scroll offset after an edit re-derives the elements - // (clipStateVersion changes) so the reload doesn't jump the view. Only in manual + // (the immutable element snapshot changes) so the reload doesn't jump the view. Only in manual // (pinned) mode — fit mode hides the x-scrollbar (scrollLeft is always 0) — and // never mid-drag (auto-scroll owns the offset then). rAF waits for the new layout // so the clamp reads the post-resync scrollWidth. zoomMode is a legitimate dep: @@ -109,7 +102,7 @@ export function useTimelineGeometry({ }); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clipStateVersion, zoomMode]); + }, [expandedElements, zoomMode]); // Publish the live scale so edit handlers OUTSIDE (the keyboard-delete // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry. // In a useEffect (not the render body) so React-18 concurrent replay — Suspense @@ -125,7 +118,7 @@ export function useTimelineGeometry({ fitPps, displayContentWidth, displayDuration, - clipStateVersion, + clipStateVersion: expandedElements, zoomModeRef, manualZoomPercentRef, }; diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index c4cd930460..abc7d34e8c 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -16,6 +16,7 @@ import { type MarqueeClipInput, } from "./timelineMarquee"; import type { Rect } from "../../utils/marqueeGeometry"; +import type { TimelineRowGeometry } from "./timelineLayout"; interface UseTimelineRangeSelectionInput { scrollRef: React.RefObject; @@ -30,7 +31,7 @@ interface UseTimelineRangeSelectionInput { setShowPopover: (v: boolean) => void; elementsRef: React.RefObject; trackOrderRef: React.RefObject; - rowHeightsRef: React.RefObject; + rowGeometryRef: React.RefObject; onSelectElement?: (element: TimelineElement | null) => void; contentOrigin: number; } @@ -107,7 +108,7 @@ export function useTimelineRangeSelection({ setShowPopover, elementsRef, trackOrderRef, - rowHeightsRef, + rowGeometryRef, onSelectElement, contentOrigin, }: UseTimelineRangeSelectionInput) { @@ -176,12 +177,12 @@ export function useTimelineRangeSelection({ marquee, elementsRef.current ?? [], trackOrderRef.current ?? [], - rowHeightsRef.current, + rowGeometryRef.current.rowHeights, ppsRef.current, contentOrigin, ); }, - [toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin], + [toContentPoint, elementsRef, trackOrderRef, rowGeometryRef, ppsRef, contentOrigin], ); const stopMarqueeAutoScroll = useCallback(() => { diff --git a/packages/studio/src/player/components/useTimelineRangeSelectionScrub.test.tsx b/packages/studio/src/player/components/useTimelineRangeSelectionScrub.test.tsx index 61c93c6a40..6f8b32a77c 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelectionScrub.test.tsx +++ b/packages/studio/src/player/components/useTimelineRangeSelectionScrub.test.tsx @@ -8,6 +8,7 @@ import React, { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { mountReactHarness } from "../../hooks/domSelectionTestHarness"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; +import { getTimelineRowGeometry } from "./timelineLayout"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -60,7 +61,7 @@ function setup(): { handlers: () => Handlers; seekFromX: ReturnType Handlers; seekFromX: ReturnType { + vi.useFakeTimers(); + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + vi.useRealTimers(); + globalThis.ResizeObserver = originalResizeObserver; + resizeCallback = null; + document.body.innerHTML = ""; +}); + +describe("useTimelineScrollViewport", () => { + it("publishes resize, scroll, and settled snapshots", () => { + let hook: ReturnType | undefined; + function Probe() { + hook = useTimelineScrollViewport(useRef(null), []); + return null; + } + + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + const element = document.createElement("div"); + const values = { + left: 0, + top: 0, + width: 640, + height: 240, + scrollWidth: 1200, + scrollHeight: 800, + }; + Object.defineProperties(element, { + scrollLeft: { configurable: true, get: () => values.left }, + scrollTop: { configurable: true, get: () => values.top }, + clientWidth: { configurable: true, get: () => values.width }, + clientHeight: { configurable: true, get: () => values.height }, + scrollWidth: { configurable: true, get: () => values.scrollWidth }, + scrollHeight: { configurable: true, get: () => values.scrollHeight }, + }); + + act(() => hook?.setScrollRef(element)); + expect(hook?.viewport.clientWidth).toBe(640); + + values.width = 800; + act(() => resizeCallback?.([], {} as ResizeObserver)); + expect(hook?.viewport.clientWidth).toBe(800); + + values.left = 120; + values.top = 48; + act(() => hook?.syncScrollViewport(element, true)); + act(() => vi.advanceTimersByTime(16)); + expect(hook?.viewport).toMatchObject({ scrollLeft: 120, scrollTop: 48, isScrolling: true }); + + act(() => vi.advanceTimersByTime(100)); + expect(hook?.viewport.isScrolling).toBe(false); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineScrollViewport.ts b/packages/studio/src/player/components/useTimelineScrollViewport.ts index 55d5e9a2df..f08f558968 100644 --- a/packages/studio/src/player/components/useTimelineScrollViewport.ts +++ b/packages/studio/src/player/components/useTimelineScrollViewport.ts @@ -2,6 +2,44 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react" import { useMountEffect } from "../../hooks/useMountEffect"; import { shouldShowTimelineShortcutHint } from "./timelineLayout"; +export interface TimelineScrollViewportSnapshot { + readonly scrollLeft: number; + readonly scrollTop: number; + readonly clientWidth: number; + readonly clientHeight: number; + readonly scrollWidth: number; + readonly scrollHeight: number; + readonly isScrolling: boolean; +} + +const EMPTY_VIEWPORT: TimelineScrollViewportSnapshot = Object.freeze({ + scrollLeft: 0, + scrollTop: 0, + clientWidth: 0, + clientHeight: 0, + scrollWidth: 0, + scrollHeight: 0, + isScrolling: false, +}); + +function readTimelineScrollViewport( + element: Pick< + HTMLElement, + "scrollLeft" | "scrollTop" | "clientWidth" | "clientHeight" | "scrollWidth" | "scrollHeight" + >, + isScrolling: boolean, +): TimelineScrollViewportSnapshot { + return { + scrollLeft: element.scrollLeft, + scrollTop: element.scrollTop, + clientWidth: element.clientWidth, + clientHeight: element.clientHeight, + scrollWidth: element.scrollWidth, + scrollHeight: element.scrollHeight, + isScrolling, + }; +} + /** * The timeline scroll container's viewport plumbing — extracted verbatim from * Timeline.tsx (600-line studio cap): the ResizeObserver-backed viewport width, @@ -14,14 +52,40 @@ export function useTimelineScrollViewport( scrollRef: RefObject, resyncShortcutHintOn: ReadonlyArray, ): { - viewportWidth: number; + viewport: TimelineScrollViewportSnapshot; showShortcutHint: boolean; setScrollRef: (el: HTMLDivElement | null) => void; + syncScrollViewport: (el: HTMLDivElement, isScrolling?: boolean) => void; } { - const [viewportWidth, setViewportWidth] = useState(0); + const [viewport, setViewport] = useState(EMPTY_VIEWPORT); const [showShortcutHint, setShowShortcutHint] = useState(true); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); + const viewportRafRef = useRef(0); + const scrollSettledTimerRef = useRef | null>(null); + const scrollingRef = useRef(false); + + const syncScrollViewport = useCallback((el: HTMLDivElement, isScrolling = false) => { + scrollingRef.current = isScrolling; + const publish = () => { + viewportRafRef.current = 0; + setViewport(readTimelineScrollViewport(el, scrollingRef.current)); + }; + if (isScrolling) { + if (!viewportRafRef.current) viewportRafRef.current = requestAnimationFrame(publish); + } else { + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + publish(); + return; + } + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); + scrollSettledTimerRef.current = setTimeout(() => { + scrollSettledTimerRef.current = null; + scrollingRef.current = false; + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + publish(); + }, 100); + }, []); const syncShortcutHintVisibility = useCallback(() => { const scroll = scrollRef.current; @@ -45,23 +109,30 @@ export function useTimelineScrollViewport( roRef.current = null; } scrollRef.current = el; - if (!el) return; + if (!el) { + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); + scrollSettledTimerRef.current = null; + scrollingRef.current = false; + return; + } - const syncScrollViewport = () => { - setViewportWidth(el.clientWidth); + const syncResize = () => { + syncScrollViewport(el, scrollingRef.current); scheduleShortcutHintVisibilitySync(); }; - syncScrollViewport(); - roRef.current = new ResizeObserver(syncScrollViewport); + syncResize(); + roRef.current = new ResizeObserver(syncResize); roRef.current.observe(el); }, - [scrollRef, scheduleShortcutHintVisibilitySync], + [scrollRef, scheduleShortcutHintVisibilitySync, syncScrollViewport], ); useMountEffect(() => () => { roRef.current?.disconnect(); if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current); + if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current); + if (scrollSettledTimerRef.current) clearTimeout(scrollSettledTimerRef.current); }); useEffect(() => { @@ -69,5 +140,5 @@ export function useTimelineScrollViewport( // eslint-disable-next-line react-hooks/exhaustive-deps }, [syncShortcutHintVisibility, ...resyncShortcutHintOn]); - return { viewportWidth, showShortcutHint, setScrollRef }; + return { viewport, showShortcutHint, setScrollRef, syncScrollViewport }; } diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index dd07f7f376..51b1eefa6f 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -49,6 +49,8 @@ describe("useTimelineTrackLayout", () => { expect(layout?.laneCounts.get("clip-1")).toBe(1); expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]); + expect(layout?.rowGeometry.rowKeys).toEqual([0]); + expect(layout?.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H); act(() => root.unmount()); }); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 9bf17b8d65..0455293994 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -6,8 +6,8 @@ import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; import { TRACK_H, - getTimelineCanvasHeight, - getTimelineRowHeight, + createTimelineRowGeometry, + type TimelineRowGeometry, trackHeights, type TimelineTrackHeightClip, } from "./timelineLayout"; @@ -72,7 +72,7 @@ function useTimelineRowHeights( selectedElementIds: ReadonlySet, ) { const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); - const { laneCounts, rowHeights } = useMemo(() => { + const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); // Row height follows only the active keyframe clip, so a track with several // keyframed elements never reserves empty lanes for the ones not shown. @@ -87,14 +87,23 @@ function useTimelineRowHeights( const clipId = active.key ?? active.id; return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }]; }); + const rowHeights = trackHeights(heightTracks, expandedClipIds); return { laneCounts, - rowHeights: trackHeights(heightTracks, expandedClipIds), + rowGeometry: createTimelineRowGeometry( + tracks.map(([track]) => track), + rowHeights, + ), }; }, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]); - const rowHeightsRef = useRef(rowHeights); - rowHeightsRef.current = rowHeights; - return { laneCounts, rowHeights, rowHeightsRef }; + const rowGeometryRef = useRef(rowGeometry); + rowGeometryRef.current = rowGeometry; + return { + laneCounts, + rowGeometry, + rowGeometryRef, + rowHeights: rowGeometry.rowHeights, + }; } export function useTimelineTrackLayout( @@ -106,7 +115,7 @@ export function useTimelineTrackLayout( const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); const trackOrderRef = useRef(trackOrder); trackOrderRef.current = trackOrder; - const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights( + const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, selectedElementId, @@ -119,23 +128,23 @@ export function useTimelineTrackLayout( trackOrder, trackOrderRef, laneCounts, + rowGeometry, + rowGeometryRef, rowHeights, - rowHeightsRef, }; } function useDisplayRowHeights( displayTrackOrder: readonly number[], - trackOrder: readonly number[], - rowHeights: readonly number[], + rowGeometry: TimelineRowGeometry, ) { return useMemo( () => displayTrackOrder.map((track) => { - const row = trackOrder.indexOf(track); - return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights); + const row = rowGeometry.getRowIndex(track); + return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row); }), - [displayTrackOrder, trackOrder, rowHeights], + [displayTrackOrder, rowGeometry], ); } @@ -149,10 +158,18 @@ function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: export function useTimelineDisplayLayout( draggedClip: DraggedClipState | null, trackOrder: number[], - rowHeights: readonly number[], + rowGeometry: TimelineRowGeometry, ) { const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder); - const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights); - const totalH = getTimelineCanvasHeight(displayRowHeights); - return { displayTrackOrder, displayRowHeights, totalH }; + const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry); + const displayRowGeometry = useMemo( + () => createTimelineRowGeometry(displayTrackOrder, displayRowHeights), + [displayTrackOrder, displayRowHeights], + ); + return { + displayTrackOrder, + displayRowHeights: displayRowGeometry.rowHeights, + rowGeometry: displayRowGeometry, + totalH: displayRowGeometry.canvasHeight, + }; } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 726b64b3d6..9c17d60c50 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -39,6 +39,7 @@ import { import { normalizeToZones } from "../components/timelineZones"; import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers"; import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub"; +import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture"; import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe"; import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek"; import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore"; @@ -62,8 +63,11 @@ export function useTimelinePlayer() { const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } = usePlayerStore.getState(); + // The fixture lease belongs at this shared synchronization boundary so every + // iframe discovery path has the same owner for deciding whether it may write. const syncTimelineElements = useCallback( (elements: TimelineElement[], nextDuration?: number) => { + if (hasTimelinePerformanceFixtureLease()) return; const state = usePlayerStore.getState(); const resolvedDuration = nextDuration ?? state.duration; // applyCachedSourceDurations re-applies the cached probe duration: re-derived diff --git a/packages/studio/src/player/lib/playbackScrub.ts b/packages/studio/src/player/lib/playbackScrub.ts index 227c2a78c1..ae789e410e 100644 --- a/packages/studio/src/player/lib/playbackScrub.ts +++ b/packages/studio/src/player/lib/playbackScrub.ts @@ -1,6 +1,6 @@ import { usePlayerStore } from "../store/playerStore"; -import { isMusicTrack } from "../../utils/timelineInspector"; import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers"; +import { getTimelineElementIndexes } from "./timelineElementIndexes"; export { stopScrubPreviewAudio }; @@ -8,7 +8,7 @@ export { stopScrubPreviewAudio }; // Skipped when audio is muted or the time falls outside the music clip. export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void { const s = usePlayerStore.getState(); - const music = s.elements.find(isMusicTrack); + const music = getTimelineElementIndexes(s.elements).musicElement; if (!music || s.audioMuted) return; const rel = nextTime - music.start; const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null; diff --git a/packages/studio/src/player/lib/timelineElementIndexes.test.ts b/packages/studio/src/player/lib/timelineElementIndexes.test.ts new file mode 100644 index 0000000000..c1c088352f --- /dev/null +++ b/packages/studio/src/player/lib/timelineElementIndexes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIndexes } from "./timelineElementIndexes"; + +describe("getTimelineElementIndexes", () => { + const elements: TimelineElement[] = [ + { id: "hero", tag: "img", src: "hero.png", start: 0, duration: 2, track: 0 }, + { + id: "bgm", + tag: "audio", + src: "music.wav", + start: 0, + duration: 10, + track: 3, + timelineRole: "music", + }, + ]; + + it("indexes media and music identities", () => { + const indexes = getTimelineElementIndexes(elements); + expect(indexes.byKey.get("hero")).toBe(elements[0]); + expect(indexes.musicElement).toBe(elements[1]); + expect(indexes.mediaElements).toEqual(elements); + expect(indexes.audioTracks).toEqual(new Set([3])); + }); + + it("reuses one index for the same immutable array snapshot", () => { + expect(getTimelineElementIndexes(elements)).toBe(getTimelineElementIndexes(elements)); + expect(getTimelineElementIndexes([...elements])).not.toBe(getTimelineElementIndexes(elements)); + }); +}); diff --git a/packages/studio/src/player/lib/timelineElementIndexes.ts b/packages/studio/src/player/lib/timelineElementIndexes.ts new file mode 100644 index 0000000000..bf5fc065f1 --- /dev/null +++ b/packages/studio/src/player/lib/timelineElementIndexes.ts @@ -0,0 +1,44 @@ +import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; +import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIdentity } from "./timelineElementHelpers"; + +export interface TimelineElementIndexes { + readonly byKey: ReadonlyMap; + readonly musicElement: TimelineElement | null; + readonly mediaElements: readonly TimelineElement[]; + readonly audioTracks: ReadonlySet; +} + +const indexCache = new WeakMap(); + +/** + * Index a store element snapshot once. Playback-only Zustand updates keep the + * same array identity, so selectors can reuse this object without rescanning a + * large timeline or triggering a component render. + */ +export function getTimelineElementIndexes( + elements: readonly TimelineElement[], +): TimelineElementIndexes { + const cached = indexCache.get(elements); + if (cached) return cached; + + const byKey = new Map(); + const mediaElements: TimelineElement[] = []; + const audioTracks = new Set(); + let musicElement: TimelineElement | null = null; + for (const element of elements) { + byKey.set(getTimelineElementIdentity(element), element); + if (element.src) mediaElements.push(element); + if (isAudioTimelineElement(element)) audioTracks.add(element.track); + if (!musicElement && isMusicTrack(element)) musicElement = element; + } + + const indexes = Object.freeze({ + byKey, + musicElement, + mediaElements: Object.freeze(mediaElements), + audioTracks, + }); + indexCache.set(elements, indexes); + return indexes; +} diff --git a/packages/studio/src/player/lib/timelinePerformanceFixture.ts b/packages/studio/src/player/lib/timelinePerformanceFixture.ts index d49c7b1b98..e3fe74fb3d 100644 --- a/packages/studio/src/player/lib/timelinePerformanceFixture.ts +++ b/packages/studio/src/player/lib/timelinePerformanceFixture.ts @@ -29,6 +29,7 @@ export interface TimelinePerformanceFixture { } const TRACK_COUNT = 1_000; +let fixtureLeaseActive = false; const PROFILE_GEOMETRY: Readonly< Record > = Object.freeze({ @@ -39,6 +40,15 @@ const PROFILE_GEOMETRY: Readonly< "remote-unsupported": { duration: 900, clipDuration: 12 }, }); +/** Prevent live iframe discovery from replacing an explicitly loaded dev fixture. */ +export function setTimelinePerformanceFixtureLease(active: boolean): void { + fixtureLeaseActive = active; +} + +export function hasTimelinePerformanceFixtureLease(): boolean { + return fixtureLeaseActive; +} + 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"); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 1eb3f72c56..b9b1157b22 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -441,6 +441,21 @@ describe("usePlayerStore", () => { }); describe("reset", () => { + it("increments the session epoch only for a hard project switch", () => { + usePlayerStore.getState().beginTimelineSession("project-a"); + const firstEpoch = usePlayerStore.getState().timelineSessionEpoch; + + usePlayerStore.getState().reset(); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch); + + usePlayerStore.getState().beginTimelineSession("project-a"); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch); + + usePlayerStore.getState().beginTimelineSession("project-b"); + expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1); + expect(usePlayerStore.getState().timelineProjectId).toBe("project-b"); + }); + it("resets all state to defaults", () => { // Mutate everything const store = usePlayerStore.getState(); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 18c085d044..309917900f 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -106,6 +106,10 @@ interface PlayerState extends KeyframeSlice { currentTime: number; duration: number; timelineReady: boolean; + /** Increments exactly once when the Studio switches to a different project. */ + timelineSessionEpoch: number; + /** Project owning the current timeline session; null outside a project-scoped reset. */ + timelineProjectId: string | null; /** True while a beat dot is being dragged — hides the playhead guideline. */ beatDragging: boolean; elements: TimelineElement[]; @@ -206,6 +210,9 @@ interface PlayerState extends KeyframeSlice { bumpZEditVersion: () => void; setInPoint: (time: number | null) => void; setOutPoint: (time: number | null) => void; + /** Owns the hard project boundary; repeated calls for one project are no-ops. */ + beginTimelineSession: (projectId: string) => void; + /** Clears project data without creating a new hard-project session. */ reset: () => void; /** @@ -329,6 +336,8 @@ export const usePlayerStore = create((set, get) => ({ currentTime: 0, duration: 0, timelineReady: false, + timelineSessionEpoch: 0, + timelineProjectId: null, beatDragging: false, elements: [], selectedElementId: null, @@ -555,9 +564,18 @@ export const usePlayerStore = create((set, get) => ({ (el.key ?? el.id) === elementId ? { ...el, ...updates } : el, ), })), - // 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. + // playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are + // intentionally absent from createTimelineResetState because they are user + // preferences that survive both source refreshes and project switches. + beginTimelineSession: (projectId) => + set((state) => { + if (state.timelineProjectId === projectId) return state; + return { + ...createTimelineResetState(), + timelineSessionEpoch: state.timelineSessionEpoch + 1, + timelineProjectId: projectId, + }; + }), reset: () => set(createTimelineResetState()), })); diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html index 2846170bf5..5363b13f5c 100644 --- a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html @@ -17,6 +17,7 @@
node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight, - ); + const scroller = root.querySelector("[data-timeline-scroll-viewport]"); if (!(scroller instanceof HTMLElement)) throw new Error("Timeline scroller not mounted"); return scroller; } @@ -247,15 +245,14 @@ try { if (TIER === "low-resource") { await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }); } - await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.goto(STUDIO_URL, { waitUntil: "networkidle0", timeout: 60_000 }); await page.waitForFunction( () => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function", { timeout: 30_000 }, ); + await waitForStudioTestHookSettle(page); - await page.evaluate((profile) => { - window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); - }, PROFILE); + await loadFixtureAndWait(page, 1_000, PROFILE); await client.send("HeapProfiler.collectGarbage"); const baselineHeapBytes = await collectHeapBytes(client); @@ -263,12 +260,7 @@ try { 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 summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE); const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); const runs = []; @@ -294,9 +286,8 @@ try { 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 waitForStudioTestHookSettle(page); + await loadFixtureAndWait(page, 1_000, PROFILE); await client.send("HeapProfiler.collectGarbage"); const returnedHeapBytes = await collectHeapBytes(client); const memoryReturned = @@ -359,3 +350,53 @@ try { await browser.close(); } process.exit(exitCode); + +async function waitForFixtureRender(page, elementCount) { + const deadline = Date.now() + 60_000; + let observed = null; + while (Date.now() < deadline) { + observed = await page.evaluate(() => ({ + modelCount: window.__playerStore?.getState().elements.length ?? null, + renderedCount: + document + .querySelector('[aria-label="Timeline"]') + ?.getAttribute("data-timeline-element-count") ?? null, + })); + if (observed.renderedCount === String(elementCount)) { + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timeline fixture ${elementCount} did not render: ${JSON.stringify(observed)}`); +} + +async function loadFixtureAndWait(page, elementCount, profile) { + const summary = await page.evaluate( + ({ count, fixtureProfile }) => + window.__studioTest.loadTimelinePerformanceFixture({ + elementCount: count, + profile: fixtureProfile, + }), + { count: elementCount, fixtureProfile: profile }, + ); + await waitForFixtureRender(page, elementCount); + return summary; +} + +async function waitForStudioTestHookSettle(page) { + await page.evaluate(async () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + for (;;) { + const candidate = window.__studioTest; + await nextFrame(); + await nextFrame(); + if ( + candidate === window.__studioTest && + typeof candidate?.loadTimelinePerformanceFixture === "function" + ) { + return; + } + } + }); +}