mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
feat(studio): track timeline performance (#2898)
This commit is contained in:
@@ -36,6 +36,7 @@ import { useTrackGapMenu } from "./useTrackGapMenu";
|
|||||||
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||||
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
||||||
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
||||||
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
||||||
|
|
||||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||||
export {
|
export {
|
||||||
@@ -276,6 +277,11 @@ export const Timeline = memo(function Timeline({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
|
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
|
||||||
|
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
|
||||||
|
totalClipCount: expandedElements.length,
|
||||||
|
totalRowCount: displayLayout.displayTrackOrder.length,
|
||||||
|
zoomMode,
|
||||||
|
});
|
||||||
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
|
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
|
||||||
timelineReady,
|
timelineReady,
|
||||||
expandedElements.length,
|
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`}
|
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
|
lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
|
||||||
|
recordTimelineScroll(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
onDragOver={handleAssetDragOver}
|
onDragOver={handleAssetDragOver}
|
||||||
onDragLeave={() => clearDropPreview()}
|
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 };
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const {
|
|||||||
trackStudioKeyframeLaneExpand,
|
trackStudioKeyframeLaneExpand,
|
||||||
trackStudioSegmentEaseEdit,
|
trackStudioSegmentEaseEdit,
|
||||||
trackStudioFeedback,
|
trackStudioFeedback,
|
||||||
|
trackStudioTimelinePerformance,
|
||||||
} = await import("./events");
|
} = await import("./events");
|
||||||
|
|
||||||
describe("studio telemetry 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", () => {
|
it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => {
|
||||||
trackStudioRazorSplit({ mode: "all", count: 3 });
|
trackStudioRazorSplit({ mode: "all", count: 3 });
|
||||||
expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
|
expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
|
||||||
|
|||||||
@@ -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 {
|
function getBrowserDoctorSummary(): string {
|
||||||
try {
|
try {
|
||||||
const nav = navigator as Navigator & {
|
const nav = navigator as Navigator & {
|
||||||
|
|||||||
Reference in New Issue
Block a user