diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 99228e92f..1c3022c85 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -36,6 +36,7 @@ import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; +import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -276,6 +277,11 @@ export const Timeline = memo(function Timeline({ }); const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); + const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ + totalClipCount: expandedElements.length, + totalRowCount: displayLayout.displayTrackOrder.length, + zoomMode, + }); const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ timelineReady, expandedElements.length, @@ -469,6 +475,7 @@ export const Timeline = memo(function Timeline({ className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`} onScroll={(e) => { lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload + recordTimelineScroll(e.currentTarget); }} onDragOver={handleAssetDragOver} onDragLeave={() => clearDropPreview()} diff --git a/packages/studio/src/player/components/useTimelinePerformanceTelemetry.test.ts b/packages/studio/src/player/components/useTimelinePerformanceTelemetry.test.ts new file mode 100644 index 000000000..39129196f --- /dev/null +++ b/packages/studio/src/player/components/useTimelinePerformanceTelemetry.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { summarizeTimelinePerformance } from "./useTimelinePerformanceTelemetry"; + +describe("summarizeTimelinePerformance", () => { + it("reports raw mounted work and p95 scroll timings", () => { + const scroll = document.createElement("div"); + Object.defineProperties(scroll, { + clientWidth: { value: 1_200 }, + clientHeight: { value: 360 }, + }); + scroll.innerHTML = ` +
+
+
+
+ `; + + expect( + summarizeTimelinePerformance( + scroll, + { totalClipCount: 3_000, totalRowCount: 24, zoomMode: "fit" }, + [8, 10, 12, 80], + [16, 17, 45], + ), + ).toEqual({ + total_clip_count: 3_000, + mounted_clip_count: 2, + total_row_count: 24, + timeline_dom_node_count: 4, + viewport_width: 1_200, + viewport_height: 360, + zoom_mode: "fit", + scroll_sample_count: 4, + scroll_frame_latency_p95_ms: 80, + scroll_frame_latency_max_ms: 80, + frame_interval_p95_ms: 45, + }); + }); + + it("does not emit a summary without a completed animation frame", () => { + const scroll = document.createElement("div"); + + expect( + summarizeTimelinePerformance( + scroll, + { totalClipCount: 1, totalRowCount: 1, zoomMode: "manual" }, + [], + [], + ), + ).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/useTimelinePerformanceTelemetry.ts b/packages/studio/src/player/components/useTimelinePerformanceTelemetry.ts new file mode 100644 index 000000000..fb88fb40b --- /dev/null +++ b/packages/studio/src/player/components/useTimelinePerformanceTelemetry.ts @@ -0,0 +1,132 @@ +import { useRef } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; +import { + trackStudioTimelinePerformance, + type StudioTimelinePerformanceSample, +} from "../../telemetry/events"; + +const SCROLL_IDLE_MS = 400; +const MIN_EVENT_INTERVAL_MS = 60_000; + +interface TimelinePerformanceState { + frameRequest: number; + idleTimer: ReturnType | null; + pendingScrollStartedAt: number; + previousFrameAt: number | null; + frameLatencies: number[]; + frameIntervals: number[]; + lastEmittedAt: number; +} + +interface TimelinePerformanceContext { + totalClipCount: number; + totalRowCount: number; + zoomMode: string; +} + +function percentile(values: readonly number[], fraction: number): number | undefined { + if (values.length === 0) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1); + return Number(sorted[Math.max(0, index)].toFixed(2)); +} + +export function summarizeTimelinePerformance( + scroll: HTMLElement, + context: TimelinePerformanceContext, + frameLatencies: readonly number[], + frameIntervals: readonly number[], +): StudioTimelinePerformanceSample | null { + const latencyP95 = percentile(frameLatencies, 0.95); + if (latencyP95 === undefined) return null; + + const frameIntervalP95 = percentile(frameIntervals, 0.95); + return { + total_clip_count: context.totalClipCount, + mounted_clip_count: scroll.querySelectorAll('[data-clip="true"]').length, + total_row_count: context.totalRowCount, + timeline_dom_node_count: scroll.querySelectorAll("*").length, + viewport_width: scroll.clientWidth, + viewport_height: scroll.clientHeight, + zoom_mode: context.zoomMode, + scroll_sample_count: frameLatencies.length, + scroll_frame_latency_p95_ms: latencyP95, + scroll_frame_latency_max_ms: Number(Math.max(...frameLatencies).toFixed(2)), + ...(frameIntervalP95 === undefined ? {} : { frame_interval_p95_ms: frameIntervalP95 }), + }; +} + +function resetMeasurements(state: TimelinePerformanceState): void { + state.previousFrameAt = null; + state.frameLatencies = []; + state.frameIntervals = []; +} + +/** + * Samples one event per timeline scroll burst, capped at one event per minute. + * The event contains only aggregate counts and timings—never project content. + */ +export function useTimelinePerformanceTelemetry(context: TimelinePerformanceContext): { + recordTimelineScroll: (scroll: HTMLDivElement) => void; +} { + const stateRef = useRef({ + frameRequest: 0, + idleTimer: null, + pendingScrollStartedAt: 0, + previousFrameAt: null, + frameLatencies: [], + frameIntervals: [], + lastEmittedAt: Number.NEGATIVE_INFINITY, + }); + + const recordTimelineScroll = (scroll: HTMLDivElement) => { + const state = stateRef.current; + const now = performance.now(); + + if (state.frameRequest === 0) { + state.pendingScrollStartedAt = now; + state.frameRequest = requestAnimationFrame((frameAt) => { + state.frameRequest = 0; + state.frameLatencies.push(frameAt - state.pendingScrollStartedAt); + if (state.previousFrameAt !== null) { + state.frameIntervals.push(frameAt - state.previousFrameAt); + } + state.previousFrameAt = frameAt; + }); + } + + if (state.idleTimer !== null) clearTimeout(state.idleTimer); + state.idleTimer = setTimeout(() => { + state.idleTimer = null; + if (state.frameRequest !== 0) { + cancelAnimationFrame(state.frameRequest); + state.frameRequest = 0; + resetMeasurements(state); + return; + } + + const emittedAt = performance.now(); + if (emittedAt - state.lastEmittedAt >= MIN_EVENT_INTERVAL_MS) { + const sample = summarizeTimelinePerformance( + scroll, + context, + state.frameLatencies, + state.frameIntervals, + ); + if (sample) { + trackStudioTimelinePerformance(sample); + state.lastEmittedAt = emittedAt; + } + } + resetMeasurements(state); + }, SCROLL_IDLE_MS); + }; + + useMountEffect(() => () => { + const state = stateRef.current; + if (state.frameRequest !== 0) cancelAnimationFrame(state.frameRequest); + if (state.idleTimer !== null) clearTimeout(state.idleTimer); + }); + + return { recordTimelineScroll }; +} diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index 041f1cfe0..3e543b850 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -15,6 +15,7 @@ const { trackStudioKeyframeLaneExpand, trackStudioSegmentEaseEdit, trackStudioFeedback, + trackStudioTimelinePerformance, } = await import("./events"); describe("studio telemetry events", () => { @@ -63,6 +64,26 @@ describe("studio telemetry events", () => { }); }); + it("trackStudioTimelinePerformance emits raw timeline measurements", () => { + const sample = { + total_clip_count: 3_000, + mounted_clip_count: 160, + total_row_count: 24, + timeline_dom_node_count: 957, + viewport_width: 1_200, + viewport_height: 360, + zoom_mode: "fit", + scroll_sample_count: 20, + scroll_frame_latency_p95_ms: 24.5, + scroll_frame_latency_max_ms: 31.2, + frame_interval_p95_ms: 42.3, + }; + + trackStudioTimelinePerformance(sample); + + expect(trackEvent).toHaveBeenCalledWith("studio_timeline_performance", sample); + }); + it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => { trackStudioRazorSplit({ mode: "all", count: 3 }); expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 }); diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index 156baa81c..a2f3e4b17 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -26,6 +26,24 @@ export function trackStudioRenderStart(props: { }); } +export type StudioTimelinePerformanceSample = { + total_clip_count: number; + mounted_clip_count: number; + total_row_count: number; + timeline_dom_node_count: number; + viewport_width: number; + viewport_height: number; + zoom_mode: string; + scroll_sample_count: number; + scroll_frame_latency_p95_ms: number; + scroll_frame_latency_max_ms: number; + frame_interval_p95_ms?: number; +}; + +export function trackStudioTimelinePerformance(props: StudioTimelinePerformanceSample): void { + trackEvent("studio_timeline_performance", props); +} + function getBrowserDoctorSummary(): string { try { const nav = navigator as Navigator & {