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/4] 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/4] 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/4] 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/4] 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