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 & {