feat(studio): track timeline performance (#2898)

This commit is contained in:
Miguel Ángel
2026-07-30 18:53:02 +02:00
committed by GitHub
parent 1481fe1ed9
commit 10b517dab9
5 changed files with 232 additions and 0 deletions
@@ -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()}
@@ -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 = `
<div>
<div data-clip="true"></div>
<div data-clip="true"><span></span></div>
</div>
`;
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();
});
});
@@ -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<typeof setTimeout> | 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<TimelinePerformanceState>({
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 };
}